fix: address release v2 reviews

This commit is contained in:
ajnart committed 2026-08-14 02:18:46 +02:00
1 parent 4905ea6738
commit 32732680e5
55 files changed
+925 -294

No files matched your search

+3
View File
@@ -98,6 +98,9 @@ jobs:
- name: Validate feature contracts
run: pnpm check:feature-contracts
- name: Test feature platform
run: pnpm test:feature-platform
- name: Validate Custom Widget bundle budget
run: |
pnpm --filter nextjs build
+24
View File
@@ -86,6 +86,30 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Docker action payload migration
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
Replace the old payload:
```json
{ "ids": ["container-id"] }
```
with endpoint-qualified targets:
```json
{
"targets": [{ "endpointId": "local", "id": "container-id" }]
}
```
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
Docker or Podman endpoints from targeting the wrong service.
## Partial integration failures
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
@@ -1,9 +1,14 @@
import { describe, expect, test } from "vitest";
import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox";
import {
dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox,
getValidDockerServiceUrl,
} from "./docker-reconciliation-inbox";
const candidates = [
{ candidateKey: "home:new", state: "newRecognized" as const },
{ candidateKey: "home:new-app", state: "newApp" as const },
{ candidateKey: "remote:moved", state: "moved" as const },
{ candidateKey: "home:linked", state: "linked" as const },
{ candidateKey: "home:represented", state: "represented" as const },
@@ -13,7 +18,7 @@ describe("Docker reconciliation inbox", () => {
test("separates attention items from represented services", () => {
expect(
filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey),
).toEqual(["home:new", "remote:moved"]);
).toEqual(["home:new", "home:new-app", "remote:moved"]);
expect(
filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey),
).toEqual(["home:linked", "home:represented"]);
@@ -31,4 +36,10 @@ describe("Docker reconciliation inbox", () => {
});
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
});
test("accepts only HTTP service URLs", () => {
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
});
});
@@ -23,3 +23,12 @@ export const filterDockerReconciliationInbox = <Candidate extends DockerReconcil
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
export const getValidDockerServiceUrl = (value: string) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
};
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Alert,
Avatar,
@@ -23,10 +23,15 @@ import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox";
import {
dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox,
getValidDockerServiceUrl,
} from "./docker-reconciliation-inbox";
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
@@ -44,6 +49,12 @@ export const DockerReconciliation = () => {
utils.docker.getServiceHealth.invalidate(),
]);
},
onError() {
showErrorNotification({
title: t("refreshError.title"),
message: t("refreshError.message"),
});
},
});
const [filter, setFilter] = useState<DockerReconciliationInboxFilter>("attention");
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
@@ -51,6 +62,19 @@ export const DockerReconciliation = () => {
defaultValue: [],
});
if (reconciliation.isError) {
return (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
<Stack gap="sm">
<Text size="sm">{t("loadError.message")}</Text>
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
{t("action.retry")}
</Button>
</Stack>
</Alert>
);
}
if (!reconciliation.data) return null;
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
@@ -156,7 +180,16 @@ const DockerReconciliationCandidate = ({
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
const target = getCandidateTarget(candidate, url);
const validatedUrl = getValidDockerServiceUrl(url);
const isInvalidUrl = url.length > 0 && validatedUrl === null;
const target = getCandidateTarget(candidate, validatedUrl ?? "");
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
useEffect(() => {
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
setUrl(initialUrlCandidate?.url ?? "");
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
return (
<Card withBorder padding="md">
@@ -224,8 +257,11 @@ const DockerReconciliationCandidate = ({
}}
/>
<TextInput
label={t("url.inputLabel")}
aria-label={t("url.inputLabel")}
value={url}
placeholder="https://service.example.com"
error={isInvalidUrl ? t("url.invalid") : undefined}
onChange={(event) => setUrl(event.currentTarget.value)}
/>
@@ -237,14 +273,27 @@ const DockerReconciliationCandidate = ({
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
disabled={isActionDisabled}
onClick={() =>
openModal({ selectedContainers: [candidate.container], initialUrls: url ? [url] : undefined })
openModal({
selectedContainers: [candidate.container],
initialUrls: validatedUrl ? [validatedUrl] : undefined,
})
}
>
{t("action.createApp")}
</Button>
) : (
<Button component={Link} href={target.href} variant="light" rightSection={<IconArrowRight size={16} />}>
<Button
component={Link}
href={target.href}
variant="light"
rightSection={<IconArrowRight size={16} />}
disabled={isActionDisabled}
onClick={(event) => {
if (isActionDisabled) event.preventDefault();
}}
>
{t(`action.${target.kind}`)}
</Button>
)}
@@ -24,10 +24,11 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
const { containerId } = await params;
const { endpointId, name } = await searchParams;
if (!endpointId) notFound();
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
if (!resolvedEndpointId) notFound();
try {
await api.docker.logs({ endpointId, id: containerId, tail: 1 });
await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
} catch {
notFound();
}
@@ -41,8 +42,17 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal endpointId={endpointId} containerId={containerId} />
<ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} containerId={containerId} />
</Box>
</>
);
}
const getOnlyDockerEndpointIdAsync = async () => {
try {
const { endpoints } = await api.docker.getContainers();
return endpoints.length === 1 ? endpoints[0]?.id : undefined;
} catch {
return undefined;
}
};
@@ -4,69 +4,31 @@ export type ResponseFixtureId = "loading" | "success" | "failure";
export interface CatalogStateFixture {
id: CatalogFixtureId;
label: string;
status: string;
description: string;
color: "green" | "yellow" | "gray" | "red";
}
export const catalogStateFixtures: readonly CatalogStateFixture[] = [
{
id: "ready",
label: "Ready service",
status: "Ready",
description: "Configured and available to three widgets.",
color: "green",
},
{
id: "needsSetup",
label: "Service needing setup",
status: "Needs setup",
description: "Credentials or a service URL are still required.",
color: "yellow",
},
{
id: "noConnection",
label: "Standalone feature",
status: "No connection required",
description: "This feature works without a configured integration.",
color: "gray",
},
{
id: "error",
label: "Unreachable service",
status: "Connection failed",
description: "The saved connection could not be reached.",
color: "red",
},
{ id: "ready", color: "green" },
{ id: "needsSetup", color: "yellow" },
{ id: "noConnection", color: "gray" },
{ id: "error", color: "red" },
];
export const catalogInteractionFixtures: readonly { id: CatalogInteractionId; label: string }[] = [
{ id: "default", label: "Default" },
{ id: "selected", label: "Selected" },
{ id: "loading", label: "Loading" },
{ id: "disabled", label: "Disabled" },
export const catalogInteractionFixtures: readonly CatalogInteractionId[] = [
"default",
"selected",
"loading",
"disabled",
];
export const responseStateFixtures = [
{
id: "loading",
label: "Loading",
title: "Checking response",
description: "The parser is waiting for the integration response.",
},
{ id: "loading" },
{
id: "success",
label: "Success",
title: "Response accepted",
description: "The upstream payload matched the integration contract.",
payload: { status: "ok", service: "fixture-service", version: "2.0" },
},
{
id: "failure",
label: "Failure",
title: "Response rejected",
description: "The upstream payload failed schema validation and stays isolated from the widget.",
payload: { status: 503, message: "Service unavailable" },
},
] as const;
@@ -10,6 +10,21 @@ import { FeatureStateWorkbench } from "./_feature-state-workbench";
import { catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
({
"catalog.state.needsSetup.label": "Service needing setup",
"catalog.state.needsSetup.status": "Needs setup",
"catalog.interaction.loading": "Loading",
"catalog.interaction.disabled": "Disabled",
"response.state.failure.label": "Failure",
"response.state.failure.title": "Response rejected",
"response.contractPassed": "Fixture contract passed",
"catalog.loadingAriaLabel": "Loading catalog item",
"catalog.loading": "Loading",
})[key] ?? key,
}));
let root: Root;
let host: HTMLDivElement;
@@ -17,6 +17,7 @@ import {
} from "@mantine/core";
import { IconAlertCircle, IconCircleCheck, IconPlugConnected, IconPlugConnectedX } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { CatalogItem } from "@homarr/ui";
import { catalogInteractionFixtures, catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
@@ -28,6 +29,7 @@ interface FeatureStateWorkbenchProps {
}
export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbenchProps) {
const t = useScopedI18n("featureWorkbench");
const [catalogFixtureId, setCatalogFixtureId] = useState<CatalogFixtureId>("ready");
const [interactionId, setInteractionId] = useState<CatalogInteractionId>("default");
const [responseFixtureId, setResponseFixtureId] = useState<ResponseFixtureId>("loading");
@@ -38,6 +40,8 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
const busy = interactionId === "loading";
const disabled = interactionId === "disabled";
const selected = interactionId === "selected";
const catalogLabel = t(`catalog.state.${catalogFixture.id}.label`);
const catalogStatus = busy ? t("catalog.loading") : t(`catalog.state.${catalogFixture.id}.status`);
return (
<Stack gap="xl" maw={960}>
@@ -45,34 +49,40 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
<Stack gap="lg">
<div>
<Title id="catalog-workbench-title" order={2} size="h3">
Catalog item states
{t("catalog.title")}
</Title>
<Text c="dimmed" size="sm" maw={680}>
Compare the shared selection surface with stable service and interaction fixtures.
{t("catalog.description")}
</Text>
</div>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Stack gap="xs">
<Text size="sm" fw={600} id="catalog-fixture-label">
Service state
{t("catalog.serviceState")}
</Text>
<SegmentedControl
aria-labelledby="catalog-fixture-label"
value={catalogFixtureId}
onChange={(value) => setCatalogFixtureId(value as CatalogFixtureId)}
data={catalogStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.status }))}
data={catalogStateFixtures.map((fixture) => ({
value: fixture.id,
label: t(`catalog.state.${fixture.id}.status`),
}))}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={600} id="catalog-interaction-label">
Interaction state
{t("catalog.interactionState")}
</Text>
<SegmentedControl
aria-labelledby="catalog-interaction-label"
value={interactionId}
onChange={(value) => setInteractionId(value as CatalogInteractionId)}
data={catalogInteractionFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))}
data={catalogInteractionFixtures.map((id) => ({
value: id,
label: t(`catalog.interaction.${id}`),
}))}
/>
</Stack>
</SimpleGrid>
@@ -80,15 +90,15 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<CatalogItem
height={140}
label={catalogFixture.label}
status={busy ? "Loading" : catalogFixture.status}
label={catalogLabel}
status={catalogStatus}
selected={selected}
busy={busy}
disabled={disabled}
onSelect={() => setInteractionId(selected ? "default" : "selected")}
>
{busy ? (
<Stack gap="sm" aria-label="Loading catalog item">
<Stack gap="sm" aria-label={t("catalog.loadingAriaLabel")}>
<Skeleton height={24} width="65%" />
<Skeleton height={16} />
<Skeleton height={20} width="40%" />
@@ -105,15 +115,15 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} size="sm">
{catalogFixture.label}
{catalogLabel}
</Text>
<Text size="xs" c="dimmed">
{catalogFixture.description}
{t(`catalog.state.${catalogFixture.id}.description`)}
</Text>
</Stack>
</Group>
<Badge color={catalogFixture.color} variant="light" size="sm" w="fit-content">
{catalogFixture.status}
{t(`catalog.state.${catalogFixture.id}.status`)}
</Badge>
</Stack>
)}
@@ -121,13 +131,13 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
<Stack gap="xs" justify="center">
<Text size="sm" fw={600}>
Semantic output
{t("catalog.semanticOutput")}
</Text>
<Code
block
>{`aria-label="${catalogFixture.label}, ${busy ? "Loading" : catalogFixture.status}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`}</Code>
>{`aria-label="${catalogLabel}, ${catalogStatus}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`}</Code>
<Text size="xs" c="dimmed">
Tab to the item to inspect its visible focus ring. Select it with Enter or Space.
{t("catalog.instructions")}
</Text>
</Stack>
</SimpleGrid>
@@ -138,23 +148,25 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
<Stack gap="lg">
<div>
<Title id="response-workbench-title" order={2} size="h3">
Integration response contract
{t("response.title")}
</Title>
<Text c="dimmed" size="sm" maw={680}>
Inspect waiting, accepted, and rejected response fixtures using the same parser contract as generated
integrations.
{t("response.description")}
</Text>
</div>
<SegmentedControl
aria-label="Response fixture"
aria-label={t("response.fixtureLabel")}
value={responseFixtureId}
onChange={(value) => setResponseFixtureId(value as ResponseFixtureId)}
data={responseStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))}
data={responseStateFixtures.map((fixture) => ({
value: fixture.id,
label: t(`response.state.${fixture.id}.label`),
}))}
/>
{responseFixture.id === "loading" ? (
<Stack gap="sm" role="status" aria-label="Checking response">
<Stack component="output" gap="sm" aria-label={t("response.checking")}>
<Group justify="space-between">
<Skeleton height={22} width={180} />
<Skeleton height={22} width={72} />
@@ -165,16 +177,14 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
<Alert
color={responseFixture.id === "success" ? "green" : "red"}
icon={responseFixture.id === "success" ? <IconCircleCheck /> : <IconAlertCircle />}
title={responseFixture.title}
title={t(`response.state.${responseFixture.id}.title`)}
role={responseFixture.id === "failure" ? "alert" : "status"}
>
<Stack gap="sm">
<Text size="sm">{responseFixture.description}</Text>
<Text size="sm">{t(`response.state.${responseFixture.id}.description`)}</Text>
<Code block>{JSON.stringify(responseFixture.payload, null, 2)}</Code>
<Badge color={responseResult?.passed ? "green" : "red"} variant="light" w="fit-content">
{responseResult?.passed
? "Fixture contract passed"
: (responseResult?.message ?? "Fixture contract failed")}
{responseResult?.passed ? t("response.contractPassed") : t("response.contractFailed")}
</Badge>
</Stack>
</Alert>
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { FeatureStateWorkbench } from "./_feature-state-workbench";
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
@@ -10,15 +11,15 @@ export default async function FeatureWorkbenchPage() {
if (process.env.NODE_ENV === "production") notFound();
const session = await auth();
if (!session?.user.permissions.includes("admin")) notFound();
const t = await getScopedI18n("featureWorkbench.page");
const responseResults = await getResponseContractFixtureResultsAsync();
return (
<Stack gap="xl">
<div>
<Title>Native feature state workbench</Title>
<Title>{t("title")}</Title>
<Text c="dimmed" maw={720}>
A development-only fixture surface for reviewing shared catalog and integration response states without live
services.
{t("description")}
</Text>
</div>
<FeatureStateWorkbench responseResults={responseResults} />
@@ -2,7 +2,7 @@
import { useEffect } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Alert, Badge, Group, Select } from "@mantine/core";
import { Alert, Badge, Button, Group, Select, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -14,7 +14,7 @@ export const KubernetesContextSelector = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
const { data, isError, refetch } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
refetchInterval: 30_000,
refetchOnMount: "always",
refetchOnReconnect: true,
@@ -40,6 +40,19 @@ export const KubernetesContextSelector = () => {
router.replace(`${pathname}?${next.toString()}`);
}, [contextId, pathname, requestedContextId, router, searchParams]);
if (isError) {
return (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
<Stack gap="sm">
<Text size="sm">{t("loadError.message")}</Text>
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void refetch()}>
{t("loadError.retry")}
</Button>
</Stack>
</Alert>
);
}
if (!data || !contextId) return null;
const selectContext = (value: string | null) => {
@@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => {
boardName: "Home",
kind: "audioStats",
summary: "Show audio statistics",
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"],
integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"],
},
settings,
[
@@ -66,7 +66,10 @@ export const BoardSetupChecklist = () => {
</ActionIcon>
</Tooltip>
</Group>
<Progress value={(progress.completedCount / progress.totalCount) * 100} aria-label={t("progress")} />
<Progress
value={progress.totalCount === 0 ? 0 : (progress.completedCount / progress.totalCount) * 100}
aria-label={t("progress")}
/>
<ChecklistStep
complete={progress.steps.content}
label={t("step.content.label")}
@@ -86,6 +86,7 @@ const createItemLayouts = (
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
: undefined;
const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main";
if (currentSection.kind === "container" && !containerLayout) return [];
const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0);
if (columnCount === 0) return [];
const size = {
@@ -169,6 +169,27 @@ describe("item actions create-item", () => {
]);
});
test("omits layouts where the requested container has no layout entry", () => {
const board = new BoardMockBuilder()
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
.addEmptySection({ id: "canvas" })
.addSection(
new ContainerSectionMockBuilder({ id: "media" })
.addLayout({ layoutId: "base", parentSectionId: "canvas", width: 4, height: 3 })
.build(),
)
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) throw new Error("Expected a base layout");
baseLayout.id = "base";
const result = createItemCallback({ id: "calendar", kind: "clock", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "base", sectionId: "media" }),
]);
});
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 })
@@ -15,11 +15,12 @@ describe("getBoardRecipeRecommendations", () => {
preferredIntegrationKind: "jellyfin",
});
expect(recommendations).toHaveLength(3);
expect(recommendations.slice(0, 2).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
expect(recommendations.at(2)).toEqual(
expect.objectContaining({ integrationKind: "piHole", isNewlyAvailable: false }),
);
const firstFallbackIndex = recommendations.findIndex(({ isNewlyAvailable }) => !isNewlyAvailable);
expect(recommendations.some(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
if (firstFallbackIndex !== -1) {
expect(recommendations.slice(0, firstFallbackIndex).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
expect(recommendations.slice(firstFallbackIndex).every(({ isNewlyAvailable }) => !isNewlyAvailable)).toBe(true);
}
});
test("does not recommend widget kinds already present on the board", () => {
@@ -29,7 +30,8 @@ describe("getBoardRecipeRecommendations", () => {
preferredIntegrationKind: "piHole",
});
expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls"]);
expect(recommendations.every(({ widgetKind }) => widgetKind !== "dnsHoleSummary")).toBe(true);
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
});
test("deduplicates configured kinds and emitted widget capabilities", () => {
@@ -39,7 +41,9 @@ describe("getBoardRecipeRecommendations", () => {
limit: 10,
});
expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls", "dnsHoleSummary"]);
const widgetKinds = recommendations.map(({ widgetKind }) => widgetKind);
expect(new Set(widgetKinds).size).toBe(widgetKinds.length);
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
});
test("falls back to other configured integrations when the new service has no missing widgets", () => {
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, 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";
@@ -94,10 +94,16 @@ const ItemSelectModalContent = ({
],
[board.sections, currentLayout, mainCanvasSection, t],
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(mainCanvasSection?.id ?? null);
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const { createItem, removeItem } = useItemActions();
useEffect(() => {
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
setTargetSectionId(placementOptions[0]?.value ?? null);
}, [placementOptions, targetSectionId]);
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const settings = useSettings();
@@ -155,14 +161,32 @@ const ItemSelectModalContent = ({
});
};
const notifyCreated = (itemId: string, name: string) => {
const notifyCreated = (updatedBoard: ReturnType<typeof createItem>, itemId: string, name: string) => {
const createdItem = updatedBoard?.items.find((item) => item.id === itemId);
if (!createdItem) {
showErrorNotification({
title: t("item.create.notification.error.title"),
message: t("item.create.notification.error.message"),
});
return false;
}
const actualSectionId =
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
createdItem.layouts[0]?.sectionId;
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
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>
<Text size="sm">
{wasRelocated
? t("item.create.notification.success.relocatedMessage", { name, destination: actualDestination })
: t("item.create.notification.success.message", { name })}
</Text>
<Button
variant="subtle"
size="compact-xs"
@@ -177,6 +201,7 @@ const ItemSelectModalContent = ({
),
autoClose: 10_000,
});
return true;
};
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
@@ -204,7 +229,7 @@ const ItemSelectModalContent = ({
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({
const updatedBoard = createItem({
id: itemId,
kind: "customApi",
integrationIds: [],
@@ -212,7 +237,7 @@ const ItemSelectModalContent = ({
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
notifyCreated(itemId, customWidgetDefinition.name);
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
},
integrationData: [],
integrationSupport: false,
@@ -247,8 +272,8 @@ const ItemSelectModalContent = ({
ensureDataAsync: ensureIntegrationDataAsync,
});
const maxIntegrations = (definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity;
const integrationIds = matchingIntegrations.slice(0, maxIntegrations).map((i) => i.id);
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const openEditor = (
@@ -267,7 +292,7 @@ const ItemSelectModalContent = ({
integrationIds: selectedIntegrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
createItem({
const updatedBoard = createItem({
id: itemId,
kind,
options,
@@ -275,7 +300,7 @@ const ItemSelectModalContent = ({
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
notifyCreated(itemId, t(`widget.${kind}.name`));
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
trackSetup("widget-completed", {
entryPoint: "board",
intent: kind,
@@ -295,7 +320,7 @@ const ItemSelectModalContent = ({
const integrationsRequired =
hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
if (integrationsRequired && integrationIds.length === 0) {
if (integrationsRequired && matchingIntegrationCount === 0) {
trackSetup("dependency-blocked", {
entryPoint: "board",
intent: kind,
@@ -12,7 +12,15 @@ describe("SetupMetricProperties", () => {
"hasBoardContext",
"canResolveInline",
] satisfies (keyof SetupMetricProperties)[];
type ExpectedKey = (typeof allowedKeys)[number];
type ActualKey = keyof SetupMetricProperties;
const exactContract: [ActualKey] extends [ExpectedKey]
? [ExpectedKey] extends [ActualKey]
? true
: false
: false = true;
expect(exactContract).toBe(true);
expect(allowedKeys).not.toContain("url");
expect(allowedKeys).not.toContain("query");
expect(allowedKeys).not.toContain("recordId");
@@ -3,6 +3,8 @@
import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
export type SetupMetricEvent =
| "surface-opened"
@@ -30,7 +32,15 @@ export interface SetupMetricProperties {
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
*/
export const useSetupAnalytics = () => {
const { mutate } = clientApi.analytics.trackFeature.useMutation();
const t = useI18n();
const { mutate } = clientApi.analytics.trackFeature.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: t("universalCreate.analyticsError"),
});
},
});
return useCallback(
(event: SetupMetricEvent, properties: SetupMetricProperties) => {
@@ -173,6 +173,6 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
</Stack>
);
}).withOptions({
defaultTitle: "universalCreate.title",
defaultTitle: (t) => t("universalCreate.title"),
size: "lg",
});
@@ -174,6 +174,10 @@ Every resource uses `{ endpointId, id }` from inventory through query keys, acti
Bulk lifecycle and removal mutations return a result for every target. Management and Widget surfaces report partial failures instead of treating a resolved request as universal success. Removal uses one shared confirmation contract on both surfaces.
This changes the tRPC and MCP action input from `{ ids: string[] }` to endpoint-qualified
`{ targets: Array<{ endpointId: string; id: string }> }`. Existing automation must read both fields from the container
inventory and pass them unchanged to start, stop, restart, and remove actions.
The continuous reconciliation surface:
- Preserves multiple instances of the same Integration kind.
@@ -27,4 +27,37 @@ describe("getTrackedFeatureProperties", () => {
userId: "user-1",
});
});
test("discards setup values outside the closed telemetry contract", () => {
expect(
getTrackedFeatureProperties(
"setup:widget-completed",
{
entryPoint: "https://private.example",
intent: "secret-id",
outcome: "credential",
elapsedMs: Number.POSITIVE_INFINITY,
hasBoardContext: "true",
canResolveInline: 1,
},
"session-user",
),
).toEqual({});
});
test("keeps known widget intents and bounded elapsed times", () => {
expect(
getTrackedFeatureProperties(
"setup:widget-completed",
{ entryPoint: "board", intent: "weather", outcome: "completed", elapsedMs: 0, canResolveInline: false },
"session-user",
),
).toEqual({ entryPoint: "board", intent: "weather", outcome: "completed", elapsedMs: 0, canResolveInline: false });
});
test("keeps the app intent used by Universal Create", () => {
expect(
getTrackedFeatureProperties("setup:intent-selected", { entryPoint: "header", intent: "app" }, "user-1"),
).toEqual({ entryPoint: "header", intent: "app" });
});
});
+27 -10
View File
@@ -1,11 +1,30 @@
const setupPropertyKeys = new Set([
"entryPoint",
"intent",
"outcome",
"elapsedMs",
"hasBoardContext",
"canResolveInline",
import { widgetKinds } from "@homarr/definitions";
const setupEntryPoints = new Set(["header", "spotlight", "board", "assistant", "docker", "management"]);
const setupIntents = new Set([
...widgetKinds,
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
"add-compatible-widget",
]);
const setupOutcomes = new Set(["completed", "blocked", "continued"]);
const maximumSetupElapsedMs = 24 * 60 * 60 * 1_000;
const isValidSetupProperty = (key: string, value: unknown) => {
if (key === "entryPoint") return typeof value === "string" && setupEntryPoints.has(value);
if (key === "intent") return typeof value === "string" && setupIntents.has(value);
if (key === "outcome") return typeof value === "string" && setupOutcomes.has(value);
if (key === "elapsedMs") {
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= maximumSetupElapsedMs;
}
if (key === "hasBoardContext" || key === "canResolveInline") return typeof value === "boolean";
return false;
};
export const getTrackedFeatureProperties = (
feature: string,
@@ -15,8 +34,6 @@ export const getTrackedFeatureProperties = (
if (!feature.startsWith("setup:")) return { ...properties, userId };
return Object.fromEntries(
Object.entries(properties ?? {}).filter(
([key, value]) => setupPropertyKeys.has(key) && ["string", "number", "boolean"].includes(typeof value),
),
Object.entries(properties ?? {}).filter(([key, value]) => isValidSetupProperty(key, value)),
);
};
+1 -1
View File
@@ -562,7 +562,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission",
"Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId, name, layoutId }. Requires board-create permission",
},
})
.input(boardCreateSchema)
@@ -26,6 +26,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
}),
db.query.apps.findMany(),
]);
const normalizedIntegrations = integrations.map((integration) => ({
integration,
normalizedUrl: normalizeDockerServiceUrl(integration.url),
}));
const normalizedApps = apps.map((app) => ({ app, normalizedUrl: normalizeDockerServiceUrl(app.href) }));
const containerNameCounts = new Map<string, number>();
for (const container of data.containers) {
const name = normalizeName(container.name);
@@ -44,13 +49,16 @@ export const getDockerReconciliationAsync = async (db: Database) => {
urlCandidates.map(({ url }) => normalizeDockerServiceUrl(url)).filter((url) => url !== null),
);
const uniqueName = containerNameCounts.get(normalizeName(container.name)) === 1;
const sameKindIntegrations = match ? integrations.filter(({ kind }) => kind === match.kind) : [];
const exactIntegrationMatches = sameKindIntegrations.filter(({ url }) => {
const normalizedUrl = normalizeDockerServiceUrl(url);
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
});
const sameKindIntegrations = match
? normalizedIntegrations.filter(({ integration }) => integration.kind === match.kind)
: [];
const exactIntegrationMatches = sameKindIntegrations
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
.map(({ integration }) => integration);
const nameIntegrationMatches = uniqueName
? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
? sameKindIntegrations
.filter(({ integration }) => normalizeName(integration.name) === normalizeName(container.name))
.map(({ integration }) => integration)
: [];
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
const nameIntegration =
@@ -58,10 +66,9 @@ export const getDockerReconciliationAsync = async (db: Database) => {
? nameIntegrationMatches[0]
: undefined;
const integration = exactIntegration ?? nameIntegration;
const exactAppMatches = apps.filter(({ href }) => {
const normalizedUrl = normalizeDockerServiceUrl(href);
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
});
const exactAppMatches = normalizedApps
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
.map(({ app }) => app);
const nameAppMatches = uniqueName
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
: [];
@@ -19,18 +19,40 @@ const dockerContainerTargetSchema = z.object({
endpointId: z.string().min(1),
id: z.string().min(1),
});
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100);
export const dockerRouter = createTRPCRouter({
reconcileServices: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Reconcile Docker container inventory with Homarr integrations and apps, including URL matches and recommended setup or repair actions. Requires admin permission.",
},
})
.concat(dockerMiddleware())
.query(async ({ ctx }) => await getDockerReconciliationAsync(ctx.db)),
getServiceHealth: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get Docker endpoint and discovered-service health projected across Docker, integration, app, and widget setup layers. Requires admin permission.",
},
})
.concat(dockerMiddleware())
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
refreshInventory: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Invalidate the cached Docker inventory so the next Docker query performs a fresh user-requested discovery. Requires admin permission.",
},
})
.concat(dockerMiddleware())
.mutation(() => {
dockerContainersRequestHandler.invalidateCache();
@@ -65,7 +87,7 @@ export const dockerRouter = createTRPCRouter({
},
})
.concat(dockerMiddleware())
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
.input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => {
return await performDockerContainerActionsAsync(input.targets, "start");
}),
@@ -78,7 +100,7 @@ export const dockerRouter = createTRPCRouter({
},
})
.concat(dockerMiddleware())
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
.input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => {
return await performDockerContainerActionsAsync(input.targets, "stop");
}),
@@ -91,7 +113,7 @@ export const dockerRouter = createTRPCRouter({
},
})
.concat(dockerMiddleware())
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
.input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => {
return await performDockerContainerActionsAsync(input.targets, "restart");
}),
@@ -105,7 +127,7 @@ export const dockerRouter = createTRPCRouter({
},
})
.concat(dockerMiddleware())
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
.input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => {
return await performDockerContainerActionsAsync(input.targets, "remove");
}),
@@ -280,7 +280,7 @@ export const integrationRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it.",
"Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it. Returns integration details and appId, which is null when no app is linked or created.",
},
})
.input(integrationCreateSchema)
@@ -99,6 +99,30 @@ describe("KubernetesClientRegistry", () => {
}
});
test("aborts a timed-out node metrics request", async () => {
vi.useFakeTimers();
try {
const client = createRegistry().getClient("home");
let observedSignal: AbortSignal | undefined;
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockImplementation(async (_request, options) => {
const middleware = (
options as { middleware?: { pre?: (request: unknown) => { toPromise: () => Promise<unknown> } }[] }
).middleware?.[0];
await middleware?.pre?.({ setSignal: (signal: AbortSignal) => (observedSignal = signal) }).toPromise();
return await new Promise<never>(() => undefined);
});
const resultPromise = client.getNodeMetricsAsync(50);
const rejection = expect(resultPromise).rejects.toThrow("Kubernetes context probe timed out");
await vi.advanceTimersByTimeAsync(50);
await rejection;
expect(observedSignal?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
test("rejects unknown context IDs", () => {
expect(() => createRegistry().getClient("missing")).toThrow(KubernetesContextNotFoundError);
});
@@ -1,6 +1,6 @@
import * as fs from "fs";
import type { RequestContext, ResponseContext } from "@kubernetes/client-node";
import { CoreV1Api, CustomObjectsApi, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
import type { NodeMetricsList, RequestContext, ResponseContext } from "@kubernetes/client-node";
import { CoreV1Api, CustomObjectsApi, KubeConfig, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
import { of } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
import { env } from "../../env";
@@ -55,7 +55,6 @@ export class KubernetesClient {
public kubeConfig: KubeConfig;
public coreApi: CoreV1Api;
public networkingApi: NetworkingV1Api;
public metricsApi: Metrics;
public metricsProbeApi: CustomObjectsApi;
public versionApi: VersionApi;
@@ -63,11 +62,22 @@ export class KubernetesClient {
this.kubeConfig = kubeConfig;
this.coreApi = kubeConfig.makeApiClient(CoreV1Api);
this.networkingApi = kubeConfig.makeApiClient(NetworkingV1Api);
this.metricsApi = new Metrics(kubeConfig);
this.metricsProbeApi = kubeConfig.makeApiClient(CustomObjectsApi);
this.versionApi = kubeConfig.makeApiClient(VersionApi);
}
public async getNodeMetricsAsync(timeoutMs = kubernetesContextProbeTimeoutMs): Promise<NodeMetricsList> {
const result: unknown = await withKubernetesProbeTimeoutAsync(
async (signal) =>
await this.metricsProbeApi.listClusterCustomObject(
{ group: "metrics.k8s.io", version: "v1beta1", plural: "nodes" },
abortableKubernetesRequest(signal),
),
timeoutMs,
);
return result as NodeMetricsList;
}
public static getInstance(contextId: string): KubernetesClient {
return KubernetesClient.getRegistry().getClient(contextId);
}
@@ -125,14 +135,7 @@ export class KubernetesClientRegistry {
async (signal) => await client.versionApi.getCode(undefined, abortableKubernetesRequest(signal)),
timeoutMs,
),
withKubernetesProbeTimeoutAsync(
async (signal) =>
await client.metricsProbeApi.listClusterCustomObject(
{ group: "metrics.k8s.io", version: "v1beta1", plural: "nodes" },
abortableKubernetesRequest(signal),
),
timeoutMs,
),
client.getNodeMetricsAsync(timeoutMs),
]);
const status =
api.status === "rejected" ? "unavailable" : metrics.status === "rejected" ? "degraded" : "available";
@@ -16,14 +16,15 @@ export const clusterRouter = createTRPCRouter({
.concat(kubernetesMiddleware())
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesCluster> => {
const { coreApi, metricsApi, versionApi, kubeConfig } = getKubernetesClient(input.contextId);
const client = getKubernetesClient(input.contextId);
const { coreApi, versionApi, kubeConfig } = client;
try {
const [versionInfo, nodes, listPodForAllNamespaces, nodeMetricsClient] = await Promise.all([
versionApi.getCode(),
coreApi.listNode(),
coreApi.listPodForAllNamespaces(),
metricsApi.getNodeMetrics().catch(() => null),
client.getNodeMetricsAsync().catch(() => null),
]);
let totalCPUCapacity = 0;
@@ -5,6 +5,13 @@ import { KubernetesClient } from "../kubernetes-client";
export const contextsRouter = createTRPCRouter({
getContexts: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List configured Kubernetes contexts with availability, metrics status, and default-context metadata. Requires admin permission.",
},
})
.concat(kubernetesMiddleware())
.query(async () => await KubernetesClient.getContextsAsync()),
});
@@ -10,6 +10,13 @@ import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-conte
export const ingressesRouter = createTRPCRouter({
getIngresses: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List Kubernetes ingress inventory for a context. Get the required contextId from kubernetes_contexts_getContexts. Requires admin permission.",
},
})
.concat(kubernetesMiddleware())
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesIngress[]> => {
@@ -15,12 +15,13 @@ export const nodesRouter = createTRPCRouter({
.concat(kubernetesMiddleware())
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesNode[]> => {
const { coreApi, metricsApi } = getKubernetesClient(input.contextId);
const client = getKubernetesClient(input.contextId);
const { coreApi } = client;
try {
const [nodes, nodeMetricsClient] = await Promise.all([
coreApi.listNode(),
metricsApi.getNodeMetrics().catch(() => null),
client.getNodeMetricsAsync().catch(() => null),
]);
const cpuResourceParser = new CpuResourceParser();
const memoryResourceParser = new MemoryResourceParser();
@@ -14,6 +14,13 @@ const logger = createLogger({ module: "podsRouter" });
export const podsRouter = createTRPCRouter({
getPods: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List Kubernetes pod inventory and inferred workload types for a context. Get the required contextId from kubernetes_contexts_getContexts. Requires admin permission.",
},
})
.concat(kubernetesMiddleware())
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesPod[]> => {
@@ -184,6 +184,20 @@ describe("All procedures should only be accessible for users with admin permissi
});
describe("Docker action targeting", () => {
test.each(["startAll", "stopAll", "restartAll", "removeAll"] as const)(
"%s requires a bounded non-empty target list",
async (procedure) => {
const caller = createAdminCaller();
await expect(caller[procedure]({ targets: [] })).rejects.toThrow();
await expect(
caller[procedure]({
targets: Array.from({ length: 101 }, (_, index) => ({ endpointId: "endpoint", id: String(index) })),
}),
).rejects.toThrow();
},
);
test("enforces endpoint capabilities before running a lifecycle action", async () => {
hasDockerEndpointCapabilityMock.mockReturnValueOnce(false);
+3 -3
View File
@@ -89,7 +89,7 @@ const parseTransport = (value: unknown, index: number): DockerEndpointTransport
};
const parseCapabilities = (value: unknown, index: number): DockerEndpointCapability[] => {
if (value === undefined) return allCapabilities;
if (value === undefined) return [...allCapabilities];
if (!Array.isArray(value) || value.some((capability) => !dockerEndpointCapabilities.includes(capability))) {
throw new Error(`DOCKER_ENDPOINTS[${index}].capabilities contains an unsupported capability`);
}
@@ -136,7 +136,7 @@ export const createLegacySocketDescriptor = (path: string): DockerEndpointDescri
name: path,
kind: path.toLowerCase().includes("podman") ? "podman" : "docker",
transport: { type: "socket", path },
capabilities: allCapabilities,
capabilities: [...allCapabilities],
scope: "admin",
source: "legacy",
});
@@ -146,7 +146,7 @@ export const createLegacyTcpDescriptor = (host: string, port: number): DockerEnd
name: `${host}:${port}`,
kind: "docker",
transport: { type: "tcp", host, port, allowInsecure: true },
capabilities: allCapabilities,
capabilities: [...allCapabilities],
scope: "admin",
source: "legacy",
});
+2 -2
View File
@@ -1,7 +1,7 @@
import type Docker from "dockerode";
import type { DockerEndpointCapability } from "./endpoint-descriptor";
export type { DockerInstance } from "./singleton";
export type { DockerEndpointInitializationFailure, DockerInstance } from "./singleton";
export { DockerSingleton } from "./singleton";
export * from "./endpoint-descriptor";
export type { ContainerInfo, Container, Port } from "dockerode";
@@ -19,7 +19,7 @@ export interface DockerContainerTarget {
export interface DockerEndpointStatus {
id: string;
name: string;
status: "available" | "unavailable";
status: "available" | "degraded" | "unavailable";
kind?: "docker" | "podman";
transport?: "socket" | "tcp" | "tls";
capabilities?: DockerEndpointCapability[];
+45 -5
View File
@@ -1,6 +1,9 @@
import { readFileSync } from "node:fs";
import Docker from "dockerode";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { env } from "./env";
import type { DockerEndpointCapability, DockerEndpointDescriptor } from "./endpoint-descriptor";
import {
@@ -18,11 +21,40 @@ export interface DockerInstance {
instance: Docker;
}
export interface DockerEndpointInitializationFailure {
descriptor: DockerEndpointDescriptor;
host: string;
}
const logger = createLogger({ module: "dockerSingleton" });
export class DockerSingleton {
private static instances: DockerInstance[] | null = null;
private static initializationFailures: DockerEndpointInitializationFailure[] = [];
private createInstanceSafely(descriptor: DockerEndpointDescriptor): DockerInstance[] {
try {
return [createInstance(descriptor)];
} catch (error) {
const host = getDockerEndpointHost(descriptor);
DockerSingleton.initializationFailures.push({ descriptor, host });
logger.warn(
new ErrorWithMetadata(
"Failed to initialize Docker endpoint",
{ endpointId: descriptor.id, host },
{ cause: error },
),
);
return [];
}
}
private createInstances() {
if (env.DOCKER_ENDPOINTS) return parseDockerEndpointDescriptors(env.DOCKER_ENDPOINTS).map(createInstance);
if (env.DOCKER_ENDPOINTS) {
return parseDockerEndpointDescriptors(env.DOCKER_ENDPOINTS).flatMap((descriptor) =>
this.createInstanceSafely(descriptor),
);
}
const socketPaths = env.DOCKER_SOCKET_PATHS;
const hostVariable = env.DOCKER_HOSTNAMES;
@@ -30,7 +62,9 @@ export class DockerSingleton {
// Socket instances from DOCKER_SOCKET_PATHS
const socketInstances: DockerInstance[] = socketPaths
? socketPaths.split(",").map((socketPath) => createInstance(createLegacySocketDescriptor(socketPath)))
? socketPaths
.split(",")
.flatMap((socketPath) => this.createInstanceSafely(createLegacySocketDescriptor(socketPath)))
: [];
// TCP instances from existing DOCKER_HOSTNAMES/DOCKER_PORTS
@@ -43,9 +77,9 @@ export class DockerSingleton {
throw new Error("The number of hosts and ports must match");
}
tcpInstances = hostnames.map((host, i) =>
tcpInstances = hostnames.flatMap((host, i) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
createInstance(createLegacyTcpDescriptor(host, parseInt(ports[i]!, 10))),
this.createInstanceSafely(createLegacyTcpDescriptor(host, parseInt(ports[i]!, 10))),
);
}
@@ -59,7 +93,7 @@ export class DockerSingleton {
name: "Local Docker",
source: "default" as const,
};
return [{ ...createInstance(descriptor), host: "socket", instance: new Docker() }];
return [createInstance(descriptor)];
}
return instances;
@@ -78,9 +112,15 @@ export class DockerSingleton {
return this.instances;
}
this.initializationFailures = [];
this.instances = new DockerSingleton().createInstances();
return this.instances;
}
public static getInitializationFailures(): DockerEndpointInitializationFailure[] {
this.getInstances();
return [...this.initializationFailures];
}
}
const createInstance = (descriptor: DockerEndpointDescriptor): DockerInstance => ({
@@ -57,4 +57,15 @@ describe("parseDockerEndpointDescriptors", () => {
),
).toThrow("must include inventory");
});
test("returns independent default capability arrays", () => {
const [first, second] = parseDockerEndpointDescriptors(
JSON.stringify([
{ id: "first", name: "First", transport: { type: "socket", path: "/first.sock" } },
{ id: "second", name: "Second", transport: { type: "socket", path: "/second.sock" } },
]),
);
expect(first?.capabilities).not.toBe(second?.capabilities);
});
});
+19 -1
View File
@@ -40,8 +40,10 @@ describe("DockerSingleton", () => {
const instances = DockerSingleton.getInstances();
expect(instances).toHaveLength(1);
expect(instances[0]?.host).toBe("socket");
expect(instances[0]?.host).toBe("/var/run/docker.sock");
expect(instances[0]).toMatchObject({ endpointId: "socket:default", endpointName: "Local Docker" });
const options = (instances[0]?.instance as unknown as { opts: { socketPath: string } } | undefined)?.opts;
expect(options?.socketPath).toBe("/var/run/docker.sock");
});
test("should return TCP instances only when DOCKER_HOSTNAMES and DOCKER_PORTS set", () => {
@@ -131,4 +133,20 @@ describe("DockerSingleton", () => {
expect(DockerSingleton.hasCapability("readonly-podman", "logs")).toBe(true);
expect(DockerSingleton.hasCapability("readonly-podman", "remove")).toBe(false);
});
test("isolates an unreadable TLS endpoint from healthy endpoints", () => {
mockEnv.DOCKER_ENDPOINTS = JSON.stringify([
{ id: "local", name: "Local", transport: { type: "socket", path: "/var/run/docker.sock" } },
{
id: "broken-tls",
name: "Broken TLS",
transport: { type: "tls", host: "broken.example", port: 2376, caPath: "/missing/docker-ca.pem" },
},
]);
expect(DockerSingleton.getInstances().map(({ endpointId }) => endpointId)).toEqual(["local"]);
expect(DockerSingleton.getInitializationFailures()).toEqual([
expect.objectContaining({ descriptor: expect.objectContaining({ id: "broken-tls" }) }),
]);
});
});
@@ -68,4 +68,41 @@ describe("ConfirmModal", () => {
expect(confirmButton.disabled).toBe(false);
expect(closeModal).not.toHaveBeenCalled();
});
test("waits for a rejecting confirmation button callback", async () => {
const closeModal = vi.fn();
const onConfirm = vi.fn();
let rejectClick!: (error: Error) => void;
const onClick = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectClick = reject;
}),
);
await act(async () =>
root.render(
<MantineProvider>
<ConfirmModalComponent
actions={{ closeModal }}
innerProps={{ children: "Remove containers?", confirmProps: { onClick }, onConfirm }}
/>
</MantineProvider>,
),
);
const confirmButton = host.querySelectorAll("button").item(1);
await act(async () => confirmButton.click());
expect(confirmButton.disabled).toBe(true);
expect(onConfirm).not.toHaveBeenCalled();
await act(async () => {
rejectClick(new Error("Callback failed"));
await Promise.resolve();
});
expect(confirmButton.disabled).toBe(false);
expect(onConfirm).not.toHaveBeenCalled();
expect(closeModal).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -61,7 +61,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
setLoading(true);
try {
if (typeof confirmProps?.onClick === "function") {
confirmProps.onClick(event);
await confirmProps.onClick(event);
}
if (typeof onConfirm === "function") {
+65 -33
View File
@@ -41,7 +41,7 @@ const withDockerTimeoutAsync = async <T>(
}
};
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const isDemoMode = () => ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const port = (privatePort: number, publicPort: number, type: string): Port => ({
IP: "0.0.0.0",
@@ -197,7 +197,7 @@ const mockContainers: {
export const dockerContainersRequestHandler = createWidgetRequestHandler({
async requestAsync() {
if (isDemoMode) {
if (isDemoMode()) {
return {
containers: mockContainers.map((container) => ({
...container,
@@ -228,7 +228,10 @@ const extractImage = (container: ContainerInfo) => extractContainerImageName(con
export const hasDockerEndpointCapability = (
endpointId: string,
capability: "inventory" | "logs" | "lifecycle" | "remove",
) => DockerSingleton.hasCapability(endpointId, capability);
) =>
isDemoMode()
? endpointId === "demo" && capability === "inventory"
: DockerSingleton.hasCapability(endpointId, capability);
export const findDockerContainerAsync = async (
{ endpointId, id }: DockerContainerTarget,
@@ -302,6 +305,7 @@ export const streamContainerLogsAsync = async (
export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
const dockerInstances = DockerSingleton.getInstances();
const initializationFailures = DockerSingleton.getInitializationFailures();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
const instanceContainers = await withDockerTimeoutAsync(
@@ -315,20 +319,32 @@ export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpoi
}),
);
const endpoints = results.map((result, index) => {
const dockerInstance = dockerInstances.at(index);
if (!dockerInstance) throw new Error("Docker endpoint result did not match a configured endpoint");
return {
id: dockerInstance.endpointId,
name: dockerInstance.endpointName,
status: result.status === "fulfilled" ? "available" : "unavailable",
kind: dockerInstance.descriptor.kind,
transport: dockerInstance.descriptor.transport.type,
capabilities: dockerInstance.descriptor.capabilities,
source: dockerInstance.descriptor.source,
scope: dockerInstance.descriptor.scope,
} satisfies DockerEndpointStatus;
});
const endpoints = [
...results.map((result, index) => {
const dockerInstance = dockerInstances.at(index);
if (!dockerInstance) throw new Error("Docker endpoint result did not match a configured endpoint");
return {
id: dockerInstance.endpointId,
name: dockerInstance.endpointName,
status: result.status === "fulfilled" ? ("available" as const) : ("unavailable" as const),
kind: dockerInstance.descriptor.kind,
transport: dockerInstance.descriptor.transport.type,
capabilities: dockerInstance.descriptor.capabilities,
source: dockerInstance.descriptor.source,
scope: dockerInstance.descriptor.scope,
} satisfies DockerEndpointStatus;
}),
...initializationFailures.map(({ descriptor }) => ({
id: descriptor.id,
name: descriptor.name,
status: "unavailable" as const,
kind: descriptor.kind,
transport: descriptor.transport.type,
capabilities: descriptor.capabilities,
source: descriptor.source,
scope: descriptor.scope,
})),
] satisfies DockerEndpointStatus[];
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value;
@@ -354,24 +370,35 @@ export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpoi
})
: [];
const degradedEndpointIds = new Set<string>();
const containerStatsPromises = containers.map(async (container) => {
const instance = DockerSingleton.findInstance(container.endpointId)?.instance;
if (!instance) return null;
const stats = await withDockerTimeoutAsync(
async (signal) => {
const options = { stream: false as const, "one-shot": true, abortSignal: signal };
return await instance.getContainer(container.Id).stats(options);
},
`Timed out reading Docker container stats for ${container.Id}`,
timeoutMs,
).catch(
() =>
({
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
memory_stats: { usage: 0 },
}) as ContainerStats,
);
let stats: ContainerStats;
try {
stats = await withDockerTimeoutAsync(
async (signal) => {
const options = { stream: false as const, "one-shot": true, abortSignal: signal };
return await instance.getContainer(container.Id).stats(options);
},
`Timed out reading Docker container stats for ${container.Id}`,
timeoutMs,
);
} catch (error) {
degradedEndpointIds.add(container.endpointId);
logger.warn(
new ErrorWithMetadata(
"Failed to read Docker container stats",
{ endpointId: container.endpointId, containerId: container.Id, host: container.instance },
{ cause: error },
),
);
stats = {
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
memory_stats: { usage: 0 },
} as ContainerStats;
}
const cpuUsage = calculateCpuUsage(stats);
const memoryUsage = calculateMemoryUsage(stats);
@@ -392,9 +419,14 @@ export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpoi
};
});
const resolvedContainers = (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
return {
containers: (await Promise.all(containerStatsPromises)).filter((container) => container !== null),
endpoints,
containers: resolvedContainers,
endpoints: endpoints.map((endpoint) =>
endpoint.status === "available" && degradedEndpointIds.has(endpoint.id)
? { ...endpoint, status: "degraded" as const }
: endpoint,
),
};
}
@@ -4,7 +4,12 @@ import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { DockerSingleton } from "@homarr/docker";
import { calculateCpuUsage, calculateMemoryUsage, getContainersWithStatsAsync } from "../docker";
import {
calculateCpuUsage,
calculateMemoryUsage,
getContainersWithStatsAsync,
hasDockerEndpointCapability,
} from "../docker";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -144,7 +149,7 @@ describe("getContainersWithStatsAsync", () => {
}
});
test("returns a container with zeroed stats when its stats request times out", async () => {
test("marks an endpoint degraded and returns zeroed stats when its stats request times out", async () => {
vi.useFakeTimers();
try {
const dockerInstance = createDockerInstance("healthy", async () => [
@@ -173,11 +178,50 @@ describe("getContainersWithStatsAsync", () => {
expect(observedSignal?.aborted).toBe(true);
expect(result.containers).toEqual([expect.objectContaining({ id: "container-1", cpuUsage: 0, memoryUsage: 0 })]);
expect(result.endpoints).toEqual([expect.objectContaining({ id: "healthy", status: "degraded" })]);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
test("includes endpoints that failed during TLS initialization", async () => {
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([]);
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([
{
host: "broken.example:2376",
descriptor: {
id: "broken-tls",
name: "Broken TLS",
kind: "docker",
transport: { type: "tls", host: "broken.example", port: 2376, caPath: "/missing/ca.pem" },
capabilities: ["inventory"],
scope: "admin",
source: "environment",
},
},
]);
const result = await getContainersWithStatsAsync(50);
expect(result.endpoints).toEqual([
expect.objectContaining({ id: "broken-tls", status: "unavailable", transport: "tls" }),
]);
vi.restoreAllMocks();
});
});
test("demo capabilities match the advertised inventory-only endpoint", () => {
const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true";
try {
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
} finally {
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = previousDemoMode;
}
});
const createDockerInstance = (endpointId: string, listContainers: () => Promise<unknown[]>) => ({
+93 -6
View File
@@ -1,4 +1,73 @@
{
"featureWorkbench": {
"page": {
"title": "Native feature state workbench",
"description": "A development-only fixture surface for reviewing shared catalog and integration response states without live services."
},
"catalog": {
"title": "Catalog item states",
"description": "Compare the shared selection surface with stable service and interaction fixtures.",
"serviceState": "Service state",
"interactionState": "Interaction state",
"loading": "Loading",
"loadingAriaLabel": "Loading catalog item",
"semanticOutput": "Semantic output",
"instructions": "Tab to the item to inspect its visible focus ring. Select it with Enter or Space.",
"interaction": {
"default": "Default",
"selected": "Selected",
"loading": "Loading",
"disabled": "Disabled"
},
"state": {
"ready": {
"label": "Ready service",
"status": "Ready",
"description": "Configured and available to three widgets."
},
"needsSetup": {
"label": "Service needing setup",
"status": "Needs setup",
"description": "Credentials or a service URL are still required."
},
"noConnection": {
"label": "Standalone feature",
"status": "No connection required",
"description": "This feature works without a configured integration."
},
"error": {
"label": "Unreachable service",
"status": "Connection failed",
"description": "The saved connection could not be reached."
}
}
},
"response": {
"title": "Integration response contract",
"description": "Inspect waiting, accepted, and rejected response fixtures using the same parser contract as generated integrations.",
"fixtureLabel": "Response fixture",
"checking": "Checking response",
"contractPassed": "Fixture contract passed",
"contractFailed": "Fixture contract failed",
"state": {
"loading": {
"label": "Loading",
"title": "Checking response",
"description": "The parser is waiting for the integration response."
},
"success": {
"label": "Success",
"title": "Response accepted",
"description": "The upstream payload matched the integration contract."
},
"failure": {
"label": "Failure",
"title": "Response rejected",
"description": "The upstream payload failed schema validation and stays isolated from the widget."
}
}
}
},
"init": {
"studio": {
"navigationLabel": "Onboarding sections",
@@ -241,6 +310,7 @@
},
"universalCreate": {
"title": "Create",
"analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
"trigger": {
"label": "Create something",
"description": "Add content, connect a service, or create a board from one place."
@@ -2703,7 +2773,8 @@
"notification": {
"success": {
"title": "Widget added",
"message": "{name} was added to this board."
"message": "{name} was added to this board.",
"relocatedMessage": "{name} was added to {destination} because the selected destination was unavailable."
},
"error": {
"title": "Unable to add item",
@@ -7051,7 +7122,9 @@
},
"url": {
"label": "Service address",
"manual": "Enter an address manually"
"inputLabel": "Service URL",
"manual": "Enter an address manually",
"invalid": "Enter a valid HTTP or HTTPS URL"
},
"reason": {
"publishedAddress": "Published address reported by Docker",
@@ -7083,12 +7156,21 @@
},
"action": {
"refresh": "Refresh",
"retry": "Try again",
"dismiss": "Dismiss",
"restoreDismissed": "Restore dismissed ({count})",
"setupIntegration": "Set up integration",
"createApp": "Create app",
"reviewIntegration": "Review integration",
"viewRepresentation": "View setup"
},
"loadError": {
"title": "Service setup could not be loaded",
"message": "Docker service information is unavailable. Try again without leaving this page."
},
"refreshError": {
"title": "Containers not refreshed",
"message": "Something went wrong while refreshing the containers"
}
},
"table": {
@@ -7187,8 +7269,8 @@
"label": "Remove",
"confirm": "Click again to remove permanently",
"confirmation": {
"title": "Remove {count} container(s)?",
"message": "This permanently removes {count} container(s): {names}"
"title": "{count, plural, one {Remove # container?} other {Remove # containers?}}",
"message": "{count, plural, one {This permanently removes # container: {names}} other {This permanently removes # containers: {names}}}"
},
"notification": {
"success": {
@@ -7204,7 +7286,7 @@
"result": {
"failure": {
"title": "Some container actions failed",
"message": "{failed} of {total} action(s) failed: {names}"
"message": "{total, plural, one {{failed} of # action failed: {names}} other {{failed} of # actions failed: {names}}}"
}
},
"refresh": {
@@ -7239,13 +7321,18 @@
},
"error": {
"internalServerError": "Failed to fetch Docker containers",
"endpointUnavailable": "{count} Docker endpoint(s) unavailable: {names}",
"endpointUnavailable": "{count, plural, one {# Docker endpoint unavailable: {names}} other {# Docker endpoints unavailable: {names}}}",
"noColumns": "Select at least one column in the widget settings"
}
},
"kubernetes": {
"context": {
"label": "Kubernetes context",
"loadError": {
"title": "Kubernetes contexts could not be loaded",
"message": "Context information is unavailable. Try again without leaving this page.",
"retry": "Try again"
},
"status": {
"available": "Available",
"degraded": "Metrics unavailable",
@@ -84,7 +84,7 @@ describe("CatalogItem", () => {
expect(getCatalogKeyboardDirection("Enter")).toBeNull();
});
it("offers arrow-key movement without replacing native Enter and Space activation", async () => {
it("moves focus only for navigation keys", async () => {
const onSelect = vi.fn();
const onMoveFocus = vi.fn();
await act(async () =>
@@ -102,6 +102,7 @@ describe("CatalogItem", () => {
expect(onMoveFocus).toHaveBeenCalledWith("next");
await act(async () => button?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
await act(async () => button?.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })));
expect(onMoveFocus).toHaveBeenCalledOnce();
});
@@ -259,6 +259,12 @@ export default function DockerWidget({
utils.docker.getServiceHealth.invalidate(),
]);
},
onError() {
showErrorNotification({
title: t("action.refresh.notification.error.title"),
message: t("action.refresh.notification.error.message"),
});
},
});
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
@@ -70,7 +70,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const integrationsRequired =
innerProps.integrationSupport &&
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
const maxIntegrations = (definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity;
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
@@ -120,6 +120,12 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
const showResourceTabs = showAppTab || showIntegrationTab;
const [activeTab, setActiveTab] = useState<string | null>("widget");
useEffect(() => {
if (activeTab === "integration" && !showIntegrationTab) setActiveTab("widget");
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
}, [activeTab, showAppTab, showIntegrationTab]);
const handleSubmit = form.onSubmit(async (values) => {
setIsSubmitting(true);
@@ -223,7 +229,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<FormProvider form={form}>
{showResourceTabs ? (
<Stack>
<Tabs defaultValue="widget">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
@@ -141,7 +141,7 @@ export const WidgetIntegrationSelect = ({
combobox.closeDropdown();
return;
}
if (event.key === "Backspace" && multiSelectValues.length > 0) {
if (event.key === "Backspace" && canSelectMultiple && multiSelectValues.length > 0) {
event.preventDefault();
const lastValue = multiSelectValues.at(-1);
if (lastValue) handleValueRemove(lastValue);
+15 -3
View File
@@ -155,10 +155,22 @@ export const createFeatureCheckPlan = (root: string, kind: string): FeatureCheck
executable: "node",
args: ["--experimental-strip-types", "scripts/check-feature-contracts.mts"],
},
{ label: "Focused fixture and unit tests", executable: "pnpm", args: ["exec", "vitest", "run", ...sortedTests] },
...(sortedTests.length > 0
? [
{
label: "Focused fixture and unit tests",
executable: "pnpm",
args: ["exec", "vitest", "run", ...sortedTests],
},
]
: []),
packageCommand("Affected package typechecks", sortedPackages),
{ label: "Affected file lint", executable: "pnpm", args: ["exec", "oxlint", ...lintFiles] },
{ label: "Affected file formatting", executable: "pnpm", args: ["exec", "oxfmt", "--check", ...sortedFiles] },
...(lintFiles.length > 0
? [{ label: "Affected file lint", executable: "pnpm", args: ["exec", "oxlint", ...lintFiles] }]
: []),
...(sortedFiles.length > 0
? [{ label: "Affected file formatting", executable: "pnpm", args: ["exec", "oxfmt", "--check", ...sortedFiles] }]
: []),
];
return {
+3 -1
View File
@@ -74,10 +74,12 @@ const compareKeys = (
});
};
const ignoredDirectories = new Set([".git", ".turbo", ".next", "node_modules", "dist", "build", "coverage"]);
const walkFiles = (directory: string): string[] => {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
if (entry.isDirectory() && [".git", ".turbo", "node_modules"].includes(entry.name)) return [];
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) return [];
const path = join(directory, entry.name);
return entry.isDirectory() ? walkFiles(path) : [path];
});
@@ -3,7 +3,7 @@ import { test } from "node:test";
import { resolve } from "node:path";
import { checkFeatureContracts } from "./contracts.mts";
import { formatFeatureChanges, planFeatureGeneration } from "./generate-feature.mts";
import { formatFeatureChanges, getIntegrationGeneratorChoices, 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";
@@ -13,6 +13,55 @@ test("the checked-in feature contracts are consistent", () => {
assert.deepEqual(checkFeatureContracts(root), []);
});
test("reads generator choices from the canonical integration definitions", () => {
const choices = getIntegrationGeneratorChoices(root);
assert.ok(choices.categories.includes("miscellaneous"));
assert.ok(choices.secretKinds.includes("apiKey"));
});
test("adds translations to empty JSON registries without a leading comma", async () => {
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-feature-generator-"));
try {
for (const sourcePath of [
"packages/definitions/src/integration.ts",
"packages/definitions/src/widget.ts",
"packages/definitions/src/widget-integration-map.ts",
"packages/definitions/src/docs/widget-doc-slugs.ts",
"packages/widgets/src/manifest.ts",
"packages/widgets/src/catalog.ts",
"packages/widgets/src/index.tsx",
]) {
const target = path.join(temporaryRoot, sourcePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(root, sourcePath), target);
}
const translationPath = path.join(temporaryRoot, "packages/translation/src/lang/en.json");
fs.mkdirSync(path.dirname(translationPath), { recursive: true });
fs.writeFileSync(translationPath, '{\n "integration": {},\n "widget": {}\n}\n');
const changes = planFeatureGeneration(temporaryRoot, {
widget: {
kind: "exampleEmptyRegistry",
name: "Example empty registry",
slug: "example-empty-registry",
description: "Verifies empty translation registry insertion.",
icon: "IconBox",
supportedIntegrations: [],
},
});
const translation = changes.find((change) => change.path === "packages/translation/src/lang/en.json");
assert.ok(translation);
assert.doesNotThrow(() => JSON.parse(translation.content));
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
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.";
+17 -12
View File
@@ -91,7 +91,20 @@ const addJsonProperty = (source: string, property: "integration" | "widget", nam
.join("\n");
const beforeClose = source.slice(0, end);
const trimmed = beforeClose.trimEnd();
return `${trimmed},\n ${JSON.stringify(name)}: ${serialized}\n ${source.slice(end)}`;
const separator = body.trim().length === 0 ? "" : ",";
return `${trimmed}${separator}\n ${JSON.stringify(name)}: ${serialized}\n ${source.slice(end)}`;
};
export const getIntegrationGeneratorChoices = (root: string) => {
const source = read(root, "packages/definitions/src/integration.ts");
const categoryRange = containerRange(source, "const integrationCategories", "[");
const secretRange = containerRange(source, "const integrationSecretKindObject", "{");
return {
categories: [...source.slice(categoryRange.start, categoryRange.end).matchAll(/"([^"]+)"/g)].map(
(match) => match[1],
),
secretKinds: [...source.slice(secretRange.start, secretRange.end).matchAll(/^ (\w+):/gm)].map((match) => match[1]),
};
};
const addChange = (
@@ -123,19 +136,11 @@ const validate = (request: FeatureRequest) => {
const planIntegration = (root: string, feature: IntegrationFeature, changes: PlannedChange[]) => {
const definitionSource = read(root, "packages/definitions/src/integration.ts");
const categoryRange = containerRange(definitionSource, "const integrationCategories", "[");
const categories = new Set(
[...definitionSource.slice(categoryRange.start, categoryRange.end).matchAll(/"([^"]+)"/g)].map((match) => match[1]),
);
if (!categories.has(feature.category)) {
const choices = getIntegrationGeneratorChoices(root);
if (!choices.categories.includes(feature.category)) {
throw new Error(`Generator stopped: ${feature.category} is not a current integration category.`);
}
const secretObject = definitionSource.slice(
definitionSource.indexOf("const integrationSecretKindObject"),
definitionSource.indexOf("satisfies Record<string", definitionSource.indexOf("const integrationSecretKindObject")),
);
const validSecrets = new Set([...secretObject.matchAll(/^ (\w+):/gm)].map((match) => match[1]));
const unknownSecrets = feature.secretKinds.filter((kind) => !validSecrets.has(kind));
const unknownSecrets = feature.secretKinds.filter((kind) => !choices.secretKinds.includes(kind));
if (unknownSecrets.length > 0) {
throw new Error(`Generator stopped: unknown credential kind(s): ${unknownSecrets.join(", ")}.`);
}
+11 -66
View File
@@ -1,79 +1,22 @@
import type { PlopTypes } from "@turbo/gen";
import type { FeatureRequest } from "../../scripts/feature-platform/generate-feature.mts";
import { generateFeature } from "../../scripts/feature-platform/generate-feature.mts";
import { generateFeature, getIntegrationGeneratorChoices } from "../../scripts/feature-platform/generate-feature.mts";
type Answers = Record<string, unknown> & { turbo: { paths: { root: string } } };
const text = (value: unknown) => String(value ?? "").trim();
const list = (value: unknown) =>
text(value)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
(Array.isArray(value) ? value : text(value).split(",")).map((item) => text(item)).filter(Boolean);
const lowerCamel = (value: string) =>
/^[a-z][A-Za-z0-9]*$/.test(value) || "Use lower camel case (for example, mediaServer).";
const kebab = (value: string) =>
/^[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",
"mediaService",
"calendar",
"mediaOrganizer",
"mediaSearch",
"mediaRelease",
"mediaRequest",
"downloadClient",
"usenet",
"torrent",
"miscellaneous",
"smartHomeServer",
"indexerManager",
"healthMonitoring",
"beszel",
"search",
"mediaTranscoding",
"networkController",
"notifications",
"firewall",
"timetable",
"photoService",
"notes",
"mediaMonitoring",
"speedtest",
"analytics",
"vpn",
"archiving",
"ups",
"documents",
"mediaLibrary",
"uptimeMonitoring",
"subtitleManager",
"reverseProxy",
];
const integrationSecretKinds = [
"apiKey",
"username",
"password",
"tokenId",
"realm",
"personalAccessToken",
"topic",
"opnsenseApiKey",
"opnsenseApiSecret",
"patchmonApiKey",
"patchmonApiSecret",
"url",
"privateKey",
"githubAppId",
"githubInstallationId",
"slug",
];
const integrationPrompts = (prefix = "") =>
[
const integrationPrompts = (root: string, prefix = "") => {
const choices = getIntegrationGeneratorChoices(root);
return [
{ type: "input", name: `${prefix}Kind`, message: "Integration kind", validate: lowerCamel },
{ type: "input", name: `${prefix}Name`, message: "Integration display name", validate: required },
{ type: "input", name: `${prefix}Slug`, message: "Integration docs and folder slug", validate: kebab },
@@ -87,14 +30,14 @@ const integrationPrompts = (prefix = "") =>
type: "list",
name: `${prefix}Category`,
message: "Existing integration category",
choices: integrationCategories,
choices: choices.categories,
default: "miscellaneous",
},
{
type: "checkbox",
name: `${prefix}SecretKinds`,
message: "Credential kinds (leave empty for an unauthenticated integration)",
choices: integrationSecretKinds,
choices: choices.secretKinds,
},
{
type: "input",
@@ -105,6 +48,7 @@ const integrationPrompts = (prefix = "") =>
validate: required,
},
] satisfies PlopTypes.PromptQuestion[];
};
const widgetPrompts = (prefix = "") =>
[
@@ -149,9 +93,10 @@ const action =
};
export default function generator(plop: PlopTypes.NodePlopAPI) {
const root = plop.getDestBasePath();
plop.setGenerator("native-integration", {
description: "Scaffold a native integration, tests, docs, translations, and current explicit registries",
prompts: integrationPrompts(),
prompts: integrationPrompts(root),
actions: [action((answers) => ({ integration: integrationFrom(answers) }))],
});
plop.setGenerator("widget", {
@@ -161,7 +106,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI) {
});
plop.setGenerator("integration-widget", {
description: "Scaffold a paired native integration and widget contract",
prompts: [...integrationPrompts("integration"), ...widgetPrompts("widget")],
prompts: [...integrationPrompts(root, "integration"), ...widgetPrompts("widget")],
actions: [
action((answers) => {
const integration = integrationFrom(answers, "integration");
+10
View File
@@ -53,6 +53,15 @@ export default defineConfig({
include: ["packages/request-handler/**/*.spec.{ts,tsx}"],
},
},
{
extends: true,
test: {
name: "docker-node",
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: ["packages/docker/**/*.spec.{ts,tsx}"],
},
},
{
extends: true,
test: {
@@ -65,6 +74,7 @@ export default defineConfig({
"packages/api/**",
"packages/custom-widgets/**",
"packages/db/**",
"packages/docker/**",
"packages/request-handler/**",
"e2e/**",
],