From 65b725e1bb63709ce844b2fcff77d43f983ba63d Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 18 Aug 2026 20:37:07 +0200 Subject: [PATCH] refactor(docker): simplify assisted service setup --- apps/docs/docs/integrations/docker/index.mdx | 8 +- .../docker-reconciliation-inbox.spec.ts | 12 +- .../docker/docker-reconciliation-inbox.ts | 9 - .../tools/docker/docker-reconciliation.tsx | 263 ++++++++++-------- .../app/[locale]/manage/tools/docker/page.tsx | 9 +- .../integration/integration-select-modal.tsx | 18 +- .../src/docker/add-docker-app-to-homarr.tsx | 42 +-- 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/translation/src/lang/en.json | 18 +- 12 files changed, 312 insertions(+), 250 deletions(-) create mode 100644 packages/onboarding/src/service-url-template.tsx diff --git a/apps/docs/docs/integrations/docker/index.mdx b/apps/docs/docs/integrations/docker/index.mdx index 27ab9e422..55069ae0c 100644 --- a/apps/docs/docs/integrations/docker/index.mdx +++ b/apps/docs/docs/integrations/docker/index.mdx @@ -192,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. ::: -Suggestions appear as a compact task list. Expand an item only when you want to inspect its address, match confidence, or observed health layers. 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. +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. 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..86f04d35d 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 @@ -1,10 +1,6 @@ import { describe, expect, test } from "vitest"; -import { - dismissDockerReconciliationCandidate, - filterDockerReconciliationInbox, - getValidDockerServiceUrl, -} from "./docker-reconciliation-inbox"; +import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox"; const candidates = [ { candidateKey: "home:new", state: "newRecognized" as const }, @@ -36,10 +32,4 @@ 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(); - }); }); 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..f9d8f1bfc 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 @@ -23,12 +23,3 @@ 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; - } -}; 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 b96afa6f1..17382d889 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 @@ -5,6 +5,7 @@ import { Accordion, ActionIcon, Alert, + Autocomplete, Avatar, Badge, Button, @@ -13,43 +14,45 @@ import { Group, Paper, SegmentedControl, - Select, SimpleGrid, Stack, Text, - TextInput, ThemeIcon, Tooltip, } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { IconAlertTriangle, - IconArrowRight, + IconApps, + IconCheck, IconChevronDown, + IconEye, IconEyeOff, - IconRefresh, IconBrandDocker, + IconMinus, + IconPlugConnected, + IconRefresh, + IconSettings, } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { getIntegrationName } from "@homarr/definitions"; +import type { UrlTemplateMode } from "@homarr/definitions"; +import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort, 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, -} from "./docker-reconciliation-inbox"; +import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } 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 utils = clientApi.useUtils(); const panelId = useId(); @@ -79,6 +82,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 ( @@ -119,9 +130,6 @@ export const DockerReconciliation = () => { {t("suggestions", { count: String(attentionCount) })} - - {t("description")} - - ) : ( + )} + {target.kind === "setupIntegration" && ( + + )} + {(target.kind === "reviewIntegration" || target.kind === "viewRepresentation") && ( @@ -299,64 +333,36 @@ const DockerReconciliationCandidate = ({ - {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}`)} - - ))} - -
+ {visibleHealthLayers.length > 0 && ( + + {visibleHealthLayers.map((layer) => ( + + + + {t(`health.layer.${layer.layer}`)} ·{" "} + + {t(`health.status.${layer.status}`)} + + + + ))} + )} {actionNeedsUrl && ( - -