From 4905ea673803d8923950bfbb76bed796470a5d12 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 13 Aug 2026 22:28:46 +0200 Subject: [PATCH] fix release v2 recovery flows --- apps/docs/docs/management/boards/index.mdx | 4 + .../docs/management/integrations/index.mdx | 7 ++ .../integration-test-connection-error.tsx | 14 ++- .../edit/[id]/_integration-edit-form.tsx | 88 +++++++++++-------- .../new/_integration-new-form.tsx | 32 ++++--- .../configmaps/configmaps-table.tsx | 7 +- .../kubernetes/ingresses/ingresses-table.tsx | 7 +- .../kubernetes-context-selector.tsx | 13 ++- .../namespaces/namespaces-table.tsx | 7 +- .../tools/kubernetes/nodes/nodes-table.tsx | 7 +- .../tools/kubernetes/pods/pods-table.tsx | 7 +- .../kubernetes/secrets/secrets-table.tsx | 7 +- .../kubernetes/services/services-table.tsx | 7 +- .../kubernetes/volumes/volumes-table.tsx | 7 +- .../assistant/assistant-widget-tool.spec.ts | 8 +- .../assistant/assistant-widget-tool.tsx | 2 +- .../src/components/board/items/item-menu.tsx | 6 +- .../board/items/item-select-data.spec.ts | 16 ++++ .../board/items/item-select-data.ts | 9 +- .../board/items/item-select-modal.tsx | 12 ++- .../board/items/lazy-widget-edit-modal.tsx | 75 +++++++++++++++- .../board/items/widget-context-menu.tsx | 8 +- .../embedded-integration-edit-modal.tsx | 86 ++++++++++++++++++ docs/release-v2-experience-implementation.md | 17 ++++ .../router/docker/docker-reconciliation.ts | 11 ++- .../router/integration/integration-router.ts | 33 ++----- .../kubernetes/kubernetes-client.spec.ts | 40 ++++++++- .../router/kubernetes/kubernetes-client.ts | 59 ++++++++++++- .../router/test/docker/docker-router.spec.ts | 34 +++++++ .../integration/integration-router.spec.ts | 1 + .../permissions/integration-permissions.ts | 15 ++-- .../test/integration-permissions.spec.ts | 12 +++ .../src/docker-service-discovery.ts | 18 +++- .../src/docs/homarr-docs-sitemap.ts | 1 + .../src/test/docker-service-discovery.spec.ts | 26 ++++++ .../src/test/widget-integration-map.spec.ts | 16 +++- .../definitions/src/widget-integration-map.ts | 30 ++++--- packages/request-handler/src/docker.ts | 57 +++++++++--- .../request-handler/src/test/docker.spec.ts | 76 +++++++++++++++- packages/translation/src/lang/en.json | 22 ++++- .../widgets/src/modals/widget-edit-modal.tsx | 81 +++++++++++++++-- .../widgets/src/widget-integration-select.tsx | 64 ++++++++++---- scripts/feature-platform/contracts.mts | 13 ++- vitest.config.mts | 10 +++ 44 files changed, 871 insertions(+), 201 deletions(-) create mode 100644 apps/nextjs/src/components/integration/embedded-integration-edit-modal.tsx diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 8b00a36fc..76734d565 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -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. diff --git a/apps/docs/docs/management/integrations/index.mdx b/apps/docs/docs/management/integrations/index.mdx index ceec74ef4..a7ccbe1a6 100644 --- a/apps/docs/docs/management/integrations/index.mdx +++ b/apps/docs/docs/management/integrations/index.mdx @@ -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 diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/integration-test-connection-error.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/integration-test-connection-error.tsx index a50625106..42bc8b065 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/integration-test-connection-error.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/integration-test-connection-error.tsx @@ -42,7 +42,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo {t.rich("integration.testConnection.error.statusCode.otherDescription", { statusCode: error.data.statusCode.toString(), - url: () => {error.data.url}, + url: () => ( + + {error.data.url} + + ), })} ) : ( @@ -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: () => {error.data.url}, + url: () => ( + + {error.data.url} + + ), })} ) @@ -66,7 +74,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo {t("integration.testConnection.error.common.cause.title")} -
+                
                   {error.name}: {error.message}
                   {"\n"}
                   {causeArray
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx
index d04b5389e..a2f5e0290 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx
@@ -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);
+  const errorRef = useRef(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) => {
 
          form.setFieldValue("app", app)} />
 
-        {error !== null && }
+        {error !== null && (
+          
+ +
+ )} - + {!embedded && ( + + )} diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 04b43c8a6..b226653f9 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -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; 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", + )} /> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx index 341538fb4..47360ef15 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx index afdb27c91..a9364f923 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx index d61636a4b..ad1165725 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context-selector.tsx @@ -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({ 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 ( diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx index 6c1aa28d1..df1e3b9ea 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx index 132b321f5..6ccdcc97d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx index 7b8f0466e..2afbec470 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx index 5a4fa6a68..fe66f05e3 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx index cc075627b..2cdfd5800 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx index caf404948..9cad0a017 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx @@ -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), }, ); diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts index 252a0fa99..71d9153e0 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts @@ -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 }, }, ], ); diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx index 7bbb947c8..cdd00d62e 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx @@ -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; } export const getAssistantWidgetConfiguration = ( diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index c373e3c66..7af5f9fd1 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -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), ), diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts index 6674a42a0..a9527a3ba 100644 --- a/apps/nextjs/src/components/board/items/item-select-data.spec.ts +++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts @@ -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]]); + }); }); diff --git a/apps/nextjs/src/components/board/items/item-select-data.ts b/apps/nextjs/src/components/board/items/item-select-data.ts index 1842e44d3..ef6eb49f4 100644 --- a/apps/nextjs/src/components/board/items/item-select-data.ts +++ b/apps/nextjs/src/components/board/items/item-select-data.ts @@ -26,7 +26,9 @@ export const unlockSelection = (lock: { current: boolean }) => { lock.current = false; }; -export const resolveMatchingIntegrationsAsync = async ({ +export const resolveMatchingIntegrationsAsync = async < + TIntegration extends { kind: IntegrationKind; permissions?: { hasUseAccess: boolean } }, +>({ hasIntegrationSupport, supportedIntegrations, currentData, @@ -40,5 +42,8 @@ export const resolveMatchingIntegrationsAsync = async supportedIntegrations.includes(integration.kind)); + return integrations.filter( + (integration) => + integration.permissions?.hasUseAccess !== false && supportedIntegrations.includes(integration.kind), + ); }; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index a2d501d50..0cd8ba8dc 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -104,7 +104,10 @@ const ItemSelectModalContent = ({ const trackSetup = useSetupAnalytics(); const flowStartedAt = useRef(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, diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx index 642c2ab36..719a5716e 100644 --- a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx +++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx @@ -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 | 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 | 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 | 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 ; +}; + +const LazyIntegrationEditModal = createModal((props) => ( + ( +
+ { + integrationEditModalPromise = undefined; + resetErrorBoundary(); + }} + /> +
+ )} + > + + + + } + > + + +
+)).withOptions({ + defaultTitle: (t) => t("item.edit.tab.integration"), + size: modalSizeForm, + presentation: "inspector", + closeOnClickOutside: false, +}); + interface LazyWidgetEditModalContentProps { actions: { closeModal: () => void }; innerProps: WidgetEditModalProps; @@ -32,7 +90,20 @@ interface LazyWidgetEditModalContentProps { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const { WidgetEditModal } = use(loadWidgetEditModal()); const Component = WidgetEditModal.component; - return ; + const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal); + return ( + + openIntegrationEditModal({ + integrationId, + onSuccess: props.innerProps.onIntegrationSaved, + }), + }} + /> + ); }; export const LazyWidgetEditModal = createModal>((props) => ( diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 50d69da51..059ef0711 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -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, diff --git a/apps/nextjs/src/components/integration/embedded-integration-edit-modal.tsx b/apps/nextjs/src/components/integration/embedded-integration-edit-modal.tsx new file mode 100644 index 000000000..10849c67d --- /dev/null +++ b/apps/nextjs/src/components/integration/embedded-integration-edit-modal.tsx @@ -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( + ({ actions, innerProps }) => { + const t = useI18n(); + const { + data: integration, + isPending, + isError, + refetch, + } = clientApi.integration.byId.useQuery({ + id: innerProps.integrationId, + }); + + if (isPending) { + return ( + + + + + + ); + } + + if (isError || !integration) { + return ( + + + {t("item.edit.integration.loadError")} + + + + ); + } + + return ( + + + + + + {integration.name} + + + {getIntegrationName(integration.kind)} + + + + } color="blue" variant="light"> + {t("item.edit.integration.propagationNotice")} + + { + innerProps.onSuccess?.(); + actions.closeModal(); + }} + /> + + ); + }, +).withOptions({ + defaultTitle: (t) => t("item.edit.tab.integration"), + size: modalSizeForm, + presentation: "inspector", + closeOnClickOutside: false, +}); diff --git a/docs/release-v2-experience-implementation.md b/docs/release-v2-experience-implementation.md index 1a2c65935..018347b59 100644 --- a/docs/release-v2-experience-implementation.md +++ b/docs/release-v2-experience-implementation.md @@ -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. diff --git a/packages/api/src/router/docker/docker-reconciliation.ts b/packages/api/src/router/docker/docker-reconciliation.ts index 6e915dc33..491ea7397 100644 --- a/packages/api/src/router/docker/docker-reconciliation.ts +++ b/packages/api/src/router/docker/docker-reconciliation.ts @@ -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, diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index c4e812afe..98b2cdb56 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -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( diff --git a/packages/api/src/router/kubernetes/kubernetes-client.spec.ts b/packages/api/src/router/kubernetes/kubernetes-client.spec.ts index d78582775..0c701110a 100644 --- a/packages/api/src/router/kubernetes/kubernetes-client.spec.ts +++ b/packages/api/src/router/kubernetes/kubernetes-client.spec.ts @@ -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 } }[] } + ).middleware?.[0]; + await middleware?.pre?.({ setSignal: (signal: AbortSignal) => (observedSignal = signal) }).toPromise(); + return await new Promise(() => 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(); diff --git a/packages/api/src/router/kubernetes/kubernetes-client.ts b/packages/api/src/router/kubernetes/kubernetes-client.ts index 9d60d0b6d..a77a5a755 100644 --- a/packages/api/src/router/kubernetes/kubernetes-client.ts +++ b/packages/api/src/router/kubernetes/kubernetes-client.ts @@ -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 ( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number, +) => { + const controller = new AbortController(); + const timeoutError = new Error("Kubernetes context probe timed out"); + let timeout: ReturnType | undefined; + + try { + return await Promise.race([ + operation(controller.signal), + new Promise((_, 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 => { 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"; diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index 500eab8ee..76a6c81e1 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -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: { diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index cfbbc3132..1591debb2 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -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); }); }); diff --git a/packages/auth/permissions/integration-permissions.ts b/packages/auth/permissions/integration-permissions.ts index cde191996..a642c1a3d 100644 --- a/packages/auth/permissions/integration-permissions.ts +++ b/packages/auth/permissions/integration-permissions.ts @@ -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, }; }; diff --git a/packages/auth/permissions/test/integration-permissions.spec.ts b/packages/auth/permissions/test/integration-permissions.spec.ts index b84041ae6..4780518c3 100644 --- a/packages/auth/permissions/test/integration-permissions.spec.ts +++ b/packages/auth/permissions/test/integration-permissions.spec.ts @@ -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 = { diff --git a/packages/definitions/src/docker-service-discovery.ts b/packages/definitions/src/docker-service-discovery.ts index fc7de8bfd..de9d72948 100644 --- a/packages/definitions/src/docker-service-discovery.ts +++ b/packages/definitions/src/docker-service-discovery.ts @@ -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; diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 015da41d5..92cc02d44 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -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" diff --git a/packages/definitions/src/test/docker-service-discovery.spec.ts b/packages/definitions/src/test/docker-service-discovery.spec.ts index 337cb9514..fec035110 100644 --- a/packages/definitions/src/test/docker-service-discovery.spec.ts +++ b/packages/definitions/src/test/docker-service-discovery.spec.ts @@ -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); + }); }); diff --git a/packages/definitions/src/test/widget-integration-map.spec.ts b/packages/definitions/src/test/widget-integration-map.spec.ts index 85ab6b500..33407069e 100644 --- a/packages/definitions/src/test/widget-integration-map.spec.ts +++ b/packages/definitions/src/test/widget-integration-map.spec.ts @@ -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, + }); }); }); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index e97691a7b..75abfffe3 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -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>; const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [ diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index f0a503ae2..f2a6d96f8 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -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 ( + operation: (signal: AbortSignal) => Promise, + message: string, + timeoutMs: number, +) => { + const controller = new AbortController(); + const timeoutError = new Error(message); + let timeout: ReturnType | undefined; + + try { + return await Promise.race([ + operation(controller.signal), + new Promise((_, 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); diff --git a/packages/request-handler/src/test/docker.spec.ts b/packages/request-handler/src/test/docker.spec.ts index b4e34f816..26b5bc6dd 100644 --- a/packages/request-handler/src/test/docker.spec.ts +++ b/packages/request-handler/src/test/docker.spec.ts @@ -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) => ({ + endpointId, + endpointName: endpointId, + host: endpointId, + descriptor: { + kind: "docker", + transport: { type: "socket" }, + capabilities: ["inventory"], + source: "environment", + scope: "admin", + }, + instance: { listContainers, getContainer: () => ({}) }, +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 7af4576db..4d8afac89 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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 to create a new integration" + "description": "Click to create a new integration", + "manage": "Manage integrations" }, "app": { "noData": "No app found", diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 136ca236a..431fa3050 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -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 { 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>(({ actions, innerProps }) => { const t = useI18n(); const board = useOptionalBoard(); @@ -61,6 +70,7 @@ export const WidgetEditModal = createModal>(({ 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; const optionsSuperRefine = (options as Record)[OPTIONS_SUPER_REFINE] as | ((data: Record, ctx: z.RefinementCtx) => void) @@ -79,13 +89,17 @@ export const WidgetEditModal = createModal>(({ ? 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>(({ 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>(({ 1} + canSelectMultiple={maxIntegrations > 1} withAsterisk={integrationsRequired} {...form.getInputProps("integrationIds")} /> @@ -165,7 +183,7 @@ export const WidgetEditModal = createModal>(({ /> ); })} - {showAppTab ? ( + {showResourceTabs ? ( + ) : ( + + {t("item.edit.integration.fullAccessRequired")} + + )} +
+ ); + })} + + + )}