diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index 140ce3873..4048512a1 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -58,11 +58,12 @@ A random secret can be generated by using the following command: `openssl rand - ## Docker -| Environment Variable | Description | Possible values | Default | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | -| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | -| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - | -| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - | +| Environment Variable | Description | Possible values | Default | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | +| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - | +| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - | +| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - | +| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - | ## Database diff --git a/apps/docs/docs/integrations/docker/index.mdx b/apps/docs/docs/integrations/docker/index.mdx index 04ed74fa5..2b83460dc 100644 --- a/apps/docs/docs/integrations/docker/index.mdx +++ b/apps/docs/docs/integrations/docker/index.mdx @@ -122,9 +122,77 @@ services: DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock ``` +For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities: + +```yaml +services: + homarr: + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./docker-ca.pem:/run/secrets/docker-ca.pem:ro + environment: + DOCKER_ENDPOINTS: >- + [ + { + "id": "local", + "name": "Local Docker", + "kind": "docker", + "transport": { "type": "socket", "path": "/var/run/docker.sock" }, + "capabilities": ["inventory", "logs", "lifecycle", "remove"] + }, + { + "id": "remote-read-only", + "name": "Production inventory", + "kind": "docker", + "transport": { + "type": "tls", + "host": "docker.example.com", + "port": 2376, + "caPath": "/run/secrets/docker-ca.pem" + }, + "capabilities": ["inventory", "logs"] + } + ] +``` + +The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server. + +Supported transports are: + +- `socket`: an absolute in-container `path` +- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS +- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true` + +:::warning[Plaintext TCP] +Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. +::: + +When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables. + ### Managing your Docker containers within Homarr Homarr allows you to interact with Docker containers running on your system. +The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type. + +Homarr can show a service as: + +- **Ready to connect** when its image or name matches a supported integration +- **New service** when it can be added as an app but no integration is recognized +- **Already represented** or **Linked** when an app or integration already matches +- **Address changed** when a service appears to match an integration but its reachable address has changed + +Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing. + +:::info[Review before changing] +Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup. +::: + +Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically. + +The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy. + +If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation. + Each container row has an action menu with the following options: - **View logs** - opens a dedicated logs page with a live terminal diff --git a/apps/docs/docs/integrations/kubernetes/index.mdx b/apps/docs/docs/integrations/kubernetes/index.mdx index 662874467..7183a9b63 100644 --- a/apps/docs/docs/integrations/kubernetes/index.mdx +++ b/apps/docs/docs/integrations/kubernetes/index.mdx @@ -14,6 +14,8 @@ import { kubernetesIntegration } from "."; Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled. +Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically. + :::warning[Read only] This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment. ::: @@ -25,7 +27,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster Before deploying Homarr in Kubernetes, ensure the following prerequisites are met: - Kubernetes (Version >= 1.24.0) -- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server)) +- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable. - RBAC Enabled (Required for Homarr Helm chart) ### Configuring RBAC in Helm Chart @@ -45,6 +47,25 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce - ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility. ::: +### Multiple Kubernetes contexts + +When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container: + +```yaml +services: + homarr: + volumes: + - ./kubeconfig:/app/config/kubeconfig:ro + environment: + KUBECONFIG: /app/config/kubeconfig +``` + +The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate. + +Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage. + +When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context. + ### Homarr Dashboard Overview Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes: diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 82fde7057..bd2ab1363 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -86,6 +86,30 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom` Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. +## Docker action payload migration + +Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a +breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action. + +Replace the old payload: + +```json +{ "ids": ["container-id"] } +``` + +with endpoint-qualified targets: + +```json +{ + "targets": [{ "endpointId": "local", "id": "container-id" }] +} +``` + +Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action. +Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool +names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different +Docker or Podman endpoints from targeting the wrong service. + ## Partial integration failures Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 2d14a6016..cc6a5feb6 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -204,6 +204,15 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa style={{ maxWidth: "100%" }} /> +While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and +Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the +current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as +destinations before the widget is created, so the common flow does not require a second move step. + +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 from the inert widget area; interactive controls are excluded. Containers use the grab button at their top-left corner. Widgets can be resized from any edge or corner handle. Activate a Container's chrome with its top-left grip before using its resize handles. @@ -218,7 +227,11 @@ During a pointer or touch interaction, Escape cancels the move or resize and res Invalid, outside, or otherwise forbidden drops are cancelled in the same way. The **Move / resize item** action in the edit menu remains available for precise numeric input. -A focused board tile in keyboard editing mode +A focused board tile in keyboard editing mode #### Preview diff --git a/apps/docs/docs/management/integrations/index.mdx b/apps/docs/docs/management/integrations/index.mdx index f4960560d..a7ccbe1a6 100644 --- a/apps/docs/docs/management/integrations/index.mdx +++ b/apps/docs/docs/management/integrations/index.mdx @@ -59,6 +59,22 @@ It is not allowed to create an integration without passing the test. This is to ensure that you minimize the amount of broken applications on your Homarr instance. The test and create button will perform a network request to the application and authenticate if required. +When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it +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 +recommendations for that service on that board. + ## Managing permissions On the edit page of an integration, you can manage which users / groups can diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 9ad858459..0b1bdd822 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -1266,9 +1266,15 @@ "properties": { "boardId": { "type": "string" + }, + "name": { + "type": "string" + }, + "layoutId": { + "type": "string" } }, - "required": ["boardId"], + "required": ["boardId", "name", "layoutId"], "additionalProperties": false } } diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 45c46acef..9b8c3305d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -66,6 +66,7 @@ "@mantine/form": "catalog:", "@mantine/hooks": "catalog:", "@mantine/modals": "catalog:", + "@mantine/notifications": "catalog:", "@mantine/tiptap": "catalog:", "@modelcontextprotocol/server": "catalog:", "@rexa-developer/tiks": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 9a2d65b36..bfd8d33ec 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -8,6 +8,8 @@ import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview"; import { useScopedI18n } from "@homarr/translation/client"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; +import { BoardEmptyState } from "~/components/board/board-empty-state"; +import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, @@ -64,6 +66,8 @@ export const ClientBoard = () => { + + { - const { data: session } = useSession(); const board = useRequiredBoard(); - const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); - const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); - const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); - const { addContainer } = useContainerActions(); - const { createItem } = useItemActions(); + const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions(); + const { openModal } = useModalAction(UniversalCreateModal); const t = useI18n(); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const handledInitialAdd = useRef(false); - const handleSelectItem = () => { - openItemSelectModal({ boardId: board.id }); - }; + useEffect(() => { + if (handledInitialAdd.current || searchParams.get("add") !== "true") return; + handledInitialAdd.current = true; + addWidget(); - const handleSelectApp = () => { - openAppSelectModal({ - onSelect: (app) => { - createItem({ - kind: "app", - options: { appId: app.id }, - }); - }, - withCreate: session?.user.permissions.includes("app-create") ?? false, - }); - }; - - const handleAddIntegration = () => { - openIntegrationSelectModal({}); - }; + const nextSearchParams = new URLSearchParams(searchParams.toString()); + nextSearchParams.delete("add"); + const query = nextSearchParams.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, [addWidget, board.id, pathname, router, searchParams]); return ( - - - - - - - - - - - } onClick={handleSelectItem}> - {t("item.action.create")} - - } onClick={handleSelectApp}> - {t("app.action.add")} - - } onClick={handleAddIntegration}> - {t("integration.action.create")} - - - } onClick={addContainer}> - {t("section.container.action.create")} - - - + + openModal({ + boardActions: { + widget: addWidget, + app: addApp, + integration: canConnectService ? connectService : undefined, + container: addContainer, + }, + }) + } + > + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 633c097dd..397d5274f 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; import { IconDeviceFloppy, IconLayoutBoard, @@ -98,6 +99,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { latestBoardRef.current = board; const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({ + onMutate(boardToSave) { + boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`)); + }, onSuccess() { showSuccessNotification({ title: t("notification.success.title"), diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index fedcefa3b..4fb20c461 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -54,7 +54,12 @@ export default async function ManageBoardsPage() { floatingPrimaryAction={canCreateBoards} > {boards.length === 0 && ( - + )} {boards.length > 0 && ( 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/_components/test-connection/types.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/types.ts
index 7ca7f281c..1549b70cb 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/types.ts
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/types.ts
@@ -1,6 +1,8 @@
 import type { RouterOutputs } from "@homarr/api";
 
-export type AnyMappedTestConnectionError = Exclude["error"];
+type IntegrationCreateErrorResult = Extract;
+
+export type AnyMappedTestConnectionError = IntegrationCreateErrorResult["error"];
 export type MappedTestConnectionCertificateError = Extract;
 export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
 export type MappedError = Exclude;
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..ecf066e56 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);
@@ -67,53 +69,65 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
       app: integration.app ?? null,
     },
   });
-  const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
+  const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
+    onError() {
+      showErrorNotification({
+        title: t("integration.page.edit.notification.error.title"),
+        message: t("integration.page.edit.notification.error.message"),
+      });
+    },
+  });
   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(
-      {
-        id: integration.id,
-        ...values,
-        url,
-        secrets: values.secrets.map((secret) => ({
-          kind: secret.kind,
-          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;
-          }
+    setError(null);
+    let url: string;
+    try {
+      url = hasUrlSecret
+        ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
+        : values.url;
+    } catch {
+      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"),
-          });
-        },
-      },
-    );
+    const data = await mutateAsync({
+      id: integration.id,
+      ...values,
+      url,
+      secrets: values.secrets.map((secret) => ({
+        kind: secret.kind,
+        value: secret.value === "" ? null : secret.value,
+      })),
+      appId: app?.id ?? null,
+    });
+
+    // 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"));
+    }
   };
 
   const isInitialSecretKinds =
@@ -190,12 +204,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 e48ae9df5..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 @@ -19,6 +19,7 @@ import { import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react"; import { z } from "zod/v4"; +import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; @@ -49,10 +50,12 @@ interface NewIntegrationFormProps { kind: IntegrationKind; initialUrl?: string; initialName?: string; - onSuccess: () => void; + onSuccess: (result?: CreatedIntegrationResult) => void; onCancel?: () => void; } +export type CreatedIntegrationResult = Extract; + const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( z.object({ hasApp: z.boolean(), @@ -67,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, @@ -81,7 +90,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o value: "", })), attemptSearchEngineCreation: true, - hasApp: true, + hasApp: canCreateApps, appHref: url, appId: null, }, @@ -101,7 +110,9 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url; - const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => { + const onMutationSuccess = ( + data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void, + ) => { if (data && "error" in data && data.error) { setError(data.error); showErrorNotification({ @@ -116,7 +127,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o message: t("integration.page.create.notification.success.message"), }); - onSuccess(); + onSuccess(data && "integration" in data ? data : undefined); }; const onMutationError = () => { @@ -131,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( @@ -229,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/docker/docker-reconciliation-inbox.spec.ts b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.spec.ts new file mode 100644 index 000000000..d82948438 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; + +import { + dismissDockerReconciliationCandidate, + filterDockerReconciliationInbox, + getValidDockerServiceUrl, +} from "./docker-reconciliation-inbox"; + +const candidates = [ + { candidateKey: "home:new", state: "newRecognized" as const }, + { candidateKey: "home:new-app", state: "newApp" as const }, + { candidateKey: "remote:moved", state: "moved" as const }, + { candidateKey: "home:linked", state: "linked" as const }, + { candidateKey: "home:represented", state: "represented" as const }, +]; + +describe("Docker reconciliation inbox", () => { + test("separates attention items from represented services", () => { + expect( + filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey), + ).toEqual(["home:new", "home:new-app", "remote:moved"]); + expect( + filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey), + ).toEqual(["home:linked", "home:represented"]); + }); + + test("dismisses only the endpoint-qualified candidate key and remains idempotent", () => { + const dismissed = dismissDockerReconciliationCandidate([], "home:new"); + + expect( + filterDockerReconciliationInbox(candidates, "all", dismissed).map(({ candidateKey }) => candidateKey), + ).not.toContain("home:new"); + expect(filterDockerReconciliationInbox(candidates, "all", dismissed)).toContainEqual({ + candidateKey: "remote:moved", + state: "moved", + }); + expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed); + }); + + test("accepts only HTTP service URLs", () => { + expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/"); + expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull(); + expect(getValidDockerServiceUrl("service.example.com")).toBeNull(); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.ts b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.ts new file mode 100644 index 000000000..c6101abdc --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation-inbox.ts @@ -0,0 +1,34 @@ +export type DockerReconciliationInboxFilter = "attention" | "represented" | "all"; + +interface DockerReconciliationInboxCandidate { + candidateKey: string; + state: "newRecognized" | "newApp" | "represented" | "linked" | "moved"; +} + +const attentionStates = new Set(["newRecognized", "newApp", "moved"]); + +export const filterDockerReconciliationInbox = ( + candidates: Candidate[], + filter: DockerReconciliationInboxFilter, + dismissedCandidateKeys: string[], +) => { + const dismissed = new Set(dismissedCandidateKeys); + return candidates.filter((candidate) => { + if (dismissed.has(candidate.candidateKey)) return false; + if (filter === "attention") return attentionStates.has(candidate.state); + if (filter === "represented") return candidate.state === "represented" || candidate.state === "linked"; + return true; + }); +}; + +export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) => + dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey]; + +export const getValidDockerServiceUrl = (value: string) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx new file mode 100644 index 000000000..55c85523a --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Alert, + Avatar, + Badge, + Button, + Card, + Group, + SegmentedControl, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; +import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { getIntegrationName } from "@homarr/definitions"; +import { useModalAction } from "@homarr/modals"; +import { AddDockerAppToHomarr } from "@homarr/modals-collection"; +import { showErrorNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; +import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox"; +import { + dismissDockerReconciliationCandidate, + filterDockerReconciliationInbox, + getValidDockerServiceUrl, +} from "./docker-reconciliation-inbox"; + +type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number]; +type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number]; + +export const DockerReconciliation = () => { + const t = useScopedI18n("docker.reconciliation"); + const utils = clientApi.useUtils(); + const reconciliation = clientApi.docker.reconcileServices.useQuery(); + const health = clientApi.docker.getServiceHealth.useQuery(); + const refreshInventory = clientApi.docker.refreshInventory.useMutation({ + async onSuccess() { + await Promise.all([ + utils.docker.getContainers.invalidate(), + utils.docker.reconcileServices.invalidate(), + utils.docker.getServiceHealth.invalidate(), + ]); + }, + onError() { + showErrorNotification({ + title: t("refreshError.title"), + message: t("refreshError.message"), + }); + }, + }); + const [filter, setFilter] = useState("attention"); + const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage({ + key: "homarr-docker-reconciliation-dismissed", + defaultValue: [], + }); + + if (reconciliation.isError) { + return ( + } title={t("loadError.title")}> + + {t("loadError.message")} + + + + ); + } + + if (!reconciliation.data) return null; + + const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable"); + const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys); + const attentionCount = health.data + ? health.data.services.filter( + ({ key, status }) => + ["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key), + ).length + : 0; + const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching; + + return ( + + + +
+ + + {t("title")} + + + {t("description")} + +
+ {health.data && {t("attention", { count: String(attentionCount) })}} +
+ + + setFilter(value as DockerReconciliationInboxFilter)} + data={[ + { value: "attention", label: t("filter.attention") }, + { value: "represented", label: t("filter.represented") }, + { value: "all", label: t("filter.all") }, + ]} + /> + + {dismissedCandidateKeys.length > 0 && ( + + )} + + + + + {unavailableEndpoints.length > 0 && ( + }> + {t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })} + + )} + + {candidates.length === 0 ? ( + + {reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")} + + ) : ( + + {candidates.map((candidate) => ( + key === candidate.candidateKey)} + onDismiss={() => + setDismissedCandidateKeys((current) => + dismissDockerReconciliationCandidate(current, candidate.candidateKey), + ) + } + /> + ))} + + )} +
+
+ ); +}; + +const DockerReconciliationCandidate = ({ + candidate, + health, + onDismiss, +}: { + candidate: ReconciliationCandidate; + health: ServiceHealth | undefined; + onDismiss: () => void; +}) => { + const t = useScopedI18n("docker.reconciliation"); + const { openModal } = useModalAction(AddDockerAppToHomarr); + const initialUrlCandidate = + candidate.urlCandidates.find( + ({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")), + ) ?? candidate.urlCandidates.find(({ source }) => source === "manual"); + const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null); + const [url, setUrl] = useState(initialUrlCandidate?.url ?? ""); + const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId); + const validatedUrl = getValidDockerServiceUrl(url); + const isInvalidUrl = url.length > 0 && validatedUrl === null; + const target = getCandidateTarget(candidate, validatedUrl ?? ""); + const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration"; + const isActionDisabled = actionNeedsUrl && validatedUrl === null; + + useEffect(() => { + setSelectedCandidateId(initialUrlCandidate?.id ?? null); + setUrl(initialUrlCandidate?.url ?? ""); + }, [initialUrlCandidate?.id, initialUrlCandidate?.url]); + + return ( + + + + + + {candidate.container.name.at(0)?.toUpperCase()} + +
+ + {candidate.container.name} + + + {candidate.endpointName} + +
+
+ + {t(`state.${candidate.state}`)} + +
+ + {candidate.match && ( + + {candidate.match.kind} + + {t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })} + + + )} + + {candidate.representation.signals.ambiguous && ( + }> + {t("ambiguous")} + + )} + + {health && ( +
+ + {t("health.title")} + + + {health.layers.map((layer) => ( + + {t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)} + + ))} + +
+ )} + + ({ value: item.contextId, label: item.name }))} + onChange={selectContext} + allowDeselect={false} + /> + {context && ( + + {t(`status.${context.status}`)} + + )} +
+ {context?.status === "unavailable" && ( + }> + {t("unavailable")} + + )} + {context?.status === "degraded" && ( + }> + {t("metricsUnavailable")} + + )} + + ); +}; + +export const useSelectedKubernetesContextId = () => { + const searchParams = useSearchParams(); + 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 ( + data?.contexts.find(({ contextId }) => contextId === data.defaultContextId)?.contextId ?? + data?.contexts[0]?.contextId + ); +}; + +const contextStatusColor = (status: "available" | "degraded" | "unavailable") => { + if (status === "available") return "green"; + if (status === "degraded") return "yellow"; + return "red"; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context.ts b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context.ts new file mode 100644 index 000000000..13d700d84 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-context.ts @@ -0,0 +1,16 @@ +import { api } from "@homarr/api/server"; + +export interface KubernetesContextSearchParams { + context?: string | string[]; +} + +export const getSelectedKubernetesContextAsync = async (searchParams: Promise) => { + const [{ context }, result] = await Promise.all([searchParams, api.kubernetes.contexts.getContexts()]); + const requestedContextId = Array.isArray(context) ? context[0] : context; + const selected = + result.contexts.find(({ contextId }) => contextId === requestedContextId) ?? + result.contexts.find(({ contextId }) => contextId === result.defaultContextId) ?? + result.contexts[0]; + if (!selected) throw new Error("No Kubernetes context is configured"); + return selected; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/layout.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/layout.tsx new file mode 100644 index 000000000..e3fd6c49a --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/layout.tsx @@ -0,0 +1,15 @@ +import { Suspense } from "react"; +import { Stack } from "@mantine/core"; + +import { KubernetesContextSelector } from "./kubernetes-context-selector"; + +export default function KubernetesLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + ); +} 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 2486a4ad0..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 @@ -18,6 +18,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; dayjs.extend(relativeTime); interface NamespacesTableComponentProps { + contextId: string; initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"]; } @@ -56,15 +57,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.namespaces">): M }, ]; -export function NamespacesTable(initialData: NamespacesTableComponentProps) { +export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTableComponentProps) { const tNamespaces = useScopedI18n("kubernetes.namespaces"); - const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, { - initialData: initialData.initialNamespaces, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); + const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery( + { contextId }, + { + initialData: initialNamespaces, + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), + }, + ); const table = useTranslatedMantineReactTable({ data, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx index 14b48f757..72dd362ca 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx @@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server"; import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import type { KubernetesContextSearchParams } from "../kubernetes-context"; +import { getSelectedKubernetesContextAsync } from "../kubernetes-context"; -export default async function NamespacesPage() { +export default async function NamespacesPage({ + searchParams, +}: { + searchParams: Promise; +}) { const session = await auth(); if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { notFound(); } - const namespaces = await api.kubernetes.namespaces.getNamespaces(); + const context = await getSelectedKubernetesContextAsync(searchParams); + const namespaces = + context.status === "unavailable" + ? [] + : await api.kubernetes.namespaces.getNamespaces({ contextId: context.contextId }); const tNamespaces = await getScopedI18n("kubernetes.namespaces"); return ( <> {tNamespaces("label")} - + ); 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 2fc69d51f..c0e054f5c 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 @@ -15,9 +15,12 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useScopedI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import KubernetesErrorPage from "../cluster-dashboard/error"; + dayjs.extend(relativeTime); interface NodesListComponentProps { + contextId: string; initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"]; } @@ -49,14 +52,14 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co accessorKey: "allocatableCpuPercentage", header: t("field.cpu.label"), Cell({ cell }) { - return getRingProgress(cell.row.original.allocatableCpuPercentage); + return getRingProgress(cell.row.original.allocatableCpuPercentage, t("field.metricsUnavailable")); }, }, { accessorKey: "allocatableRamPercentage", header: t("field.memory.label"), Cell({ cell }) { - return getRingProgress(cell.row.original.allocatableRamPercentage); + return getRingProgress(cell.row.original.allocatableRamPercentage, t("field.metricsUnavailable")); }, }, { @@ -82,15 +85,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co }, ]; -export function NodesTable(initialData: NodesListComponentProps) { +export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) { const tNodes = useScopedI18n("kubernetes.nodes"); - const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, { - initialData: initialData.initialNodes, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); + const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery( + { contextId }, + { + initialData: initialNodes, + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), + }, + ); const table = useTranslatedMantineReactTable({ data, @@ -112,10 +119,18 @@ export function NodesTable(initialData: NodesListComponentProps) { columns: createColumns(tNodes), }); + if (isError) { + return ; + } + return ; } -function getRingProgress(value: number) { +function getRingProgress(value: number | null, unavailableLabel: string) { + if (value === null) { + return {unavailableLabel}; + } + return ( }) { const session = await auth(); if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { notFound(); } - const nodes = await api.kubernetes.nodes.getNodes(); + const context = await getSelectedKubernetesContextAsync(searchParams); + const nodes = + context.status === "unavailable" ? [] : await api.kubernetes.nodes.getNodes({ contextId: context.contextId }); const tNodes = await getScopedI18n("kubernetes.nodes"); return ( <> {tNodes("label")} - + ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx index b261bf4e2..654cc107f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx @@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server"; import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import type { KubernetesContextSearchParams } from "../kubernetes-context"; +import { getSelectedKubernetesContextAsync } from "../kubernetes-context"; -export default async function PodsPage() { +export default async function PodsPage({ searchParams }: { searchParams: Promise }) { const session = await auth(); if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { notFound(); } - const pods = await api.kubernetes.pods.getPods(); + const context = await getSelectedKubernetesContextAsync(searchParams); + const pods = + context.status === "unavailable" ? [] : await api.kubernetes.pods.getPods({ contextId: context.contextId }); const tPods = await getScopedI18n("kubernetes.pods"); return ( <> {tPods("label")} - + ); 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 dd600b387..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 @@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; dayjs.extend(relativeTime); interface PodsTableComponentProps { + contextId: string; initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"]; } @@ -47,15 +48,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.pods">): MRT_Col }, ]; -export function PodsTable(initialData: PodsTableComponentProps) { +export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) { const tPods = useScopedI18n("kubernetes.pods"); - const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, { - initialData: initialData.initialPods, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); + const { data } = clientApi.kubernetes.pods.getPods.useQuery( + { contextId }, + { + initialData: initialPods, + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), + }, + ); const table = useTranslatedMantineReactTable({ data, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx index b52b1ea41..a8a3e57ac 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx @@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server"; import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import type { KubernetesContextSearchParams } from "../kubernetes-context"; +import { getSelectedKubernetesContextAsync } from "../kubernetes-context"; -export default async function SecretsPage() { +export default async function SecretsPage({ searchParams }: { searchParams: Promise }) { const session = await auth(); if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { notFound(); } - const secrets = await api.kubernetes.secrets.getSecrets(); + const context = await getSelectedKubernetesContextAsync(searchParams); + const secrets = + context.status === "unavailable" ? [] : await api.kubernetes.secrets.getSecrets({ contextId: context.contextId }); const tSecrets = await getScopedI18n("kubernetes.secrets"); return ( <> {tSecrets("label")} - + ); 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 d0a902f22..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 @@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; dayjs.extend(relativeTime); interface SecretsTableComponentProps { + contextId: string; initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"]; } @@ -42,15 +43,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.secrets">): MRT_ }, ]; -export function SecretsTable(initialData: SecretsTableComponentProps) { +export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponentProps) { const tSecrets = useScopedI18n("kubernetes.secrets"); - const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, { - initialData: initialData.initialSecrets, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); + const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery( + { contextId }, + { + initialData: initialSecrets, + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), + }, + ); const table = useTranslatedMantineReactTable({ data, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx index d50467aa8..ed3571e8b 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx @@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server"; import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import type { KubernetesContextSearchParams } from "../kubernetes-context"; +import { getSelectedKubernetesContextAsync } from "../kubernetes-context"; -export default async function ServicesPage() { +export default async function ServicesPage({ searchParams }: { searchParams: Promise }) { const session = await auth(); if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { notFound(); } - const services = await api.kubernetes.services.getServices(); + const context = await getSelectedKubernetesContextAsync(searchParams); + const services = + context.status === "unavailable" ? [] : await api.kubernetes.services.getServices({ contextId: context.contextId }); const tServices = await getScopedI18n("kubernetes.services"); return ( <> {tServices("label")} - + ); 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 05a7f3cb9..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 @@ -17,6 +17,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; dayjs.extend(relativeTime); interface ServicesTableComponentProps { + contextId: string; initialServices: RouterOutputs["kubernetes"]["services"]["getServices"]; } @@ -61,15 +62,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.services">): MRT }, ]; -export function ServicesTable(initialData: ServicesTableComponentProps) { +export function ServicesTable({ contextId, initialServices }: ServicesTableComponentProps) { const tServices = useScopedI18n("kubernetes.services"); - const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, { - initialData: initialData.initialServices, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); + const { data } = clientApi.kubernetes.services.getServices.useQuery( + { contextId }, + { + initialData: initialServices, + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), + }, + ); const table = useTranslatedMantineReactTable({ data, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx index 301371dab..623e688ec 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx @@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server"; import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import type { KubernetesContextSearchParams } from "../kubernetes-context"; +import { getSelectedKubernetesContextAsync } from "../kubernetes-context"; -export default async function VolumesPage() { +export default async function VolumesPage({ searchParams }: { searchParams: Promise }) { const session = await auth(); if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { notFound(); } - const volumes = await api.kubernetes.volumes.getVolumes(); + const context = await getSelectedKubernetesContextAsync(searchParams); + const volumes = + context.status === "unavailable" ? [] : await api.kubernetes.volumes.getVolumes({ contextId: context.contextId }); const tVolumes = await getScopedI18n("kubernetes.volumes"); return ( <> {tVolumes("label")} - + ); 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 e911ee7a6..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 @@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; dayjs.extend(relativeTime); interface VolumesTableComponentProps { + contextId: string; initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"]; } @@ -66,15 +67,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.volumes">): MRT_ }, ]; -export function VolumesTable(initialData: VolumesTableComponentProps) { +export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponentProps) { const tVolumes = useScopedI18n("kubernetes.volumes"); - const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, { - initialData: initialData.initialVolumes, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); + const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery( + { contextId }, + { + initialData: initialVolumes, + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), + }, + ); const table = useTranslatedMantineReactTable({ data, diff --git a/apps/nextjs/src/app/api/mcp/_handler.ts b/apps/nextjs/src/app/api/mcp/_handler.ts index c94317299..d1fe6f5ac 100644 --- a/apps/nextjs/src/app/api/mcp/_handler.ts +++ b/apps/nextjs/src/app/api/mcp/_handler.ts @@ -246,7 +246,7 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr **"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). -**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. +**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with \`targets\` containing the endpointId and id returned for each container. **"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. 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..704829201 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts @@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => { boardName: "Home", kind: "audioStats", summary: "Show audio statistics", - integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"], + integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"], }, settings, [ @@ -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 1ad4552d2..cdd00d62e 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx @@ -6,6 +6,7 @@ import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@ma import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import type { IntegrationKind } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; @@ -20,9 +21,10 @@ import { useAssistantAutomaticAction } from "./assistant-auto-approval"; import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status"; import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts"; +import { IntegrationSelectModal } from "../integration/integration-select-modal"; interface AssistantIntegration extends IntegrationSelectOption { - permissions: { hasUseAccess: boolean }; + permissions: NonNullable; } export const getAssistantWidgetConfiguration = ( @@ -66,6 +68,7 @@ export const AssistantConfigureWidgetTool = ({ }: ToolCallMessagePartProps) => { const t = useScopedI18n("common.assistant.configureWidget"); const fullT = useI18n(); + const { data: session } = useSession(); const settings = useSettings(); const hasCompleteArguments = hasCompleteAssistantToolArguments(status); const definition = args?.kind ? widgetImports[args.kind].definition : undefined; @@ -80,10 +83,11 @@ export const AssistantConfigureWidgetTool = ({ }, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]); const missingRequiredIntegration = configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0; - const { openModal } = useModalAction(WidgetEditModal); + const { openModal: openWidgetModal } = useModalAction(WidgetEditModal); + const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); const autoConfirming = useAssistantAutomaticAction({ toolCallId, - ready: result === undefined && configuration !== null, + ready: result === undefined && configuration !== null && !missingRequiredIntegration, completed: result !== undefined, confirm: () => { if (!args || !configuration) return; @@ -154,6 +158,26 @@ export const AssistantConfigureWidgetTool = ({ if (!configuration) return null; + const openConfigurationModal = ( + value = configuration.value, + integrationData: IntegrationSelectOption[] = configuration.integrationData, + ) => + openWidgetModal( + { + kind: args.kind, + definition: widgetImports[args.kind].definition, + value, + integrationData, + integrationSupport: configuration.integrationSupport, + settings, + onSuccessfulEdit: ({ options, integrationIds }) => + addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }), + }, + { + title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`, + }, + ); + if (autoConfirming) { return ( @@ -168,9 +192,32 @@ export const AssistantConfigureWidgetTool = ({ {t("noIntegrationDescription")} - + {session?.user.permissions.includes("integration-create") ? ( + + ) : ( + + )} diff --git a/apps/nextjs/src/components/board/board-empty-state.module.css b/apps/nextjs/src/components/board/board-empty-state.module.css new file mode 100644 index 000000000..4c85d77c0 --- /dev/null +++ b/apps/nextjs/src/components/board/board-empty-state.module.css @@ -0,0 +1,16 @@ +.root { + position: absolute; + z-index: 3; + top: calc(var(--mantine-spacing-xl) * var(--board-canvas-inverse-scale, 1)); + right: 0; + left: 0; + display: flex; + justify-content: center; + padding-inline: var(--mantine-spacing-md); + pointer-events: none; +} + +.panel { + max-width: 42rem; + pointer-events: auto; +} diff --git a/apps/nextjs/src/components/board/board-empty-state.tsx b/apps/nextjs/src/components/board/board-empty-state.tsx new file mode 100644 index 000000000..b3d8eaedc --- /dev/null +++ b/apps/nextjs/src/components/board/board-empty-state.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconBox, IconLayoutDashboard, IconPlug, IconResize } from "@tabler/icons-react"; + +import { useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { useBoardAddActions } from "./use-board-add-actions"; +import classes from "./board-empty-state.module.css"; + +export const BoardEmptyState = () => { + const board = useRequiredBoard(); + const [isEditMode] = useEditMode(); + const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions(); + const t = useScopedI18n("board.emptyState"); + const hasBoardContent = board.items.length > 0 || board.sections.some((section) => section.kind !== "empty"); + + if (!isEditMode || hasBoardContent) return null; + + return ( +
+ + + + + + + + {t("title")} + + + {t("description")} + + + + + + {canConnectService && ( + + )} + + + +
+ ); +}; diff --git a/apps/nextjs/src/components/board/board-setup-checklist.module.css b/apps/nextjs/src/components/board/board-setup-checklist.module.css new file mode 100644 index 000000000..877814158 --- /dev/null +++ b/apps/nextjs/src/components/board/board-setup-checklist.module.css @@ -0,0 +1,21 @@ +.root { + position: absolute; + z-index: 4; + bottom: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1)); + left: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1)); + max-width: min(24rem, calc(100% - 2 * var(--mantine-spacing-md))); + pointer-events: auto; +} + +.panel { + width: min(24rem, calc(100vw - 2rem)); +} + +.step { + border-radius: var(--mantine-radius-md); + padding: var(--mantine-spacing-xs); +} + +.step[data-complete] { + background: light-dark(var(--mantine-color-green-0), rgba(47, 158, 68, 0.1)); +} diff --git a/apps/nextjs/src/components/board/board-setup-checklist.module.d.css.ts b/apps/nextjs/src/components/board/board-setup-checklist.module.d.css.ts new file mode 100644 index 000000000..eed717d4c --- /dev/null +++ b/apps/nextjs/src/components/board/board-setup-checklist.module.d.css.ts @@ -0,0 +1,6 @@ +declare const classes: { + readonly panel: string; + readonly root: string; + readonly step: string; +}; +export default classes; diff --git a/apps/nextjs/src/components/board/board-setup-checklist.tsx b/apps/nextjs/src/components/board/board-setup-checklist.tsx new file mode 100644 index 000000000..b702e5186 --- /dev/null +++ b/apps/nextjs/src/components/board/board-setup-checklist.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { ActionIcon, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; +import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react"; + +import { useIntegrationsWithUseAccess } from "@homarr/auth/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { useSetupAnalytics } from "~/components/create/setup-analytics"; +import { getBoardSetupProgress } from "./board-setup-progress"; +import { useBoardAddActions } from "./use-board-add-actions"; +import classes from "./board-setup-checklist.module.css"; + +export const BoardSetupChecklist = () => { + const board = useRequiredBoard(); + const [isEditMode] = useEditMode(); + const integrations = useIntegrationsWithUseAccess(); + const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions(); + const [collapsed, setCollapsed] = useLocalStorage({ + key: `homarr-board-setup-checklist-${board.id}`, + defaultValue: false, + }); + const t = useScopedI18n("board.setupChecklist"); + const trackSetup = useSetupAnalytics(); + const progress = getBoardSetupProgress({ + itemKinds: board.items.map((item) => item.kind), + usableIntegrationCount: integrations.length, + }); + + if (!isEditMode || progress.isComplete) return null; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ + + +
+ {t("title")} + + {t("description")} + +
+ + setCollapsed(true)}> + + + +
+ + + + +
+
+
+ ); +}; + +const ChecklistStep = ({ + complete, + label, + description, + actionLabel, + onAction, +}: { + complete: boolean; + label: string; + description: string; + actionLabel?: string; + onAction?: () => void; +}) => ( + + + {complete ? : } + + + + {label} + + + {description} + + + {!complete && onAction && actionLabel && ( + + )} + +); diff --git a/apps/nextjs/src/components/board/board-setup-progress.spec.ts b/apps/nextjs/src/components/board/board-setup-progress.spec.ts new file mode 100644 index 000000000..5d5e00478 --- /dev/null +++ b/apps/nextjs/src/components/board/board-setup-progress.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; + +import { getBoardSetupProgress } from "./board-setup-progress"; + +describe("getBoardSetupProgress", () => { + test("derives resumable progress from the board and connected services", () => { + expect(getBoardSetupProgress({ itemKinds: ["clock", "app"], usableIntegrationCount: 1 })).toEqual({ + steps: { content: true, app: true, service: true }, + completedCount: 3, + totalCount: 3, + isComplete: true, + }); + }); + + test("does not count an app as a data widget", () => { + expect(getBoardSetupProgress({ itemKinds: ["app"], usableIntegrationCount: 0 })).toMatchObject({ + steps: { content: false, app: true, service: false }, + completedCount: 1, + isComplete: false, + }); + }); +}); diff --git a/apps/nextjs/src/components/board/board-setup-progress.ts b/apps/nextjs/src/components/board/board-setup-progress.ts new file mode 100644 index 000000000..30f28f625 --- /dev/null +++ b/apps/nextjs/src/components/board/board-setup-progress.ts @@ -0,0 +1,22 @@ +import type { WidgetKind } from "@homarr/definitions"; + +export interface BoardSetupProgressInput { + itemKinds: readonly WidgetKind[]; + usableIntegrationCount: number; +} + +export const getBoardSetupProgress = ({ itemKinds, usableIntegrationCount }: BoardSetupProgressInput) => { + const steps = { + content: itemKinds.some((kind) => kind !== "app"), + app: itemKinds.includes("app"), + service: usableIntegrationCount > 0, + }; + const completedCount = Object.values(steps).filter(Boolean).length; + + return { + steps, + completedCount, + totalCount: Object.keys(steps).length, + isComplete: completedCount === Object.keys(steps).length, + }; +}; diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index bceaaacca..56ad965e0 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -4,8 +4,9 @@ import type { Modify } from "@homarr/common/types"; import { getRootSectionLane } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions"; import { widgetDefaultSizes } from "@homarr/definitions"; +import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; -import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; +import type { Board, ContainerSection, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; import { getBoardLaneColumnCount } from "~/components/board/layout"; import { getFirstEmptyPosition } from "./empty-position"; import { getSectionElements } from "./section-elements"; @@ -15,33 +16,43 @@ export interface CreateItemInput { kind: WidgetKind; options?: Record; integrationIds?: string[]; + targetSectionId?: string; + advancedOptions?: BoardItemAdvancedOptions; } export const createItemCallback = - ({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) => + ({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) => (previous: Board): Board => { - const firstSection = previous.sections + const firstCanvasSection = previous.sections .filter( (section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main", ) .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); + const requestedSection = targetSectionId + ? previous.sections.find( + (section): section is EmptySection | ContainerSection => + section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"), + ) + : undefined; + let targetSection = requestedSection ?? firstCanvasSection; - if (!firstSection) return previous; + if (!targetSection) return previous; - const itemLayouts = createItemLayouts(previous, firstSection, kind); + let itemLayouts = createItemLayouts(previous, targetSection, kind); + if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) { + targetSection = firstCanvasSection; + itemLayouts = createItemLayouts(previous, targetSection, kind); + } + if (itemLayouts.length === 0) return previous; const widget = { id: id ?? createId(), kind, options, layouts: itemLayouts, integrationIds, - advancedOptions: { - title: null, - customCssClasses: [], - borderColor: "", - }, + advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "" }, } satisfies Modify< Item, { @@ -59,14 +70,25 @@ const defaultItemSize = { width: 1, height: 1 }; const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize; -const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => { +const createItemLayouts = ( + board: Board, + currentSection: EmptySection | ContainerSection, + kind: WidgetKind, +): ItemLayout[] => { const layouts = getBoardLayouts(board); const itemSize = getWidgetItemSize(kind); - return layouts.map((layoutId) => { + return layouts.flatMap((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); - const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width; + const containerLayout = + currentSection.kind === "container" + ? currentSection.layouts.find((layout) => layout.layoutId === layoutId) + : undefined; + const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main"; + if (currentSection.kind === "container" && !containerLayout) return []; + const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0); + if (columnCount === 0) return []; const size = { width: Math.min(itemSize.width, columnCount), height: itemSize.height, @@ -80,12 +102,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid throw new Error("Your board is full"); } - return { - width: size.width, - height: size.height, - ...emptyPosition, - sectionId: currentSection.id, - layoutId, - }; + return [ + { + width: size.width, + height: size.height, + ...emptyPosition, + sectionId: currentSection.id, + layoutId, + }, + ]; }); }; diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index 9378a3dea..3b052d35c 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -149,6 +149,99 @@ describe("item actions create-item", () => { }); }); + test("places a new item directly in the requested container", () => { + const board = new BoardMockBuilder() + .addEmptySection({ id: "canvas" }) + .addSection( + new ContainerSectionMockBuilder({ id: "media" }) + .addLayout({ layoutId: "layout", parentSectionId: "canvas", width: 4, height: 3 }) + .build(), + ) + .build(); + const layout = board.layouts.at(0); + if (!layout) throw new Error("Expected a board layout"); + layout.id = "layout"; + + const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board); + + expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([ + expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }), + ]); + }); + + test("omits layouts where the requested container has no layout entry", () => { + const board = new BoardMockBuilder() + .addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }) + .addEmptySection({ id: "canvas" }) + .addSection( + new ContainerSectionMockBuilder({ id: "media" }) + .addLayout({ layoutId: "base", parentSectionId: "canvas", width: 4, height: 3 }) + .build(), + ) + .build(); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + if (!baseLayout) throw new Error("Expected a base layout"); + baseLayout.id = "base"; + + const result = createItemCallback({ id: "calendar", kind: "clock", targetSectionId: "media" })(board); + + expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([ + expect.objectContaining({ layoutId: "base", sectionId: "media" }), + ]); + }); + + test("sizes a new item for its requested rail and omits layouts where that rail is hidden", () => { + const board = new BoardMockBuilder() + .addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }) + .addEmptySection({ id: "canvas" }) + .addEmptySection({ id: "left-rail", xOffset: -1 }) + .build(); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + if (!baseLayout) throw new Error("Expected a base layout"); + baseLayout.leftGutterColumnCount = 2; + + const result = createItemCallback({ id: "missing", kind: "mediaMissing", targetSectionId: "left-rail" })(board); + + expect(result.items.find((item) => item.id === "missing")?.layouts).toEqual([ + expect.objectContaining({ layoutId: baseLayout.id, sectionId: "left-rail", width: 2 }), + ]); + }); + + test("falls back to the main canvas instead of creating a ghost item for a disabled rail", () => { + const board = new BoardMockBuilder() + .addEmptySection({ id: "canvas" }) + .addEmptySection({ id: "left-rail", xOffset: -1 }) + .build(); + + const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "left-rail" })(board); + + expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([ + expect.objectContaining({ sectionId: "canvas" }), + ]); + }); + + test("falls back to the main canvas when a requested destination no longer exists", () => { + const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build(); + const layout = board.layouts.at(0); + if (!layout) throw new Error("Expected a board layout"); + layout.id = "layout"; + + const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "removed" })(board); + + expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([ + expect.objectContaining({ layoutId: "layout", sectionId: "canvas" }), + ]); + }); + + test("commits configured advanced options in the creation transaction", () => { + const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build(); + const advancedOptions = { title: "Kitchen", customCssClasses: ["compact"], borderColor: "#abc" }; + + const result = createItemCallback({ id: "clock", kind: "clock", advancedOptions })(board); + + expect(result.items.find((item) => item.id === "clock")?.advancedOptions).toEqual(advancedOptions); + }); + test("clamps wide defaults and places below a full automatic canvas", () => { const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build(); const layout = board.layouts[0]; diff --git a/apps/nextjs/src/components/board/items/board-recipe-recommendations.spec.ts b/apps/nextjs/src/components/board/items/board-recipe-recommendations.spec.ts new file mode 100644 index 000000000..7ff9a989b --- /dev/null +++ b/apps/nextjs/src/components/board/items/board-recipe-recommendations.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vitest"; + +import { getBoardRecipeDismissalKey, getBoardRecipeRecommendations } from "./board-recipe-recommendations"; + +test("recipe dismissal is scoped to both the board and integration kind", () => { + expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-b", "piHole")); + expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-a", "jellyfin")); +}); + +describe("getBoardRecipeRecommendations", () => { + test("prioritizes widgets unlocked by the newly connected service", () => { + const recommendations = getBoardRecipeRecommendations({ + configuredIntegrationKinds: ["piHole", "jellyfin"], + existingItemKinds: [], + preferredIntegrationKind: "jellyfin", + }); + + const firstFallbackIndex = recommendations.findIndex(({ isNewlyAvailable }) => !isNewlyAvailable); + expect(recommendations.some(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true); + if (firstFallbackIndex !== -1) { + expect(recommendations.slice(0, firstFallbackIndex).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true); + expect(recommendations.slice(firstFallbackIndex).every(({ isNewlyAvailable }) => !isNewlyAvailable)).toBe(true); + } + }); + + test("does not recommend widget kinds already present on the board", () => { + const recommendations = getBoardRecipeRecommendations({ + configuredIntegrationKinds: ["piHole"], + existingItemKinds: ["dnsHoleSummary"], + preferredIntegrationKind: "piHole", + }); + + expect(recommendations.every(({ widgetKind }) => widgetKind !== "dnsHoleSummary")).toBe(true); + expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true); + }); + + test("deduplicates configured kinds and emitted widget capabilities", () => { + const recommendations = getBoardRecipeRecommendations({ + configuredIntegrationKinds: ["piHole", "piHole"], + existingItemKinds: [], + limit: 10, + }); + + const widgetKinds = recommendations.map(({ widgetKind }) => widgetKind); + expect(new Set(widgetKinds).size).toBe(widgetKinds.length); + expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true); + }); + + test("falls back to other configured integrations when the new service has no missing widgets", () => { + const recommendations = getBoardRecipeRecommendations({ + configuredIntegrationKinds: ["piHole", "jellyfin"], + existingItemKinds: ["dnsHoleSummary", "dnsHoleControls"], + preferredIntegrationKind: "piHole", + limit: 1, + }); + + expect(recommendations).toEqual([ + expect.objectContaining({ integrationKind: "jellyfin", isNewlyAvailable: false }), + ]); + }); + + test("returns no recipes without a usable connection or capacity", () => { + expect( + getBoardRecipeRecommendations({ + configuredIntegrationKinds: [], + existingItemKinds: [], + }), + ).toEqual([]); + expect( + getBoardRecipeRecommendations({ + configuredIntegrationKinds: ["piHole"], + existingItemKinds: [], + limit: 0, + }), + ).toEqual([]); + }); +}); diff --git a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts new file mode 100644 index 000000000..cd2b848a9 --- /dev/null +++ b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts @@ -0,0 +1,60 @@ +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions"; + +export interface BoardRecipeRecommendation { + widgetKind: WidgetKind; + integrationKind: IntegrationKind; + isNewlyAvailable: boolean; + score: number; +} + +export const getBoardRecipeDismissalKey = (boardId: string, integrationKind: IntegrationKind) => + `homarr-board-recipes-${boardId}-${integrationKind}`; + +interface BoardRecipeRecommendationInput { + configuredIntegrationKinds: readonly IntegrationKind[]; + existingItemKinds: readonly WidgetKind[]; + preferredIntegrationKind?: IntegrationKind; + limit?: number; +} + +/** + * Suggests missing widgets that can use an existing connection. The newly + * connected service ranks first, followed by specific capabilities with fewer + * unrelated provider matches. Inputs and outputs are deduplicated so this can + * remain a small, explainable client-side recommendation layer. + */ +export const getBoardRecipeRecommendations = ({ + configuredIntegrationKinds, + existingItemKinds, + preferredIntegrationKind, + limit = 3, +}: BoardRecipeRecommendationInput): BoardRecipeRecommendation[] => { + const availableKinds = new Set(configuredIntegrationKinds); + const existingKinds = new Set(existingItemKinds); + + return widgetKinds + .filter((widgetKind) => !existingKinds.has(widgetKind)) + .flatMap((widgetKind) => { + const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities]; + if (!capability) return []; + + const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind)); + if (matches.length === 0) return []; + + const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind); + const integrationKind = preferredMatch ? preferredIntegrationKind : matches[0]; + if (!integrationKind) return []; + + return [ + { + widgetKind, + integrationKind, + isNewlyAvailable: Boolean(preferredMatch), + score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length, + }, + ]; + }) + .sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind)) + .slice(0, Math.max(0, limit)); +}; diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 66144a962..0679edde3 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 4d0e77860..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 @@ -1,6 +1,44 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; +import { + getWidgetConnectionStatus, + resolveMatchingIntegrationsAsync, + tryLockSelection, + unlockSelection, +} from "./item-select-data"; + +describe("getWidgetConnectionStatus", () => { + const availableKinds = new Set(["sonarr" as const]); + + it("reports widgets with a configured compatible integration as ready", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["sonarr", "radarr"], + availableKinds, + connectionOptional: false, + }), + ).toBe("ready"); + }); + + it("reports required integrations that are not configured as needing setup", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["radarr"], + availableKinds, + connectionOptional: false, + }), + ).toBe("needsSetup"); + }); + + it.each([ + { supportedIntegrations: [], connectionOptional: false }, + { supportedIntegrations: ["radarr" as const], connectionOptional: true }, + ])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => { + expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe( + "noConnectionRequired", + ); + }); +}); describe("widget selection lock", () => { it("rejects repeated selection synchronously until the active selection finishes", () => { @@ -45,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 f2b37f700..ef6eb49f4 100644 --- a/apps/nextjs/src/components/board/items/item-select-data.ts +++ b/apps/nextjs/src/components/board/items/item-select-data.ts @@ -1,5 +1,21 @@ import type { IntegrationKind } from "@homarr/definitions"; +export type WidgetConnectionStatus = "ready" | "needsSetup" | "noConnectionRequired"; + +export const getWidgetConnectionStatus = ({ + supportedIntegrations, + availableKinds, + connectionOptional, +}: { + supportedIntegrations: readonly IntegrationKind[]; + availableKinds: ReadonlySet; + connectionOptional: boolean; +}): WidgetConnectionStatus => { + if (supportedIntegrations.some((kind) => availableKinds.has(kind))) return "ready"; + if (supportedIntegrations.length === 0 || connectionOptional) return "noConnectionRequired"; + return "needsSetup"; +}; + export const tryLockSelection = (lock: { current: boolean }) => { if (lock.current) return false; lock.current = true; @@ -10,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, @@ -24,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 8688f68d3..40b8ed6c2 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,27 +1,45 @@ -import { useMemo, useRef, useState } from "react"; -import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; import { IconApi } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { createId } from "@homarr/common"; -import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; +import { + getIconUrl, + getBoardLaneColumnCount, + getIntegrationName, + getRootSectionLane, + widgetIntegrationSupport, + widgetKinds, + widgetKindsWithOptionalIntegrations, +} from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; -import { showErrorNotification } from "@homarr/notifications"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; -import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; +import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; +import { useSetupAnalytics } from "~/components/create/setup-analytics"; +import type { EmptySection } from "~/app/[locale]/boards/_types"; import { useItemActions } from "./item-actions"; -import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; +import type { WidgetConnectionStatus } from "./item-select-data"; +import { + getWidgetConnectionStatus, + resolveMatchingIntegrationsAsync, + tryLockSelection, + unlockSelection, +} from "./item-select-data"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; -import classes from "./item-select-modal.module.css"; interface ItemSelectModalContentProps { actions: { closeModal: () => void }; @@ -29,6 +47,8 @@ interface ItemSelectModalContentProps { customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined; ensureIntegrationDataAsync: () => Promise; isAdmin: boolean; + canCreateIntegration: boolean; + initialSearch?: string; } const ItemSelectModalContent = ({ @@ -37,16 +57,63 @@ const ItemSelectModalContent = ({ customWidgetDefs, ensureIntegrationDataAsync, isAdmin, + canCreateIntegration, + initialSearch = "", }: ItemSelectModalContentProps) => { - const [search, setSearch] = useState(""); + const [search, setSearch] = useState(initialSearch); + const t = useI18n(); + const board = useRequiredBoard(); + const currentLayoutId = useCurrentLayout(); + const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); + const mainCanvasSection = board.sections + .filter( + (section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main", + ) + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) + .at(0); + const placementOptions = useMemo( + () => [ + ...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []), + ...board.sections.flatMap((section) => { + if (section.kind !== "empty") return []; + const lane = getRootSectionLane(section.xOffset); + if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return []; + return [ + { + value: section.id, + label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`), + }, + ]; + }), + ...board.sections + .filter((section) => section.kind === "container") + .map((section) => ({ + value: section.id, + label: section.options.title.trim() || t("section.container.untitled"), + })), + ], + [board.sections, currentLayout, mainCanvasSection, t], + ); + const [targetSectionId, setTargetSectionId] = useState(placementOptions[0]?.value ?? null); const [loadingSelection, setLoadingSelection] = useState(null); const selectionLock = useRef(false); - const t = useI18n(); - const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); - const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); - const settings = useSettings(); + const { createItem, removeItem } = useItemActions(); - const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); + useEffect(() => { + if (placementOptions.some(({ value }) => value === targetSectionId)) return; + setTargetSectionId(placementOptions[0]?.value ?? null); + }, [placementOptions, targetSectionId]); + + const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); + const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); + const settings = useSettings(); + const trackSetup = useSetupAnalytics(); + const flowStartedAt = useRef(null); + + const availableKinds = useMemo( + () => new Set((integrationData ?? []).filter(({ permissions }) => permissions.hasUseAccess).map((i) => i.kind)), + [integrationData], + ); const items = useMemo( () => @@ -94,6 +161,49 @@ const ItemSelectModalContent = ({ }); }; + const notifyCreated = (updatedBoard: ReturnType, itemId: string, name: string) => { + const createdItem = updatedBoard?.items.find((item) => item.id === itemId); + if (!createdItem) { + showErrorNotification({ + title: t("item.create.notification.error.title"), + message: t("item.create.notification.error.message"), + }); + return false; + } + + const actualSectionId = + createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ?? + createdItem.layouts[0]?.sectionId; + const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label; + const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination; + const notificationId = `board-item-created:${itemId}`; + showSuccessNotification({ + id: notificationId, + title: t("item.create.notification.success.title"), + message: ( + + + {wasRelocated + ? t("item.create.notification.success.relocatedMessage", { name, destination: actualDestination }) + : t("item.create.notification.success.message", { name })} + + + + ), + autoClose: 10_000, + }); + return true; + }; + const handleAddCustomWidget = async (customWidgetDefinition: NonNullable[number]) => { if (!tryLockSelection(selectionLock)) return; setLoadingSelection(`custom:${customWidgetDefinition.id}`); @@ -119,9 +229,15 @@ const ItemSelectModalContent = ({ integrationIds: [], }, onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => { - createItem({ id: itemId, kind: "customApi", integrationIds: [] }); - updateItemOptions({ itemId, newOptions: configuredOptions }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); + const updatedBoard = createItem({ + id: itemId, + kind: "customApi", + integrationIds: [], + options: configuredOptions, + targetSectionId: targetSectionId ?? undefined, + advancedOptions, + }); + notifyCreated(updatedBoard, itemId, customWidgetDefinition.name); }, integrationData: [], integrationSupport: false, @@ -141,6 +257,8 @@ const ItemSelectModalContent = ({ const handleAdd = async (kind: WidgetKind) => { if (!tryLockSelection(selectionLock)) return; + flowStartedAt.current = performance.now(); + trackSetup("widget-started", { entryPoint: "board", intent: kind }); setLoadingSelection(kind); preloadWidgetEditModal(); try { @@ -154,35 +272,89 @@ const ItemSelectModalContent = ({ ensureDataAsync: ensureIntegrationDataAsync, }); - const integrationIds = matchingIntegrations.map((i) => i.id); + const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; + const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations); const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + const openEditor = ( + availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[], + ) => { + const selectedIntegrationIds = availableIntegrations + .slice(0, maxIntegrations) + .map((integration) => integration.id); + openEditModal( + { + kind, + definition, + value: { + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + options: defaultOptions, + integrationIds: selectedIntegrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { + const updatedBoard = createItem({ + id: itemId, + kind, + options, + integrationIds: newIntegrationIds, + targetSectionId: targetSectionId ?? undefined, + advancedOptions, + }); + if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return; + trackSetup("widget-completed", { + entryPoint: "board", + intent: kind, + outcome: "completed", + elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined, + }); + }, + integrationData: availableIntegrations, + integrationSupport: hasIntegrationSupport, + settings, + }, + { + title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, + }, + ); + }; + const integrationsRequired = + hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false); + + if (integrationsRequired && matchingIntegrationCount === 0) { + trackSetup("dependency-blocked", { + entryPoint: "board", + intent: kind, + outcome: "blocked", + canResolveInline: canCreateIntegration, + }); + if (!canCreateIntegration) { + showErrorNotification({ + title: t("item.create.missingIntegration.title"), + message: t("item.create.missingIntegration.noPermission"), + }); + return; + } + + actions.closeModal(); + openIntegrationModal({ + allowedKinds: (definition.supportedIntegrations ?? []).filter((integration) => integration !== "mock"), + onSuccess: (result) => { + if (result) { + trackSetup("dependency-resolved-inline", { + entryPoint: "board", + intent: kind, + outcome: "continued", + canResolveInline: true, + }); + openEditor([result.integration]); + } + }, + }); + return; + } - createItem({ id: itemId, kind, integrationIds }); actions.closeModal(); - - openEditModal( - { - kind, - definition, - value: { - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - options: defaultOptions, - integrationIds, - }, - onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { - updateItemOptions({ itemId, newOptions: options }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); - updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); - }, - integrationData: matchingIntegrations, - integrationSupport: hasIntegrationSupport, - settings, - }, - { - title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, - }, - ); + openEditor(matchingIntegrations); } catch (error) { notifyDefinitionLoadError(error); } finally { @@ -204,6 +376,18 @@ const ItemSelectModalContent = ({ } }} > + {placementOptions.length > 1 && ( + +