diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx new file mode 100644 index 000000000..83320184c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Avatar, + Badge, + Checkbox, + Collapse, + Divider, + Group, + Paper, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { IconChevronDown, IconBrandDocker } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +export interface DiscoveredIntegration { + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; +} + +export interface DiscoveredApp { + containerId: string; + containerName: string; + suggestedUrl: string; + iconUrl: string | null; +} + +interface DockerDiscoveryIndicatorProps { + integrations: DiscoveredIntegration[]; + apps: DiscoveredApp[]; + appsEnabled: boolean; + onToggleAllApps: (checked: boolean) => void; +} + +export const DockerDiscoveryIndicator = ({ + integrations, + apps, + appsEnabled, + onToggleAllApps, +}: DockerDiscoveryIndicatorProps) => { + const hasApps = apps.length > 0; + const [expanded, setExpanded] = useState(false); + + const expandedOnce = useRef(false); + useEffect(() => { + if (hasApps && !expandedOnce.current) { + expandedOnce.current = true; + setExpanded(true); + } + }, [hasApps]); + const t = useScopedI18n("init.step.integrations.docker"); + const totalCount = integrations.length + apps.length; + + if (totalCount === 0) return null; + + return ( + + setExpanded((v) => !v)} w="100%"> + + + + + + + + {t("detected", { count: String(totalCount) })} + + + {t("detectedBreakdown", { + integrations: String(integrations.length), + apps: String(apps.length), + })} + + + + + + + + + + {integrations.map((integration) => ( + + + + {integration.containerName} + + + {getIntegrationName(integration.kind)} + + + ))} + + {hasApps && ( + <> + {integrations.length > 0 && } + onToggleAllApps(!appsEnabled)} + styles={{ label: { fontSize: "var(--mantine-font-size-xs)" } }} + /> + {appsEnabled && apps.map((app) => ( + + + {app.containerName.at(0)?.toUpperCase()} + + + {app.containerName} + + + ))} + + )} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx index b0caa7189..f6302e5ab 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -1,37 +1,118 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Button, Card, Center, Group, Loader, Progress, Stack, Text } from "@mantine/core"; -import { IconArrowRight } from "@tabler/icons-react"; +import { + ActionIcon, + Button, + Card, + Center, + Collapse, + Group, + Loader, + Progress, + SegmentedControl, + Stack, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconArrowRight, IconSettings } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import type { IntegrationKind } from "@homarr/definitions"; -import { getIntegrationName } from "@homarr/definitions"; +import { buildIntegrationUrl, getIntegrationName } from "@homarr/definitions"; +import type { UrlTemplateMode } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid"; +import { DockerDiscoveryIndicator } from "./docker-discovery-panel"; + type Phase = "select" | "configure" | "done"; +interface IntegrationDraft { + kind: IntegrationKind; + initialUrl: string; + initialName?: string; + dockerPort?: number | null; + source: "manual" | "docker"; +} + const CARD_WIDTH = 64 * 16; export const InitIntegrations = () => { const [phase, setPhase] = useState("select"); + const [drafts, setDrafts] = useState>(new Map()); const [selectedKinds, setSelectedKinds] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); + const [baseHost, setBaseHost] = useState(""); + const [urlMode, setUrlMode] = useState("subdomain"); const t = useScopedI18n("init.step.integrations"); const router = useRouter(); const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation(); + const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation(); + + const { data: dockerData } = clientApi.onboard.discoverDockerServices.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + + const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : []; + const dockerApps = dockerData?.status === "success" ? dockerData.apps : []; + + const [addDockerApps, setAddDockerApps] = useState(true); + + const dockerApplied = useRef(false); + useEffect(() => { + if (!dockerData || dockerData.status !== "success" || dockerApplied.current) return; + dockerApplied.current = true; + + const newDrafts = new Map(drafts); + const newKinds = new Set(selectedKinds); + + for (const item of dockerData.integrations) { + if (!newDrafts.has(item.kind)) { + newDrafts.set(item.kind, { + kind: item.kind, + initialUrl: item.suggestedUrl, + initialName: item.containerName, + dockerPort: item.publishedPort, + source: "docker", + }); + } + newKinds.add(item.kind); + } + + setDrafts(newDrafts); + setSelectedKinds(Array.from(newKinds)); + }, [dockerData]); const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => { setSelectedKinds(kinds); }, []); const handleContinueToConfig = () => { + const newDrafts = new Map(drafts); + for (const kind of selectedKinds) { + const existing = newDrafts.get(kind); + if (baseHost) { + newDrafts.set(kind, { + kind, + initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined), + initialName: existing?.initialName, + dockerPort: existing?.dockerPort, + source: existing?.source ?? "manual", + }); + } else if (!existing) { + newDrafts.set(kind, { kind, initialUrl: "", source: "manual" }); + } + } + setDrafts(newDrafts); setCurrentIndex(0); setPhase("configure"); }; @@ -39,6 +120,15 @@ export const InitIntegrations = () => { const finishSetupAsync = async () => { setPhase("done"); try { + if (addDockerApps && dockerApps.length > 0) { + await createApps( + dockerApps.map((app) => ({ + name: app.containerName, + href: app.suggestedUrl || null, + iconUrl: app.iconUrl, + })), + ); + } await setupIntegrations(); router.refresh(); } catch { @@ -73,34 +163,19 @@ export const InitIntegrations = () => { } if (phase === "configure") { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds - const currentKind = selectedKinds[currentIndex]!; - const progress = ((currentIndex + 1) / selectedKinds.length) * 100; - return ( - - - - - - {getIntegrationName(currentKind)} - - - - {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} - - - - - - + ); } @@ -109,7 +184,39 @@ export const InitIntegrations = () => { {t("description")} - + + + + {t("urlTemplate.label")} + + setBaseHost(e.currentTarget.value)} + /> + setUrlMode(value as UrlTemplateMode)} + data={[ + { label: t("urlTemplate.mode.subdomain"), value: "subdomain" }, + { label: t("urlTemplate.mode.hostPort"), value: "hostPort" }, + ]} + style={{ flex: "0 0 auto" }} + /> + + + {baseHost + ? t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) }) + : t("urlTemplate.hint")} + + + +