fix release v2 recovery flows

This commit is contained in:
ajnart committed 2026-08-14 02:18:46 +02:00
1 parent ca856f9f54
commit 4905ea6738
44 files changed
+871 -201

No files matched your search

@@ -164,6 +164,10 @@ Spotlight. It can add a widget, app, integration, Container, board, Workshop ite
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.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
saved integration changes apply everywhere that connection is used.
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
Resize tiles from any edge or corner handle.
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
@@ -63,6 +63,13 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
widget returns to its settings with the new integration already selected.
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
changes apply to every app and widget that uses the integration.
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one.
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
@@ -42,7 +42,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Text size="md">
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
statusCode: error.data.statusCode.toString(),
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
{error.data.url}
</Anchor>
),
})}
</Text>
) : (
@@ -50,7 +54,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{t.rich("integration.testConnection.error.statusCode.description", {
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
statusCode: error.data.statusCode.toString(),
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
{error.data.url}
</Anchor>
),
})}
</Text>
)
@@ -66,7 +74,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{t("integration.testConnection.error.common.cause.title")}
</Accordion.Control>
<Accordion.Panel>
<pre style={{ whiteSpace: "pre-wrap" }}>
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{error.name}: {error.message}
{"\n"}
{causeArray
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
@@ -25,8 +25,10 @@ import { SecretKindsSegmentedControl } from "../../_components/secrets/integrati
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
interface EditIntegrationForm {
interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"];
embedded?: boolean;
onSuccess?: () => void;
}
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
@@ -42,7 +44,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
}),
);
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -69,15 +71,17 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const errorRef = useRef<HTMLDivElement>(null);
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
await mutateAsync(
{
setError(null);
try {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
const data = await mutateAsync({
id: integration.id,
...values,
url,
@@ -86,34 +90,34 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
value: secret.value === "" ? null : secret.value,
})),
appId: app?.id ?? null,
},
{
onSuccess: (data) => {
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
return;
}
});
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
void utils.integration.invalidate();
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: () => {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
},
},
);
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
requestAnimationFrame(() => errorRef.current?.focus());
return;
}
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
onSuccess?.();
if (!embedded) {
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
}
} catch {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
}
};
const isInitialSecretKinds =
@@ -190,12 +194,18 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{error !== null && (
<div ref={errorRef} role="alert" tabIndex={-1}>
<IntegrationTestConnectionError error={error} url={form.values.url} />
</div>
)}
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
{!embedded && (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
)}
<Button type="submit" loading={isPending}>
{t("integration.testConnection.action.edit")}
</Button>
@@ -70,12 +70,18 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
const validationSchema = canCreateApps
? formSchema
: formSchema.superRefine((values, context) => {
if (!values.hasApp || values.appId !== null) return;
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] });
});
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
if (hasUrlSecret) {
url = "http://localhost";
}
const form = useZodForm(formSchema, {
const form = useZodForm(validationSchema, {
initialValues: {
name: initialName ?? getIntegrationName(kind),
url,
@@ -84,7 +90,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
value: "",
})),
attemptSearchEngineCreation: true,
hasApp: true,
hasApp: canCreateApps,
appHref: url,
appId: null,
},
@@ -136,13 +142,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const app = hasApp
? appId !== null
? { id: appId }
: {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(kind),
description: null,
pingUrl: url,
}
: canCreateApps
? {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(kind),
description: null,
pingUrl: url,
}
: undefined
: undefined;
await createIntegrationAsync(
@@ -234,8 +242,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
checkboxInputProps.onChange(event);
});
}}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
description={t(
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
)}
/>
<Collapse expanded={form.values.hasApp}>
@@ -47,9 +47,10 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
{ contextId },
{
initialData: initialConfigMaps,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -80,9 +80,10 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
{ contextId },
{
initialData: initialIngresses,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -14,7 +14,12 @@ export const KubernetesContextSelector = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
refetchInterval: 30_000,
refetchOnMount: "always",
refetchOnReconnect: true,
refetchOnWindowFocus: true,
});
const [storedContextId, setStoredContextId] = useLocalStorage<string | null>({
key: "homarr-kubernetes-context",
defaultValue: null,
@@ -77,7 +82,11 @@ export const KubernetesContextSelector = () => {
export const useSelectedKubernetesContextId = () => {
const searchParams = useSearchParams();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
refetchInterval: 30_000,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
});
const requested = searchParams.get("context");
if (data?.contexts.some(({ contextId }) => contextId === requested)) return requested;
return (
@@ -64,9 +64,10 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
{ contextId },
{
initialData: initialNamespaces,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -90,9 +90,10 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
{ contextId },
{
initialData: initialNodes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -55,9 +55,10 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
{ contextId },
{
initialData: initialPods,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -50,9 +50,10 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
{ contextId },
{
initialData: initialSecrets,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -69,9 +69,10 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
{ contextId },
{
initialData: initialServices,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -74,9 +74,10 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
{ contextId },
{
initialData: initialVolumes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
@@ -55,28 +55,28 @@ describe("getAssistantWidgetConfiguration", () => {
name: "Navidrome",
kind: "navidrome",
url: "https://music.example.com",
permissions: { hasUseAccess: true },
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
},
{
id: "audiobookshelf-1",
name: "Audiobookshelf",
kind: "audiobookshelf",
url: "https://books.example.com",
permissions: { hasUseAccess: true },
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
},
{
id: "plex-1",
name: "Plex",
kind: "plex",
url: "https://plex.example.com",
permissions: { hasUseAccess: true },
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
},
{
id: "navidrome-private",
name: "Private Navidrome",
kind: "navidrome",
url: "https://private.example.com",
permissions: { hasUseAccess: false },
permissions: { hasUseAccess: false, hasInteractAccess: false, hasFullAccess: false },
},
],
);
@@ -24,7 +24,7 @@ import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-too
import { IntegrationSelectModal } from "../integration/integration-select-modal";
interface AssistantIntegration extends IntegrationSelectOption {
permissions: { hasUseAccess: boolean };
permissions: NonNullable<IntegrationSelectOption["permissions"]>;
}
export const getAssistantWidgetConfiguration = (
@@ -40,7 +40,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData = [], section } = useSectionContext();
const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
@@ -77,8 +77,10 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
});
refResetErrorBoundaryOnNextRender.current = true;
},
integrationData: integrationData.filter(
onIntegrationSaved: resetErrorBoundary,
integrationData: (integrationData ?? []).filter(
(integration) =>
integration.permissions.hasUseAccess &&
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
@@ -83,4 +83,20 @@ describe("resolveMatchingIntegrationsAsync", () => {
).resolves.toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("excludes matching integrations the user cannot use", async () => {
const currentData = [
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
];
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
}),
).resolves.toEqual([currentData[0]]);
});
});
@@ -26,7 +26,9 @@ export const unlockSelection = (lock: { current: boolean }) => {
lock.current = false;
};
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
export const resolveMatchingIntegrationsAsync = async <
TIntegration extends { kind: IntegrationKind; permissions?: { hasUseAccess: boolean } },
>({
hasIntegrationSupport,
supportedIntegrations,
currentData,
@@ -40,5 +42,8 @@ export const resolveMatchingIntegrationsAsync = async <TIntegration extends { ki
if (!hasIntegrationSupport) return [];
const integrations = currentData ?? (await ensureDataAsync());
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
return integrations.filter(
(integration) =>
integration.permissions?.hasUseAccess !== false && supportedIntegrations.includes(integration.kind),
);
};
@@ -104,7 +104,10 @@ const ItemSelectModalContent = ({
const trackSetup = useSetupAnalytics();
const flowStartedAt = useRef<number | null>(null);
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
const availableKinds = useMemo(
() => new Set((integrationData ?? []).filter(({ permissions }) => permissions.hasUseAccess).map((i) => i.kind)),
[integrationData],
);
const items = useMemo(
() =>
@@ -244,13 +247,16 @@ const ItemSelectModalContent = ({
ensureDataAsync: ensureIntegrationDataAsync,
});
const integrationIds = matchingIntegrations.map((i) => i.id);
const maxIntegrations = (definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity;
const integrationIds = matchingIntegrations.slice(0, maxIntegrations).map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const openEditor = (
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
) => {
const selectedIntegrationIds = availableIntegrations.map((integration) => integration.id);
const selectedIntegrationIds = availableIntegrations
.slice(0, maxIntegrations)
.map((integration) => integration.id);
openEditModal(
{
kind,
@@ -6,10 +6,15 @@ import { ErrorBoundary } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
export const loadWidgetEditModal = () => {
if (widgetEditModalPromise) return widgetEditModalPromise;
@@ -24,6 +29,59 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
const loadIntegrationEditModal = () => {
if (integrationEditModalPromise) return integrationEditModalPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
integrationEditModalPromise = currentPromise;
void currentPromise.catch(() => {
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
});
return currentPromise;
};
const LazyIntegrationEditModalContent = (props: {
actions: { closeModal: () => void };
innerProps: EmbeddedIntegrationEditModalProps;
}) => {
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
const Component = EmbeddedIntegrationEditModal.component;
return <Component {...props} />;
};
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
integrationEditModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyIntegrationEditModalContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void };
innerProps: WidgetEditModalProps<WidgetKind>;
@@ -32,7 +90,20 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component;
return <Component {...props} />;
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
return (
<Component
{...props}
innerProps={{
...props.innerProps,
onEditIntegration: (integrationId) =>
openIntegrationEditModal({
integrationId,
onSuccess: props.innerProps.onIntegrationSaved,
}),
}}
/>
);
};
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
@@ -178,8 +178,11 @@ export const WidgetContextMenu = ({
),
}));
},
integrationData: integrationData.filter((integration) =>
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
onIntegrationSaved: handleRefetch,
integrationData: integrationData.filter(
(integration) =>
definition.supportedIntegrations?.some((kind) => kind === integration.kind) &&
integration.permissions.hasUseAccess,
),
integrationSupport: definition.supportedIntegrations !== undefined,
settings,
@@ -192,6 +195,7 @@ export const WidgetContextMenu = ({
definition,
integrationData,
item,
handleRefetch,
openModal,
persistBoard,
settings,
@@ -0,0 +1,86 @@
"use client";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export interface EmbeddedIntegrationEditModalProps {
integrationId: string;
onSuccess?: () => void;
}
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
({ actions, innerProps }) => {
const t = useI18n();
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: innerProps.integrationId,
});
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm
integration={integration}
embedded
onSuccess={() => {
innerProps.onSuccess?.();
actions.closeModal();
}}
/>
</Stack>
);
},
).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
@@ -115,6 +115,12 @@ The same action is visible in the header and ordinary Spotlight results, not onl
When a Widget or Assistant action lacks a required Integration, the user can create a compatible one inline and return to the pending Widget editor with that Integration selected. Integration creation returns the created Integration and linked App details, then opens a completion sheet with up to three capability-derived Widget recipes. Recommendations exclude Widget kinds already present, honor permission/access data, explain why they fit, and can be dismissed per Board and Integration kind.
Existing connections now have the same continuity. The Widget editor exposes an **Edit integration** tab for its selected
connections. A user with full access can open the native connection form in a nested inspector, test credentials, inspect
the complete diagnostic, repair the connection, and return without losing unsaved Widget settings. The editor is loaded
only when requested, failed module and record loads are retryable, and a successful repair invalidates Integration and
Widget data before resetting the affected Widget error state.
Setup analytics use the existing opt-in gate but enforce a special anonymous allowlist for `setup:*` events: coarse entry point, intent, outcome, elapsed time, Board-context presence, and inline-resolution ability. Record IDs, URLs, queries, credentials, provider responses, and session user IDs are excluded.
## 5. Canonical feature platform and contributor experience
@@ -178,6 +184,10 @@ The continuous reconciliation surface:
- Avoids automatic adoption when matching is ambiguous.
- Supports filters, refresh, local dismissal, and restore.
Inventory and stats calls are independently time-bounded and aborted, so one unresponsive endpoint or container cannot
hold healthy endpoints open. Browser URL suggestions reject wildcard and loopback addresses, and a linked App no longer
hides a stale Integration URL that still needs repair.
The Service health projection joins Docker availability, Integration configuration, App representation, and Widget attachment. Authentication, live API request success, and Widget query success are explicitly Not observed until Homarr has trustworthy evidence; absence of evidence is never shown as healthy.
```mermaid
@@ -218,6 +228,9 @@ The Kubernetes client registry creates a distinct client per kubeconfig context.
The context selector reports Available, Metrics unavailable, or Unavailable independently. An unreachable context does not hide healthy contexts. An empty kubeconfig `current-context` deterministically falls back to the first configured context. When `KUBECONFIG` is absent in production, the existing in-cluster service-account path remains the default.
Version and Metrics API probes are abortable and bounded per context. Resource tables retry after a failed request and
resume automatically when the selected context recovers, while healthy empty results remain idle.
Inventory and optional metrics are separated. Cluster and Node pages show counts and resource identity even when Metrics Server is absent, while percentages become unavailable rather than zero or a fatal page error.
```mermaid
@@ -306,6 +319,10 @@ Completed locally:
- Board static-dependency graph guard passes after keeping the Spotlight store behind its lazy boundary.
- `git diff --check` passes.
The final review-hardening pass also completed seven affected-package typechecks, 119 focused cross-layer tests, all 74
request-handler tests in their server-side Vitest project, the feature-platform contract suite, and a production docs
build. Three independent re-review passes found no remaining blocker in the changed paths.
The broad non-E2E suite was also attempted: 3,258 tests passed and 91 were skipped. It caught one change-related static-graph regression, which was fixed and re-tested. The remaining failures require facilities unavailable in this sandbox: a container runtime for Integration and database migration suites, loopback socket permission for Custom Widget network-policy suites, external HTTP access for Integration documentation checks, and the existing child-process bundle fixture. They do not overlap the focused green suites above.
Environment boundary: the local host runs Node 24.16 while the repository requires Node 24.18 or newer. TypeScript, lint, formatting, and focused tests pass with engine strictness disabled only for command execution. Exact-engine container and hosted CI remain the final release proof. No live Docker daemon, Kubernetes cluster, third-party Integration credentials, or authenticated browser preview was used in this implementation pass.
@@ -71,15 +71,16 @@ export const getDockerReconciliationAsync = async (db: Database) => {
const app = linkedApp ?? exactApp ?? nameApp;
const exactUrl = Boolean(exactIntegration || exactApp);
const linked = Boolean(integration?.appId && linkedApp);
const integrationMoved = Boolean(integration && !exactIntegration);
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
? "moved"
const state: DockerReconciliationState = integrationMoved
? "moved"
: linked
? "linked"
: integration || app
? "represented"
: match
@@ -115,6 +116,8 @@ export const getDockerReconciliationAsync = async (db: Database) => {
app: app ? { id: app.id, name: app.name, href: app.href } : null,
signals: {
exactUrl,
exactIntegrationUrl: Boolean(exactIntegration),
exactAppUrl: Boolean(exactApp),
nameMatch: Boolean(nameIntegration || nameApp),
linked,
ambiguous,
@@ -90,21 +90,14 @@ export const integrationRouter = createTRPCRouter({
});
return integrations
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
permissions,
};
})
.toSorted(
@@ -138,21 +131,14 @@ export const integrationRouter = createTRPCRouter({
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
permissions,
};
})
.toSorted(
@@ -189,21 +175,14 @@ export const integrationRouter = createTRPCRouter({
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
permissions,
};
})
.toSorted(
@@ -42,10 +42,10 @@ describe("KubernetesClientRegistry", () => {
for (const contextId of ["home", "remote", "metrics-degraded"]) {
const client = registry.getClient(contextId);
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockResolvedValue({ items: [] } as never);
}
registry.getClient("remote").versionApi.getCode = vi.fn().mockRejectedValue(new Error("offline"));
registry.getClient("metrics-degraded").metricsApi.getNodeMetrics = vi
registry.getClient("metrics-degraded").metricsProbeApi.listClusterCustomObject = vi
.fn()
.mockRejectedValue(new Error("no metrics"));
@@ -65,6 +65,40 @@ describe("KubernetesClientRegistry", () => {
]);
});
test("times out an unresponsive context without delaying healthy contexts", async () => {
vi.useFakeTimers();
try {
const registry = createRegistry();
for (const contextId of ["home", "remote", "metrics-degraded"]) {
const client = registry.getClient(contextId);
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockResolvedValue({ items: [] } as never);
}
let observedSignal: AbortSignal | undefined;
vi.spyOn(registry.getClient("remote").versionApi, "getCode").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 = registry.getContextsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(observedSignal?.aborted).toBe(true);
expect(result.contexts).toEqual(
expect.arrayContaining([
expect.objectContaining({ contextId: "home", status: "available" }),
expect.objectContaining({ contextId: "remote", status: "unavailable" }),
]),
);
} finally {
vi.useRealTimers();
}
});
test("rejects unknown context IDs", () => {
expect(() => createRegistry().getClient("missing")).toThrow(KubernetesContextNotFoundError);
});
@@ -80,7 +114,7 @@ describe("KubernetesClientRegistry", () => {
const registry = new KubernetesClientRegistry(kubeConfig);
const client = registry.getClient("first");
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockResolvedValue({ items: [] } as never);
const result = await registry.getContextsAsync();
@@ -1,5 +1,7 @@
import * as fs from "fs";
import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
import type { RequestContext, ResponseContext } from "@kubernetes/client-node";
import { CoreV1Api, CustomObjectsApi, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
import { of } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
import { env } from "../../env";
@@ -11,12 +13,50 @@ export interface KubernetesContextStatus {
isDefault: boolean;
}
export const kubernetesContextProbeTimeoutMs = 5_000;
const withKubernetesProbeTimeoutAsync = async <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
) => {
const controller = new AbortController();
const timeoutError = new Error("Kubernetes context probe timed out");
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
};
const abortableKubernetesRequest = (signal: AbortSignal) => ({
middleware: [
{
pre: (request: RequestContext) => {
request.setSignal(signal);
return of(request);
},
post: (response: ResponseContext) => of(response),
},
],
});
export class KubernetesClient {
private static registry: KubernetesClientRegistry | null = null;
public kubeConfig: KubeConfig;
public coreApi: CoreV1Api;
public networkingApi: NetworkingV1Api;
public metricsApi: Metrics;
public metricsProbeApi: CustomObjectsApi;
public versionApi: VersionApi;
public constructor(kubeConfig: KubeConfig) {
@@ -24,6 +64,7 @@ export class KubernetesClient {
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);
}
@@ -76,12 +117,22 @@ export class KubernetesClientRegistry {
return this.getClient(this.defaultContextId);
}
public async getContextsAsync() {
public async getContextsAsync(timeoutMs = kubernetesContextProbeTimeoutMs) {
const contexts = await Promise.all(
[...this.clients.entries()].map(async ([contextId, client]): Promise<KubernetesContextStatus> => {
const [api, metrics] = await Promise.allSettled([
client.versionApi.getCode(),
client.metricsApi.getNodeMetrics(),
withKubernetesProbeTimeoutAsync(
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,
),
]);
const status =
api.status === "rejected" ? "unavailable" : metrics.status === "rejected" ? "degraded" : "available";
@@ -418,6 +418,40 @@ test("prefers an explicitly linked app over duplicate app matches", async () =>
expect(result.candidates[0]?.state).toBe("linked");
});
test("marks a linked integration as moved when only its app URL still 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://old.example:8989",
appId: "linked",
items: [],
secrets: [],
},
]);
findAppsAsyncMock.mockResolvedValueOnce([
{ id: "linked", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
]);
const result = await createAdminCaller().reconcileServices();
expect(result.candidates[0]?.state).toBe("moved");
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
expect(result.candidates[0]?.representation.signals).toMatchObject({
exactIntegrationUrl: false,
exactAppUrl: true,
linked: true,
});
});
test("does not offer to create an app when existing app matches are ambiguous", async () => {
const dockerData = {
data: {
@@ -56,6 +56,7 @@ describe("all should return all integrations", () => {
expect(result.length).toBe(2);
expect(result[0]!.kind).toBe("plex");
expect(result[1]!.kind).toBe("homeAssistant");
expect(result.every(({ permissions }) => permissions.hasFullAccess && permissions.hasUseAccess)).toBe(true);
});
});
@@ -15,14 +15,15 @@ export const constructIntegrationPermissions = (integration: IntegrationPermissi
const permissions = integration.userPermissions
.concat(integration.groupPermissions)
.map(({ permission }) => permission);
const hasGlobalFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
const hasGlobalInteractAccess =
hasGlobalFullAccess || (session?.user.permissions.includes("integration-interact-all") ?? false);
const hasGlobalUseAccess =
hasGlobalInteractAccess || (session?.user.permissions.includes("integration-use-all") ?? false);
return {
hasFullAccess:
(session?.user.permissions.includes("integration-full-all") ?? false) || permissions.includes("full"),
hasInteractAccess:
permissions.includes("full") ||
permissions.includes("interact") ||
(session?.user.permissions.includes("integration-interact-all") ?? false),
hasUseAccess: permissions.length >= 1 || (session?.user.permissions.includes("integration-use-all") ?? false),
hasFullAccess: hasGlobalFullAccess || permissions.includes("full"),
hasInteractAccess: hasGlobalInteractAccess || permissions.includes("full") || permissions.includes("interact"),
hasUseAccess: hasGlobalUseAccess || permissions.length >= 1,
};
};
@@ -30,6 +30,18 @@ describe("constructIntegrationPermissions", () => {
expect(result.hasUseAccess).toBe(true);
});
test("should derive lower access levels from an unexpanded full permission", () => {
const result = constructIntegrationPermissions(
{ userPermissions: [], groupPermissions: [] },
{
user: { id: "2", permissions: ["integration-full-all"], colorScheme: "light" },
expires: new Date().toISOString(),
},
);
expect(result).toEqual({ hasFullAccess: true, hasInteractAccess: true, hasUseAccess: true });
});
test("should return hasInteractAccess as true when session permissions include integration-interact-all", () => {
// Arrange
const integration = {
@@ -77,7 +77,11 @@ export const buildDockerServiceUrlCandidates = ({
for (const port of tcpPorts) {
if (port.PublicPort) {
const publishedHost = isUsablePublishedAddress(port.IP) ? port.IP : endpointHostname;
const publishedHost = isUsablePublishedAddress(port.IP)
? port.IP
: isUsablePublishedAddress(endpointHostname ?? undefined)
? endpointHostname
: null;
if (publishedHost) {
addCandidate({
url: `http://${formatUrlHost(publishedHost)}:${port.PublicPort}`,
@@ -120,8 +124,16 @@ export const normalizeDockerServiceUrl = (value: string | null | undefined) => {
}
};
const isUsablePublishedAddress = (value: string | undefined): value is string =>
Boolean(value && !["0.0.0.0", "::", "::0"].includes(value));
const isUsablePublishedAddress = (value: string | undefined): value is string => {
if (!value) return false;
const host = value.toLowerCase().replace(/^\[|\]$/gu, "");
return (
!["0.0.0.0", "::", "::0", "::1", "localhost"].includes(host) &&
!host.endsWith(".localhost") &&
!/^127(?:\.\d{1,3}){3}$/.test(host) &&
!host.startsWith("::ffff:127.")
);
};
const getEndpointHostname = (host: string) => {
if (host === "socket" || host.startsWith("/")) return null;
@@ -332,6 +332,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
@@ -50,4 +50,30 @@ describe("buildDockerServiceUrlCandidates", () => {
reason: "dockerEndpointHost",
});
});
test.each(["127.0.0.1", "127.12.0.9", "::1", "::ffff:127.0.0.1", "localhost"])(
"does not advertise loopback address %s to browsers",
(address) => {
const candidates = buildDockerServiceUrlCandidates({
containerName: "sonarr",
endpointHost: "socket",
preferredPort: 8989,
ports: [{ IP: address, PrivatePort: 8989, PublicPort: 8989, Type: "tcp" }],
});
expect(candidates.some(({ source }) => source === "publishedAddress")).toBe(false);
expect(candidates.some(({ url, scopes }) => url.includes(address) && scopes.includes("browser"))).toBe(false);
},
);
test("does not use a loopback Docker endpoint as a browser URL", () => {
const candidates = buildDockerServiceUrlCandidates({
containerName: "sonarr",
endpointHost: "[::1]:2375",
preferredPort: 8989,
ports: [{ IP: "0.0.0.0", PrivatePort: 8989, PublicPort: 8989, Type: "tcp" }],
});
expect(candidates.some(({ source }) => source === "endpointHost")).toBe(false);
});
});
@@ -28,6 +28,20 @@ describe("native feature capability descriptor", () => {
test("derives optional connections and server selection limits", () => {
expect(widgetKindsWithOptionalIntegrations).toEqual(new Set(["calendar"]));
expect(widgetIntegrationLimits).toEqual({ audioStats: 1 });
expect(widgetIntegrationLimits).toEqual({
"smartHome-entityState": 1,
"smartHome-executeAutomation": 1,
mediaTranscoding: 1,
"immich-serverStats": 1,
"immich-albumCarousel": 1,
paperlessNgx: 1,
patchmon: 1,
bazarr: 1,
audioStats: 1,
umami: 1,
archiveTeamWarrior: 1,
anchorNote: 1,
wud: 1,
});
});
});
@@ -18,14 +18,20 @@ export interface NativeFeatureCapabilityDescriptor {
export const nativeFeatureCapabilities = {
dnsHoleSummary: { integrations: getIntegrationKindsByCategory("dnsHole") },
dnsHoleControls: { integrations: getIntegrationKindsByCategory("dnsHole") },
"smartHome-entityState": { integrations: getIntegrationKindsByCategory("smartHomeServer") },
"smartHome-executeAutomation": { integrations: getIntegrationKindsByCategory("smartHomeServer") },
"smartHome-entityState": {
integrations: getIntegrationKindsByCategory("smartHomeServer"),
serverMaxIntegrations: 1,
},
"smartHome-executeAutomation": {
integrations: getIntegrationKindsByCategory("smartHomeServer"),
serverMaxIntegrations: 1,
},
mediaServer: { integrations: getIntegrationKindsByCategory("mediaService") },
calendar: { integrations: getIntegrationKindsByCategory("calendar"), connectionOptional: true },
downloads: { integrations: getIntegrationKindsByCategory("downloadClient") },
"mediaRequests-requestList": { integrations: getIntegrationKindsByCategory("mediaRequest") },
"mediaRequests-requestStats": { integrations: getIntegrationKindsByCategory("mediaRequest") },
mediaTranscoding: { integrations: getIntegrationKindsByCategory("mediaTranscoding") },
mediaTranscoding: { integrations: getIntegrationKindsByCategory("mediaTranscoding"), serverMaxIntegrations: 1 },
mediaMissing: { integrations: getIntegrationKindsByCategory("mediaOrganizer") },
networkControllerSummary: { integrations: getIntegrationKindsByCategory("networkController") },
networkControllerStatus: { integrations: getIntegrationKindsByCategory("networkController") },
@@ -45,22 +51,22 @@ export const nativeFeatureCapabilities = {
beszelSystemStats: { integrations: ["beszel", "mock"] },
beszelSystemTable: { integrations: ["beszel", "mock"] },
coolify: { integrations: ["coolify"] },
"immich-serverStats": { integrations: ["immich"] },
"immich-albumCarousel": { integrations: ["immich"] },
paperlessNgx: { integrations: ["paperlessNgx"] },
patchmon: { integrations: ["patchmon"] },
bazarr: { integrations: ["bazarr"] },
"immich-serverStats": { integrations: ["immich"], serverMaxIntegrations: 1 },
"immich-albumCarousel": { integrations: ["immich"], serverMaxIntegrations: 1 },
paperlessNgx: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 },
patchmon: { integrations: ["patchmon"], serverMaxIntegrations: 1 },
bazarr: { integrations: ["bazarr"], serverMaxIntegrations: 1 },
tracearr: { integrations: ["tracearr"] },
speedtestTracker: { integrations: ["speedtestTracker"] },
uptimeKuma: { integrations: ["uptimeKuma"] },
audioStats: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 },
umami: { integrations: ["umami"] },
umami: { integrations: ["umami"], serverMaxIntegrations: 1 },
vpn: { integrations: getIntegrationKindsByCategory("vpn") },
ups: { integrations: getIntegrationKindsByCategory("ups") },
archiveTeamWarrior: { integrations: ["archiveTeamWarrior"] },
anchorNote: { integrations: ["anchor"] },
archiveTeamWarrior: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 },
anchorNote: { integrations: ["anchor"], serverMaxIntegrations: 1 },
traefik: { integrations: ["traefik"] },
wud: { integrations: ["wud"] },
wud: { integrations: ["wud"], serverMaxIntegrations: 1 },
} as const satisfies Partial<Record<WidgetKind, NativeFeatureCapabilityDescriptor>>;
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
+45 -12
View File
@@ -15,6 +15,31 @@ import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-d
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
export const dockerWidgetEndpointTimeoutMs = 5_000;
const withDockerTimeoutAsync = async <T>(
operation: (signal: AbortSignal) => Promise<T>,
message: string,
timeoutMs: number,
) => {
const controller = new AbortController();
const timeoutError = new Error(message);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
};
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
@@ -275,11 +300,15 @@ export const streamContainerLogsAsync = async (
};
};
export async function getContainersWithStatsAsync() {
export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
const instanceContainers = await instance.listContainers({ all: true });
const instanceContainers = await withDockerTimeoutAsync(
async (signal) => await instance.listContainers({ all: true, abortSignal: signal }),
`Timed out listing containers from Docker host ${host}`,
timeoutMs,
);
return instanceContainers
.filter((container) => !(dockerLabels.hide in container.Labels))
.map((container) => ({ ...container, instance: host, endpointId, endpointName }));
@@ -329,16 +358,20 @@ export async function getContainersWithStatsAsync() {
const instance = DockerSingleton.findInstance(container.endpointId)?.instance;
if (!instance) return null;
const stats = await instance
.getContainer(container.Id)
.stats({ stream: false, "one-shot": true })
.catch(
() =>
({
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
memory_stats: { usage: 0 },
}) as ContainerStats,
);
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,
);
const cpuUsage = calculateCpuUsage(stats);
const memoryUsage = calculateMemoryUsage(stats);
@@ -2,8 +2,9 @@ import type { ContainerStats } from "dockerode";
import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { DockerSingleton } from "@homarr/docker";
import { calculateCpuUsage, calculateMemoryUsage } from "../docker";
import { calculateCpuUsage, calculateMemoryUsage, getContainersWithStatsAsync } from "../docker";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -119,3 +120,76 @@ describe("calculateMemoryUsage", () => {
expect(calculateMemoryUsage(stats)).toBe(512);
});
});
describe("getContainersWithStatsAsync", () => {
test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
vi.useFakeTimers();
try {
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
createDockerInstance("stalled", () => new Promise(() => undefined)),
createDockerInstance("healthy", async () => []),
] as never);
const resultPromise = getContainersWithStatsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(result.endpoints).toEqual([
expect.objectContaining({ id: "stalled", status: "unavailable" }),
expect.objectContaining({ id: "healthy", status: "available" }),
]);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
test("returns a container with zeroed stats when its stats request times out", async () => {
vi.useFakeTimers();
try {
const dockerInstance = createDockerInstance("healthy", async () => [
{
Id: "container-1",
Image: "sonarr:latest",
Labels: {},
Names: ["/sonarr"],
State: "running",
Ports: [],
},
]);
let observedSignal: AbortSignal | undefined;
dockerInstance.instance.getContainer = (() => ({
stats: (options: { abortSignal?: AbortSignal }) => {
observedSignal = options.abortSignal;
return new Promise(() => undefined);
},
})) as never;
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([dockerInstance] as never);
vi.spyOn(DockerSingleton, "findInstance").mockReturnValue(dockerInstance as never);
const resultPromise = getContainersWithStatsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(observedSignal?.aborted).toBe(true);
expect(result.containers).toEqual([expect.objectContaining({ id: "container-1", cpuUsage: 0, memoryUsage: 0 })]);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
});
const createDockerInstance = (endpointId: string, listContainers: () => Promise<unknown[]>) => ({
endpointId,
endpointName: endpointId,
host: endpointId,
descriptor: {
kind: "docker",
transport: { type: "socket" },
capabilities: ["inventory"],
source: "environment",
scope: "admin",
},
instance: { listContainers, getContainer: () => ({}) },
});
+19 -3
View File
@@ -1613,6 +1613,10 @@
"label": "Create app",
"description": "Create an app with the same name and icon as the integration. Leave the input field below empty to create the app with the integration URL."
},
"linkApp": {
"label": "Link an existing app",
"description": "Connect this integration to an app you can already use."
},
"appHref": {
"placeholder": "Custom app URL"
},
@@ -2742,18 +2746,29 @@
"title": "Edit item",
"tab": {
"widget": "Edit widget",
"app": "Edit app"
"app": "Edit app",
"integration": "Edit integration"
},
"app": {
"propagationNotice": "Changes to this app will be applied everywhere it is used across all dashboards."
},
"integration": {
"action": "Edit connection",
"description": "Test or repair a selected connection without leaving this widget.",
"editLabel": "Edit {name} integration",
"fullAccessRequired": "Full access required",
"propagationNotice": "Changes to this integration apply everywhere it is used. The connection is tested before it is saved.",
"loading": "Loading integration",
"loadError": "This integration could not be loaded. Try again before changing its connection settings."
},
"advancedOptions": {
"label": "Advanced options",
"title": "Advanced item options"
},
"field": {
"integrations": {
"label": "Integrations"
"label": "Integrations",
"removeLabel": "Remove {name} integration"
},
"title": {
"label": "Title"
@@ -3974,7 +3989,8 @@
},
"integration": {
"noData": "No integration found",
"description": "Click <here></here> to create a new integration"
"description": "Click <here></here> to create a new integration",
"manage": "Manage integrations"
},
"app": {
"noData": "No app found",
@@ -2,8 +2,9 @@
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { Button, Group, Stack, Tabs } from "@mantine/core";
import { Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
@@ -13,6 +14,7 @@ import type { WidgetKind } from "@homarr/definitions";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { zodErrorMap } from "@homarr/validation/form/i18n";
import { getInputForType } from "../_inputs";
@@ -44,8 +46,15 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
itemId?: string;
boardId?: string;
appId?: string;
onEditIntegration?: (integrationId: string) => void;
onIntegrationSaved?: () => void;
}
export const getSelectedWidgetIntegrations = (
integrationData: readonly IntegrationSelectOption[],
selectedIds: readonly string[],
) => integrationData.filter((integration) => selectedIds.includes(integration.id));
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const board = useOptionalBoard();
@@ -61,6 +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 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)
@@ -79,13 +89,17 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
? z.object(optionsFieldSchema).superRefine(optionsSuperRefine)
: z.object(optionsFieldSchema);
let integrationIdsSchema = z.array(z.string());
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
const form = useForm({
mode: "controlled",
initialValues: innerProps.value,
validate: schemaResolver(
z.object({
options: optionsSchema,
integrationIds: integrationsRequired ? z.array(z.string()).min(1) : z.array(z.string()),
integrationIds: integrationIdsSchema,
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
@@ -102,6 +116,10 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
const showResourceTabs = showAppTab || showIntegrationTab;
const handleSubmit = form.onSubmit(async (values) => {
setIsSubmitting(true);
@@ -132,7 +150,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
{...form.getInputProps("integrationIds")}
/>
@@ -165,7 +183,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
/>
);
})}
{showAppTab ? (
{showResourceTabs ? (
<Button
variant="subtle"
type="button"
@@ -203,19 +221,64 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return (
<form onSubmit={onFormSubmit}>
<FormProvider form={form}>
{showAppTab ? (
{showResourceTabs ? (
<Stack>
<Tabs defaultValue="widget">
<Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{t("item.edit.tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
<Text size="sm" c="dimmed">
{t("item.edit.integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true;
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => innerProps.onEditIntegration?.(integration.id)}
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
>
{t("item.edit.integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{t("item.edit.integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</Stack>
</Tabs.Panel>
)}
</Tabs>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
@@ -8,7 +8,6 @@ import {
CloseButton,
Combobox,
Group,
Input,
Pill,
PillsInput,
Stack,
@@ -38,6 +37,7 @@ export const WidgetIntegrationSelect = ({
data,
onChange,
value: valueProp,
label,
canSelectMultiple = true,
withAsterisk = false,
...props
@@ -79,6 +79,7 @@ export const WidgetIntegrationSelect = ({
option={option}
onRemove={() => handleValueRemove(item)}
showRemoveButton={canSelectMultiple}
removeLabel={t("item.edit.field.integrations.removeLabel", { name: option.name })}
/>
);
});
@@ -92,7 +93,7 @@ export const WidgetIntegrationSelect = ({
<Avatar src={getIconUrl(item.kind)} size="sm" />
<Stack gap={0}>
<span>{item.name}</span>
<Text size="xs" c="gray.6">
<Text size="xs" c="dimmed">
{item.url}
</Text>
</Stack>
@@ -109,33 +110,42 @@ export const WidgetIntegrationSelect = ({
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs" span>
{t.rich("widget.common.integration.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("common.here")}
</Anchor>
),
})}
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("widget.common.integration.manage")}
</Anchor>
</Text>
}
pointer
onClick={() => combobox.toggleDropdown()}
onClick={() => combobox.openDropdown()}
label={label}
withAsterisk={withAsterisk}
{...props}
>
<Pill.Group>
{values.length > 0 ? values : <Input.Placeholder>{t("common.multiSelect.placeholder")}</Input.Placeholder>}
{values}
<Combobox.EventsTarget>
<PillsInput.Field
type="hidden"
readOnly
aria-label={label}
placeholder={values.length === 0 ? t("common.multiSelect.placeholder") : undefined}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
onKeyDown={(event) => {
if (event.key !== "Backspace") return;
event.preventDefault();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleValueRemove(multiSelectValues[multiSelectValues.length - 1]!);
if (["ArrowDown", "Enter", " "].includes(event.key)) {
event.preventDefault();
combobox.openDropdown();
return;
}
if (event.key === "Escape") {
combobox.closeDropdown();
return;
}
if (event.key === "Backspace" && multiSelectValues.length > 0) {
event.preventDefault();
const lastValue = multiSelectValues.at(-1);
if (lastValue) handleValueRemove(lastValue);
}
}}
/>
</Combobox.EventsTarget>
@@ -163,22 +173,38 @@ export interface IntegrationSelectOption {
name: string;
url: string;
kind: IntegrationKind;
permissions?: {
hasUseAccess: boolean;
hasInteractAccess: boolean;
hasFullAccess: boolean;
};
}
interface IntegrationPillProps {
option: IntegrationSelectOption;
onRemove: () => void;
showRemoveButton: boolean;
removeLabel: string;
}
const IntegrationPill = ({ option, onRemove, showRemoveButton }: IntegrationPillProps) => (
const IntegrationPill = ({ option, onRemove, showRemoveButton, removeLabel }: IntegrationPillProps) => (
<Group align="center" wrap="nowrap" gap={0} className={classes.pill} mih={24} pr={!showRemoveButton ? 10 : undefined}>
<Avatar src={getIconUrl(option.kind)} size={14} mr={6} />
<Text span size="xs" lh={1} fw={500}>
{option.name}
</Text>
{showRemoveButton && (
<CloseButton onMouseDown={onRemove} variant="transparent" color="gray" size={22} iconSize={14} tabIndex={-1} />
<CloseButton
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
aria-label={removeLabel}
variant="transparent"
color="gray"
size={24}
iconSize={14}
/>
)}
</Group>
);
+12 -1
View File
@@ -97,7 +97,10 @@ const documentationPath = (definition: string | undefined) => {
};
const collectWidgetDefinitions = (root: string) => {
const definitions = new Map<string, { path: string; hasSupportedIntegrations: boolean }>();
const definitions = new Map<
string,
{ path: string; hasSupportedIntegrations: boolean; maxIntegrations: number | undefined }
>();
for (const path of walkFiles(join(root, "packages/widgets/src"))) {
if (!/\/index\.tsx?$/.test(path)) continue;
const source = readFileSync(path, "utf8");
@@ -111,6 +114,7 @@ const collectWidgetDefinitions = (root: string) => {
definitions.set(kind, {
path: relative(root, path),
hasSupportedIntegrations: /\n supportedIntegrations:/.test(body),
maxIntegrations: Number(body.match(/\n maxIntegrations:\s*(\d+)/)?.[1]) || undefined,
});
}
}
@@ -284,6 +288,13 @@ export const checkFeatureContracts = (root: string): ContractProblem[] => {
"Declare supportedIntegrations in both the widget definition and nativeFeatureCapabilities, or in neither.",
});
}
const serverMaxIntegrations = Number(support.get(kind)?.match(/serverMaxIntegrations:\s*(\d+)/)?.[1]) || undefined;
if (definition?.maxIntegrations !== serverMaxIntegrations) {
problems.push({
message: `Widget ${kind} maxIntegrations ${String(definition?.maxIntegrations)} differs from nativeFeatureCapabilities serverMaxIntegrations ${String(serverMaxIntegrations)}`,
repair: "Declare the same integration limit in the widget definition and nativeFeatureCapabilities.",
});
}
}
for (const kind of support.keys()) {
if (!widgetKinds.includes(kind))
+10
View File
@@ -44,6 +44,15 @@ export default defineConfig({
include: ["packages/custom-widgets/src/**/*.spec.{ts,tsx}"],
},
},
{
extends: true,
test: {
name: "request-handler-node",
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: ["packages/request-handler/**/*.spec.{ts,tsx}"],
},
},
{
extends: true,
test: {
@@ -56,6 +65,7 @@ export default defineConfig({
"packages/api/**",
"packages/custom-widgets/**",
"packages/db/**",
"packages/request-handler/**",
"e2e/**",
],
},