feat(onboarding): enhance integration setup with Docker discovery, URL templating, and API key helpers

Add automatic Docker container discovery during onboarding to pre-select
and pre-fill integrations. Introduce base URL templating with subdomain/port
modes, API key settings path links, and a curated onboarding list that
hides niche integrations (GitHub, npm, Docker Hub, etc.). Prevent duplicate
widget seeding and remove the timetable widget from defaults.
This commit is contained in:
Thomas Camlong committed 2026-05-27 14:36:04 +02:00
1 parent 1fcc149c91
commit 309ba0590d
18 files changed
+1056 -56

No files matched your search

@@ -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 (
<Paper
withBorder
p="sm"
radius="md"
style={{
borderColor: "var(--mantine-color-blue-4)",
background: "var(--mantine-color-blue-light)",
}}
>
<UnstyledButton onClick={() => setExpanded((v) => !v)} w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw={600}>
{t("detected", { count: String(totalCount) })}
</Text>
<Text size="xs" c="dimmed">
{t("detectedBreakdown", {
integrations: String(integrations.length),
apps: String(apps.length),
})}
</Text>
</Stack>
</Group>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: expanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
</UnstyledButton>
<Collapse in={expanded}>
<Stack gap={4} mt="sm">
{integrations.map((integration) => (
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
<IntegrationAvatar kind={integration.kind} size="xs" />
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{integration.containerName}
</Text>
<Badge size="xs" variant="light" color="blue">
{getIntegrationName(integration.kind)}
</Badge>
</Group>
))}
{hasApps && (
<>
{integrations.length > 0 && <Divider my={4} />}
<Checkbox
size="xs"
label={t("appSectionLabel", { count: String(apps.length) })}
checked={appsEnabled}
onChange={() => onToggleAllApps(!appsEnabled)}
styles={{ label: { fontSize: "var(--mantine-font-size-xs)" } }}
/>
{appsEnabled && apps.map((app) => (
<Group key={app.containerId} gap="xs" wrap="nowrap" py={2}>
<Avatar
size="xs"
radius="sm"
src={app.iconUrl}
styles={{ image: { objectFit: "contain" } }}
>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
</Group>
))}
</>
)}
</Stack>
</Collapse>
</Paper>
);
};
@@ -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<Phase>("select");
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [baseHost, setBaseHost] = useState("");
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("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 (
<Card w={CARD_WIDTH} maw="90vw" withBorder>
<Stack>
<Stack gap="xs">
<Group gap="xs" justify="center">
<IntegrationAvatar kind={currentKind} size="sm" />
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
</Group>
<Progress value={progress} size="sm" />
<Text size="sm" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
</Stack>
<NewIntegrationForm
key={currentKind}
kind={currentKind}
onSuccess={advanceOrFinish}
onCancel={backToSelect}
onSkip={advanceOrFinish}
isOnboarding
/>
</Stack>
</Card>
<ConfigurePhase
selectedKinds={selectedKinds}
currentIndex={currentIndex}
drafts={drafts}
baseHost={baseHost}
setBaseHost={setBaseHost}
urlMode={urlMode}
setUrlMode={setUrlMode}
onSuccess={advanceOrFinish}
onCancel={backToSelect}
onSkip={advanceOrFinish}
/>
);
}
@@ -109,7 +184,39 @@ export const InitIntegrations = () => {
<Stack>
<Text>{t("description")}</Text>
<IntegrationMultiSelectGrid onSelectionChange={handleSelectionChange} initialSelection={selectedKinds} />
<DockerDiscoveryIndicator
integrations={dockerIntegrations}
apps={dockerApps}
appsEnabled={addDockerApps}
onToggleAllApps={setAddDockerApps}
/>
<Stack gap="xs">
<Text size="sm" fw={500}>{t("urlTemplate.label")}</Text>
<Group gap="sm" grow>
<TextInput
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
/>
<SegmentedControl
value={urlMode}
onChange={(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" }}
/>
</Group>
<Text size="xs" c="dimmed">
{baseHost
? t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })
: t("urlTemplate.hint")}
</Text>
</Stack>
<IntegrationMultiSelectGrid selectedKinds={selectedKinds} onSelectionChange={handleSelectionChange} onboarding />
<Group justify="space-between">
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
@@ -117,7 +224,7 @@ export const InitIntegrations = () => {
</Button>
<Button
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0}
disabled={selectedKinds.length === 0 || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
>
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
@@ -127,3 +234,111 @@ export const InitIntegrations = () => {
</Card>
);
};
interface ConfigurePhaseProps {
selectedKinds: IntegrationKind[];
currentIndex: number;
drafts: Map<IntegrationKind, IntegrationDraft>;
baseHost: string;
setBaseHost: (value: string) => void;
urlMode: UrlTemplateMode;
setUrlMode: (value: UrlTemplateMode) => void;
onSuccess: () => void;
onCancel: () => void;
onSkip: () => void;
}
const ConfigurePhase = ({
selectedKinds,
currentIndex,
drafts,
baseHost,
setBaseHost,
urlMode,
setUrlMode,
onSuccess,
onCancel,
onSkip,
}: ConfigurePhaseProps) => {
const t = useScopedI18n("init.step.integrations");
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const computedUrl = baseHost
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
return (
<Card w={CARD_WIDTH} maw="90vw" withBorder>
<Stack>
<Group justify="space-between" align="center">
<Group gap="xs">
<IntegrationAvatar kind={currentKind} size="sm" />
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
</Group>
<Tooltip label={t("configure.helpers.tooltip")}>
<ActionIcon
variant={helpersOpen ? "light" : "subtle"}
color="gray"
onClick={toggleHelpers}
>
<IconSettings size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
<Stack gap={4}>
<Progress value={progress} size="sm" />
<Text size="xs" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
</Stack>
<Collapse in={helpersOpen}>
<Stack gap="xs" p="xs" style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}>
<Text size="xs" c="dimmed">
{t("configure.helpers.description")}
</Text>
<Group gap="sm" grow>
<TextInput
size="xs"
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
/>
<SegmentedControl
size="xs"
value={urlMode}
onChange={(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" }}
/>
</Group>
{baseHost && (
<Text size="xs" c="dimmed">
{t("urlTemplate.preview", { example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined) })}
</Text>
)}
</Stack>
</Collapse>
<NewIntegrationForm
key={currentKind}
kind={currentKind}
initialUrl={computedUrl}
initialName={draft?.initialName}
onSuccess={onSuccess}
onCancel={onCancel}
onSkip={onSkip}
isOnboarding
/>
</Stack>
</Card>
);
};
@@ -3,6 +3,7 @@
import { startTransition, useState } from "react";
import {
Alert,
Anchor,
Button,
Checkbox,
Collapse,
@@ -15,7 +16,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
@@ -26,6 +27,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import {
getAllSecretKindOptions,
getIconUrl,
getIntegrationApiKeyUrl,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
@@ -61,6 +63,17 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
}),
);
const onboardingFormSchema = z
.object({
name: z.string().nonempty().max(127),
url: z.string().nonempty(),
secrets: z.array(z.object({ kind: z.string(), value: z.string() })),
attemptSearchEngineCreation: z.boolean(),
hasApp: z.boolean(),
appHref: appHrefSchema,
appId: z.string().nullable(),
});
export const NewIntegrationForm = ({
kind,
initialUrl,
@@ -80,7 +93,7 @@ export const NewIntegrationForm = ({
if (hasUrlSecret) {
url = "http://localhost";
}
const form = useZodForm(formSchema, {
const form = useZodForm(isOnboarding ? onboardingFormSchema : formSchema, {
initialValues: {
name: initialName ?? getIntegrationName(kind),
url,
@@ -190,6 +203,7 @@ export const NewIntegrationForm = ({
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
<ApiKeySettingsLink kind={kind} url={form.values.url} />
</Stack>
</Fieldset>
@@ -292,6 +306,40 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
);
};
const normalizeUrl = (raw: string): string | null => {
try {
return new URL(raw).href;
} catch {
// Bare IP / hostname — prepend http:// and retry
}
try {
return new URL(`http://${raw}`).href;
} catch {
return null;
}
};
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
const t = useI18n();
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
if (!apiKeyUrl) return null;
const resolved = normalizeUrl(url);
if (!resolved) return null;
const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind);
return (
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
<Group gap={4}>
<IconKey size={14} stroke={1.5} />
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text>
<IconExternalLink size={14} stroke={1.5} />
</Group>
</Anchor>
);
};
type IntegrationAppSelectProps = Modify<
GetInputPropsReturnType,
{
@@ -1,6 +1,12 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions";
import {
featuredIntegrations,
getIntegrationName,
hiddenFromOnboarding,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
export const CARD_HEIGHT = 180;
@@ -62,9 +68,13 @@ export interface IntegrationGridItem {
widgets: WidgetKind[];
}
export const buildSortedIntegrations = (enableMockIntegration: boolean): IntegrationGridItem[] =>
export const buildSortedIntegrations = (options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}): IntegrationGridItem[] =>
integrationKinds
.filter((kind) => enableMockIntegration || kind !== "mock")
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
})
.map((kind) => ({
kind,
name: getIntegrationName(kind),
@@ -16,18 +16,20 @@ import {
} from "./integration-grid-shared";
interface IntegrationMultiSelectGridProps {
selectedKinds: IntegrationKind[];
onSelectionChange: (kinds: IntegrationKind[]) => void;
initialSelection?: IntegrationKind[];
enableMockIntegration?: boolean;
onboarding?: boolean;
}
export const IntegrationMultiSelectGrid = ({
selectedKinds: selectedKindsArray,
onSelectionChange,
initialSelection = [],
enableMockIntegration = false,
onboarding = false,
}: IntegrationMultiSelectGridProps) => {
const [search, setSearch] = useState("");
const [selectedKinds, setSelectedKinds] = useState<Set<IntegrationKind>>(() => new Set(initialSelection));
const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]);
const t = useI18n();
const toggleKind = useCallback(
@@ -38,13 +40,12 @@ export const IntegrationMultiSelectGrid = ({
} else {
next.add(kind);
}
setSelectedKinds(next);
onSelectionChange(Array.from(next));
},
[selectedKinds, onSelectionChange],
);
const integrations = useMemo(() => buildSortedIntegrations(enableMockIntegration), [enableMockIntegration]);
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration, onboarding }), [enableMockIntegration, onboarding]);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
@@ -24,7 +24,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
const [search, setSearch] = useState("");
const t = useI18n();
const integrations = useMemo(() => buildSortedIntegrations(enableMockIntegration), [enableMockIntegration]);
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (