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

@@ -5,9 +5,11 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { eq, like, or } from "@homarr/db";
import {
apps,
groups,
icons,
integrationItems,
integrations,
integrationSecrets,
@@ -20,12 +22,15 @@ import {
defaultWidgetConfigs,
emptySuperJSON,
everyoneGroup,
extractContainerImageName,
getIconUrl,
getWidgetKindsForIntegration,
integrationKinds,
integrationSecretKinds,
matchIntegrationKindFromContainer,
onboardingSteps,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc";
@@ -33,6 +38,25 @@ import { MissingSecretError, testConnectionAsync } from "../integration/integrat
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
interface PortInfo {
IP?: string;
PublicPort?: number;
}
interface SuggestedUrlResult {
url: string;
publishedPort: number | null;
}
const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
const port = ports?.[0];
if (port?.PublicPort) {
const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : host;
return { url: `http://${ip}:${port.PublicPort}`, publishedPort: port.PublicPort };
}
return { url: "", publishedPort: null };
};
const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
const getWidgetConfig = (kind: WidgetKind) => widgetConfigMap.get(kind);
@@ -125,10 +149,7 @@ export const onboardRouter = createTRPCRouter({
.input(
z.object({
name: z.string().nonempty().max(127),
url: z
.string()
.url()
.regex(/^https?:\/\//),
url: z.string().nonempty(),
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
@@ -159,12 +180,23 @@ export const onboardRouter = createTRPCRouter({
}
const integrationId = createId();
const appId = createId();
await ctx.db.insert(apps).values({
id: appId,
name: input.name,
iconUrl: getIconUrl(input.kind),
href: input.url,
pingUrl: input.url,
description: null,
});
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId: null,
appId,
});
if (input.secrets.length >= 1) {
@@ -177,6 +209,103 @@ export const onboardRouter = createTRPCRouter({
);
}
}),
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
const emptyResult = {
integrations: [] as { containerId: string; containerName: string; kind: IntegrationKind; suggestedUrl: string; publishedPort: number | null; iconUrl: string | null }[],
apps: [] as { containerId: string; containerName: string; suggestedUrl: string; iconUrl: string | null }[],
};
try {
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
const { bestMatch } = await import("@homarr/common");
const dockerInstances = DockerSingleton.getInstances();
if (dockerInstances.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containerList = await instance.listContainers({ all: true });
return containerList
.filter((c) => !(dockerLabels.hide in c.Labels))
.map((c) => ({ ...c, host }));
}),
);
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
if (containers.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
const dbIcons = likeQueries.length > 0
? await ctx.db.query.icons.findMany({ where: or(...likeQueries) })
: [];
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations = emptyResult.integrations;
const discoveredApps = emptyResult.apps;
for (const container of containers) {
const imageName = extractContainerImageName(container.Image);
const containerName = container.Names[0]?.split("/")[1] ?? "Unknown";
const iconUrl = bestMatch(imageName, dbIcons, (icon) => icon.name)?.url ?? null;
const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host);
const kind = matchIntegrationKindFromContainer({ image: container.Image, name: containerName });
if (kind && !seenKinds.has(kind)) {
seenKinds.add(kind);
discoveredIntegrations.push({
containerId: container.Id,
containerName,
kind,
suggestedUrl,
publishedPort,
iconUrl,
});
} else if (!kind && iconUrl) {
discoveredApps.push({
containerId: container.Id,
containerName,
suggestedUrl,
iconUrl,
});
}
}
return { status: "success" as const, integrations: discoveredIntegrations, apps: discoveredApps };
} catch {
return { status: "unavailable" as const, ...emptyResult };
}
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("integrations")
.input(
z.array(
z.object({
name: z.string().nonempty().max(127),
href: z.string().nullable(),
iconUrl: z.string().nullable(),
}),
),
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) return;
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
await ctx.db.insert(apps).values(
input.map((app) => ({
id: createId(),
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
href: app.href,
description: null,
})),
);
}),
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
@@ -206,6 +335,17 @@ export const onboardRouter = createTRPCRouter({
const layout = board.layouts[0];
if (section && layout) {
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
for (const item of existingItems) {
await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
}
if (existingItems.length > 0) {
await db.delete(items).where(eq(items.boardId, board.id));
}
const widgetCtx: PlaceWidgetContext = {
db,
boardId: board.id,