From 150d38be626158fb8a48ae82542590afa1b0d7ac Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 19 Aug 2026 08:45:07 +0200 Subject: [PATCH] feat(docker): add endpoint filtering and streamline setup assistant (#6625) * feat(docker): add endpoint filtering and setup assistant * fix(docker): simplify environment setup * test(docker): cover demo endpoint filtering * refactor(docker): use accordion for setup suggestions * fix(docker): use docker icon for setup assistant * refactor(docker): simplify assisted service setup * refactor(docker): reuse shared translations * fix(docker): address review feedback --- .../advanced/environment-variables/index.mdx | 12 +- apps/docs/docs/integrations/docker/index.mdx | 47 +- apps/docs/docs/management/api/index.mdx | 4 + .../docs/widgets/docker-containers/index.mdx | 17 +- .../docs/widgets/docker-containers/index.ts | 6 + .../docker-reconciliation-inbox.spec.ts | 45 +- .../docker/docker-reconciliation-inbox.ts | 32 +- .../tools/docker/docker-reconciliation.tsx | 464 +++++++++++------- .../app/[locale]/manage/tools/docker/page.tsx | 9 +- .../integration/integration-select-modal.tsx | 41 +- .../api/src/router/docker/docker-router.ts | 13 +- .../src/docker/add-docker-app-to-homarr.tsx | 89 ++-- packages/onboarding/src/index.ts | 2 + .../src/onboarding-studio.module.css | 1 + .../onboarding/src/service-url-template.tsx | 106 ++++ packages/onboarding/src/setup-studio.tsx | 74 +-- packages/request-handler/src/docker.ts | 49 +- .../request-handler/src/test/docker.spec.ts | 41 +- packages/translation/src/lang/en.json | 45 +- packages/widgets/src/_inputs/index.ts | 2 + .../widget-dynamic-multiselect-input.tsx | 45 ++ packages/widgets/src/docker/component.tsx | 7 +- packages/widgets/src/docker/index.ts | 14 + packages/widgets/src/options.ts | 16 + 24 files changed, 772 insertions(+), 409 deletions(-) create mode 100644 packages/onboarding/src/service-url-template.tsx create mode 100644 packages/widgets/src/_inputs/widget-dynamic-multiselect-input.tsx diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index 4048512a1..2c1d3e22c 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -58,12 +58,12 @@ A random secret can be generated by using the following command: `openssl rand - ## Docker -| 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` | - | +| Environment Variable | Description | Possible values | Default | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | +| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - | +| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - | +| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - | +| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - | ## Database diff --git a/apps/docs/docs/integrations/docker/index.mdx b/apps/docs/docs/integrations/docker/index.mdx index 2b83460dc..55069ae0c 100644 --- a/apps/docs/docs/integrations/docker/index.mdx +++ b/apps/docs/docs/integrations/docker/index.mdx @@ -24,12 +24,11 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers"; ]} /> -## Enabling the Docker integration +## Connecting Docker to Homarr -This integration must first be enabled. -If you directly run on the host system and you have docker installed, it will be enabled by default. -Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container, -so Homarr can interact with the Docker service. +Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**. + +If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker. In Docker Compose, you can simply add this line: @@ -122,7 +121,23 @@ 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: +For remote socket proxies, list the hostnames and matching ports in the same order: + +```yaml +environment: + DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab + DOCKER_PORTS: 2375,2375 +``` + +Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup. + +:::warning[Plaintext TCP] +Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS. +::: + +### Advanced endpoint configuration + +Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities: ```yaml services: @@ -163,16 +178,12 @@ Supported transports are: - `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. +The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`. ### 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. +The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type. Homarr can show a service as: @@ -181,15 +192,15 @@ Homarr can show a service as: - **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. +Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly. :::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. +Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final 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. +Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the 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 helper. 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. +Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics. 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. @@ -216,6 +227,10 @@ This opens a dedicated page with an xterm.js terminal that streams the container The Docker management table supports optional **CPU usage** and **Memory usage** columns. These columns are hidden by default and can be toggled on from the table column visibility settings. +#### Choosing Docker environments in the widget + +The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints. + #### Add apps from containers Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button. diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index bd2ab1363..44181afe8 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -110,6 +110,10 @@ Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.resta names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different Docker or Podman endpoints from targeting the wrong service. +`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured +Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The +Docker widget uses the same behavior for its environment selector. + ## 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/widgets/docker-containers/index.mdx b/apps/docs/docs/widgets/docker-containers/index.mdx index bd0e2ee19..e405f270d 100644 --- a/apps/docs/docs/widgets/docker-containers/index.mdx +++ b/apps/docs/docs/widgets/docker-containers/index.mdx @@ -7,20 +7,15 @@ hide_title: true import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; -import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; -import { IconAd } from "@tabler/icons-react"; -import Tabs from "@theme/Tabs"; -import TabItem from "@theme/TabItem"; import { dockerContainersWidget } from "."; -import { dockerIntegration } from "@site/docs/integrations/docker"; This widget displays the resource usage of your Docker containers and allows you to manage them. :::info -This widget can only be used with administrator privileges. +This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges. ::: ### Screenshots @@ -29,16 +24,6 @@ import dockerTable from "./img/table.png"; docker-stats table docker-containers -### Supported Integrations - - - ### Interactions Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode. diff --git a/apps/docs/docs/widgets/docker-containers/index.ts b/apps/docs/docs/widgets/docker-containers/index.ts index 0517938d3..b81514511 100644 --- a/apps/docs/docs/widgets/docker-containers/index.ts +++ b/apps/docs/docs/widgets/docker-containers/index.ts @@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = { path: "../../widgets/docker-containers", configuration: { items: [ + { + name: "Docker environments", + description: "Select one or more automatically discovered Docker environments, or leave empty to show all", + values: "Any Docker socket or host configured for Homarr", + defaultValue: "All Docker environments", + }, { name: "Columns to show", description: "Select which columns are visible in the table", 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 index d82948438..b466bc466 100644 --- 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 @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox, - getValidDockerServiceUrl, + getTemplateUrl, } from "./docker-reconciliation-inbox"; const candidates = [ @@ -37,9 +37,44 @@ describe("Docker reconciliation inbox", () => { expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed); }); - test("accepts only HTTP service URLs", () => { - expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/"); - expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull(); - expect(getValidDockerServiceUrl("service.example.com")).toBeNull(); + test("builds service URLs from the server origin and published ports", () => { + expect( + getTemplateUrl( + { + container: { name: "Custom service", ports: [{ PrivatePort: 3000, Type: "tcp" }] }, + match: null, + }, + "home.lan", + "hostPort", + ), + ).toBe("http://home.lan"); + + expect( + getTemplateUrl( + { + container: { + name: "Sonarr", + ports: [ + { PrivatePort: 3000, PublicPort: 3000, Type: "tcp" }, + { PrivatePort: 8989, PublicPort: 9999, Type: "tcp" }, + ], + }, + match: { kind: "sonarr" }, + }, + "home.lan", + "hostPort", + ), + ).toBe("http://home.lan:9999"); + + expect( + getTemplateUrl( + { + container: { name: "Sonarr", ports: [{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" }] }, + match: { kind: "sonarr" }, + }, + "", + "hostPort", + ), + ).toBe(""); }); }); 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 index c6101abdc..5a3cf8082 100644 --- 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 @@ -1,3 +1,6 @@ +import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions"; +import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions"; + export type DockerReconciliationInboxFilter = "attention" | "represented" | "all"; interface DockerReconciliationInboxCandidate { @@ -24,11 +27,28 @@ export const filterDockerReconciliationInbox = 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; +interface DockerTemplateCandidate { + container: { + name: string; + ports?: DockerServicePort[]; + }; + match: Pick | null; +} + +export const getTemplateUrl = (candidate: DockerTemplateCandidate, serverOrigin: string, urlMode: UrlTemplateMode) => { + if (!serverOrigin.trim()) return ""; + + const tcpPorts = candidate.container.ports?.filter(({ Type }) => Type.toLowerCase() === "tcp") ?? []; + let publishedPort = tcpPorts.find(({ PublicPort }) => PublicPort)?.PublicPort; + + if (candidate.match) { + const defaultPort = getIntegrationDefaultPort(candidate.match.kind); + const preferredPort = tcpPorts.find( + ({ PrivatePort, PublicPort }) => PrivatePort === defaultPort && PublicPort !== undefined, + ); + publishedPort = preferredPort?.PublicPort ?? publishedPort; + return buildIntegrationUrl(candidate.match.kind, serverOrigin, urlMode, publishedPort); } + + return buildAppUrl(candidate.container.name, serverOrigin, urlMode, publishedPort); }; 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 index 55c85523a..3d1a97ace 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-reconciliation.tsx @@ -1,46 +1,72 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { + Accordion, + ActionIcon, Alert, + Autocomplete, Avatar, Badge, Button, - Card, + Collapse, + Divider, Group, + Paper, SegmentedControl, - Select, SimpleGrid, Stack, Text, - TextInput, + ThemeIcon, + Tooltip, } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; -import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react"; +import { + IconAlertTriangle, + IconApps, + IconCheck, + IconChevronDown, + IconEye, + IconEyeOff, + IconBrandDocker, + IconMinus, + IconPlugConnected, + IconRefresh, + IconSettings, +} from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import type { UrlTemplateMode } from "@homarr/definitions"; 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 { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox"; import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox, - getValidDockerServiceUrl, + getTemplateUrl, } from "./docker-reconciliation-inbox"; type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number]; type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number]; -export const DockerReconciliation = () => { +export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => { const t = useScopedI18n("docker.reconciliation"); + const tGlobal = useI18n(); const utils = clientApi.useUtils(); + const panelId = useId(); + const [isOpen, setIsOpen] = useLocalStorage({ + key: "homarr-docker-reconciliation-open", + defaultValue: false, + }); const reconciliation = clientApi.docker.reconcileServices.useQuery(); - const health = clientApi.docker.getServiceHealth.useQuery(); + const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen }); const refreshInventory = clientApi.docker.refreshInventory.useMutation({ async onSuccess() { await Promise.all([ @@ -61,6 +87,14 @@ export const DockerReconciliation = () => { key: "homarr-docker-reconciliation-dismissed", defaultValue: [], }); + const [serverOrigin, setServerOrigin] = useLocalStorage({ + key: "homarr-docker-service-origin", + defaultValue: defaultServerOrigin, + }); + const [urlMode, setUrlMode] = useLocalStorage({ + key: "homarr-docker-service-url-mode", + defaultValue: "hostPort", + }); if (reconciliation.isError) { return ( @@ -68,7 +102,7 @@ export const DockerReconciliation = () => { {t("loadError.message")} @@ -79,231 +113,274 @@ export const DockerReconciliation = () => { 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 attentionCount = reconciliation.data.candidates.filter( + ({ candidateKey, state }) => + ["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey), + ).length; const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching; + const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review"); 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 && ( - - )} - + + + + +
+ + {t("title")} + 0 ? "blue" : "gray"} aria-live="polite"> + {t("suggestions", { count: String(attentionCount) })} + + +
+
- {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), - ) - } + + 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)} + serverOrigin={serverOrigin} + urlMode={urlMode} + onDismiss={() => + setDismissedCandidateKeys((current) => + dismissDockerReconciliationCandidate(current, candidate.candidateKey), + ) + } + /> + ))} + + )} +
+
-
+ ); }; const DockerReconciliationCandidate = ({ candidate, health, + serverOrigin, + urlMode, onDismiss, }: { candidate: ReconciliationCandidate; health: ServiceHealth | undefined; + serverOrigin: string; + urlMode: UrlTemplateMode; 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 tGlobal = useI18n(); + const { openModal: openAppModal } = useModalAction(AddDockerAppToHomarr); + const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); + const templateUrl = getTemplateUrl(candidate, serverOrigin, urlMode); + const urlSuggestions = Array.from( + new Set([templateUrl, ...candidate.urlCandidates.map(({ url }) => url)].filter((url) => url.length > 0)), + ); + const initialUrl = urlSuggestions[0] ?? ""; + const [url, setUrl] = useState(initialUrl); + const isUrlEdited = useRef(false); + const target = getCandidateTarget(candidate); const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration"; - const isActionDisabled = actionNeedsUrl && validatedUrl === null; + const visibleHealthLayers = + health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? []; useEffect(() => { - setSelectedCandidateId(initialUrlCandidate?.id ?? null); - setUrl(initialUrlCandidate?.url ?? ""); - }, [initialUrlCandidate?.id, initialUrlCandidate?.url]); + if (isUrlEdited.current) return; + setUrl(initialUrl); + }, [initialUrl]); 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}`)} - - ))} + + {t(`state.${candidate.state}`)} + + + {candidate.endpointName} +
- )} - -