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 (
@@ -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,
@@ -0,0 +1,91 @@
import { objectKeys } from "@homarr/common";
import { integrationDefs } from "./integration";
import type { IntegrationKind } from "./integration";
export const extractContainerImageName = (image: string): string =>
image.split("/").at(-1)?.split(":").at(0) ?? "";
const extractIconSlug = (iconUrl: string): string => {
const filename = iconUrl.split("/").pop() ?? "";
return filename.replace(/\.(svg|png)$/, "").toLowerCase();
};
export const integrationIconSlugs: Record<IntegrationKind, string> = Object.fromEntries(
objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]),
) as Record<IntegrationKind, string>;
const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> = {
piHole: ["pihole", "pi-hole"],
adGuardHome: ["adguardhome", "adguard-home", "adguard"],
homeAssistant: ["homeassistant", "home-assistant", "hass"],
openmediavault: ["omv"],
qBittorrent: ["qbittorrent"],
sabNzbd: ["sabnzbd"],
nzbGet: ["nzbget"],
unifiController: ["unifi", "unifi-controller"],
jellyseerr: ["jellyseerr"],
overseerr: ["overseerr"],
dashDot: ["dashdot", "dash-dot", "dash."],
speedtestTracker: ["speedtest-tracker"],
searchCh: ["search-ch"],
gitHubContainerRegistry: ["ghcr"],
linuxServerIO: ["linuxserver"],
coolify: ["coolify"],
truenas: ["truenas"],
};
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
const normalized = search.toLowerCase().trim();
if (!normalized) return null;
for (const kind of objectKeys(integrationDefs)) {
if (kind.toLowerCase() === normalized) return kind;
if (integrationDefs[kind].name.toLowerCase() === normalized) return kind;
}
for (const kind of objectKeys(integrationDefs)) {
if (integrationIconSlugs[kind] === normalized) return kind;
}
for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) {
if (aliases.some((alias) => alias === normalized)) return kind;
}
for (const kind of objectKeys(integrationDefs)) {
if (normalized.includes(kind.toLowerCase())) return kind;
if (normalized.includes(integrationIconSlugs[kind])) return kind;
}
return null;
};
const notDockerDiscoverable = new Set<IntegrationKind>([
"dockerHub",
"github",
"gitlab",
"npm",
"codeberg",
"linuxServerIO",
"gitHubContainerRegistry",
"quay",
"ical",
"searchCh",
"mock",
]);
interface ContainerMatchInput {
image: string;
name: string;
}
export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
const imageName = extractContainerImageName(container.image);
const fromImage = matchIntegrationKind(imageName);
if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage;
const fromName = matchIntegrationKind(container.name);
if (fromName && !notDockerDiscoverable.has(fromName)) return fromName;
return null;
};
+2
View File
@@ -15,3 +15,5 @@ export * from "./onboarding";
export * from "./widget-integration-map";
export * from "./emptysuperjson";
export * from "./hotkeys";
export * from "./docker-integration-match";
export * from "./integration-url-template";
@@ -0,0 +1,30 @@
import { integrationIconSlugs } from "./docker-integration-match";
import type { IntegrationKind } from "./integration";
import { getIntegrationDefaultPort } from "./integration";
export type UrlTemplateMode = "hostPort" | "subdomain";
const getSlugForKind = (kind: IntegrationKind): string =>
integrationIconSlugs[kind] ?? kind.toLowerCase();
export const buildIntegrationUrl = (
kind: IntegrationKind,
baseHost: string,
mode: UrlTemplateMode,
dockerPort?: number,
): string => {
const host = baseHost.trim().replace(/\/+$/, "");
if (!host) return "";
const slug = getSlugForKind(kind);
const modeBuilders: Record<UrlTemplateMode, () => string> = {
subdomain: () => `http://${slug}.${host}`,
hostPort: () => {
const port = dockerPort ?? getIntegrationDefaultPort(kind);
return port ? `http://${host}:${port}` : `http://${host}`;
},
};
return modeBuilders[mode]();
};
+63 -2
View File
@@ -27,7 +27,9 @@ interface integrationDefinition {
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>;
documentationUrl: string | null;
defaultUrl?: string; // optional default URL for the integration
defaultUrl?: string;
defaultPort?: number;
apiKeySettingsPath?: string;
}
export const integrationDefs = {
@@ -37,6 +39,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"),
defaultPort: 8080,
apiKeySettingsPath: "/config/general/",
},
nzbGet: {
name: "NZBGet",
@@ -44,6 +48,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/nzbget"),
defaultPort: 6789,
},
deluge: {
name: "Deluge",
@@ -51,6 +56,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/deluge"),
defaultPort: 8112,
},
transmission: {
name: "Transmission",
@@ -58,6 +64,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/transmission"),
defaultPort: 9091,
},
qBittorrent: {
name: "qBittorrent",
@@ -65,6 +72,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"),
defaultPort: 8080,
},
aria2: {
name: "Aria2",
@@ -72,6 +80,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
category: ["downloadClient", "torrent", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/aria2"),
defaultPort: 6800,
},
slskd: {
name: "Slskd",
@@ -79,6 +88,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg",
category: ["downloadClient", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/slskd"),
defaultPort: 5030,
},
sonarr: {
name: "Sonarr",
@@ -86,6 +96,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
defaultPort: 8989,
apiKeySettingsPath: "/settings/general",
},
radarr: {
name: "Radarr",
@@ -93,6 +105,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
defaultPort: 7878,
apiKeySettingsPath: "/settings/general",
},
lidarr: {
name: "Lidarr",
@@ -100,6 +114,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/lidarr"),
defaultPort: 8686,
apiKeySettingsPath: "/settings/general",
},
readarr: {
name: "Readarr",
@@ -107,6 +123,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
defaultPort: 8787,
apiKeySettingsPath: "/settings/general",
},
prowlarr: {
name: "Prowlarr",
@@ -114,6 +132,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
category: ["indexerManager"],
documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"),
defaultPort: 9696,
apiKeySettingsPath: "/settings/general",
},
jellyfin: {
name: "Jellyfin",
@@ -121,6 +141,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"),
defaultPort: 8096,
apiKeySettingsPath: "/web/index.html#!/dashboard/keys",
},
emby: {
name: "Emby",
@@ -128,6 +150,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/emby"),
defaultPort: 8096,
},
plex: {
name: "Plex",
@@ -135,6 +158,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/plex"),
defaultPort: 32400,
},
jellyseerr: {
name: "Jellyseerr",
@@ -142,6 +166,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"),
defaultPort: 5055,
apiKeySettingsPath: "/settings",
},
seerr: {
name: "Seerr",
@@ -149,6 +175,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/seerr"),
defaultPort: 5055,
apiKeySettingsPath: "/settings",
},
overseerr: {
name: "Overseerr",
@@ -156,6 +184,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/overseerr"),
defaultPort: 5055,
apiKeySettingsPath: "/settings",
},
piHole: {
name: "Pi-hole",
@@ -163,6 +193,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"),
defaultPort: 80,
},
adGuardHome: {
name: "AdGuard Home",
@@ -170,6 +201,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"),
defaultPort: 3000,
},
homeAssistant: {
name: "Home Assistant",
@@ -177,6 +209,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
category: ["smartHomeServer", "calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"),
defaultPort: 8123,
},
openmediavault: {
name: "OpenMediaVault",
@@ -184,6 +217,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"),
defaultPort: 80,
},
dashDot: {
name: "Dash.",
@@ -198,6 +232,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
category: ["healthMonitoring"],
documentationUrl: null,
defaultPort: 61208,
},
tdarr: {
name: "Tdarr",
@@ -205,6 +240,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
category: ["mediaTranscoding"],
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
defaultPort: 8265,
},
proxmox: {
name: "Proxmox",
@@ -212,6 +248,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/proxmox"),
defaultPort: 8006,
},
nextcloud: {
name: "Nextcloud",
@@ -219,6 +256,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
category: ["calendar", "notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"),
defaultPort: 443,
},
unifiController: {
name: "Unifi Controller",
@@ -226,6 +264,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
defaultPort: 8443,
},
opnsense: {
name: "OPNsense",
@@ -233,6 +272,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
category: ["firewall"],
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
defaultPort: 443,
},
github: {
name: "Github",
@@ -304,6 +344,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
defaultPort: 80,
},
ical: {
name: "iCal",
@@ -318,6 +359,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
category: ["notes"],
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
defaultPort: 8080,
},
truenas: {
name: "TrueNAS",
@@ -325,6 +367,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
defaultPort: 80,
},
unraid: {
name: "Unraid",
@@ -332,6 +375,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
defaultPort: 80,
},
coolify: {
name: "Coolify",
@@ -340,12 +384,12 @@ export const integrationDefs = {
category: ["healthMonitoring"],
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
defaultPort: 8000,
},
searchCh: {
name: "Search.ch",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png",
defaultUrl: "https://search.ch",
category: ["timetable"],
documentationUrl: createDocumentationLink("/docs/integrations/search-ch"),
},
@@ -355,6 +399,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg",
category: ["photoService"],
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
defaultPort: 2283,
},
tracearr: {
name: "Tracearr",
@@ -362,6 +407,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg",
category: ["mediaMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
defaultPort: 7040,
},
speedtestTracker: {
name: "Speedtest Tracker",
@@ -369,6 +415,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
defaultPort: 80,
},
umami: {
name: "Umami",
@@ -377,6 +424,7 @@ export const integrationDefs = {
category: ["analytics"],
defaultUrl: "https://api.umami.is/v1",
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
defaultPort: 3000,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
@@ -418,6 +466,19 @@ export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
return "defaultUrl" in definition ? definition.defaultUrl : undefined;
};
export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undefined => {
const definition = integrationDefs[kind];
return "defaultPort" in definition ? definition.defaultPort : undefined;
};
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
const definition = integrationDefs[kind];
if (!("apiKeySettingsPath" in definition) || !definition.apiKeySettingsPath) return null;
const base = integrationUrl.replace(/\/+$/, "");
if (!base) return null;
return `${base}${definition.apiKeySettingsPath}`;
};
/**
* Get all integration kinds that share a category, typed only by the kinds belonging to the category
* @param category Category to filter by, belonging to IntegrationCategory
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { getIntegrationApiKeyUrl } from "../integration";
describe("getIntegrationApiKeyUrl", () => {
it("builds URL for sonarr", () => {
expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe(
"http://192.168.1.10:8989/settings/general",
);
});
it("builds URL for jellyfin", () => {
expect(getIntegrationApiKeyUrl("http://media.local:8096", "jellyfin")).toBe(
"http://media.local:8096/web/index.html#!/dashboard/keys",
);
});
it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
});
it("returns null for empty URL", () => {
expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull();
});
it("strips trailing slash from base URL", () => {
expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe(
"http://sonarr.local/settings/general",
);
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
extractContainerImageName,
matchIntegrationKind,
matchIntegrationKindFromContainer,
} from "../docker-integration-match";
describe("extractContainerImageName", () => {
it("extracts name from full image reference", () => {
expect(extractContainerImageName("linuxserver/sonarr:latest")).toBe("sonarr");
});
it("extracts name from ghcr image", () => {
expect(extractContainerImageName("ghcr.io/org/radarr:v5")).toBe("radarr");
});
it("handles plain image name", () => {
expect(extractContainerImageName("jellyfin")).toBe("jellyfin");
});
it("handles empty string", () => {
expect(extractContainerImageName("")).toBe("");
});
});
describe("matchIntegrationKind", () => {
it("matches exact kind name", () => {
expect(matchIntegrationKind("sonarr")).toBe("sonarr");
});
it("matches case-insensitively", () => {
expect(matchIntegrationKind("Sonarr")).toBe("sonarr");
});
it("matches icon slug for pi-hole", () => {
expect(matchIntegrationKind("pi-hole")).toBe("piHole");
});
it("matches alias pihole", () => {
expect(matchIntegrationKind("pihole")).toBe("piHole");
});
it("matches alias adguardhome", () => {
expect(matchIntegrationKind("adguardhome")).toBe("adGuardHome");
});
it("matches alias homeassistant", () => {
expect(matchIntegrationKind("homeassistant")).toBe("homeAssistant");
});
it("matches substring in image name", () => {
expect(matchIntegrationKind("hotio-sonarr")).toBe("sonarr");
});
it("returns null for unknown", () => {
expect(matchIntegrationKind("nginx")).toBeNull();
});
it("returns null for empty string", () => {
expect(matchIntegrationKind("")).toBeNull();
});
});
describe("matchIntegrationKindFromContainer", () => {
it("matches from image name first", () => {
expect(
matchIntegrationKindFromContainer({
image: "linuxserver/sonarr:latest",
name: "my-sonarr",
}),
).toBe("sonarr");
});
it("falls back to container name", () => {
expect(
matchIntegrationKindFromContainer({
image: "custom/myapp:latest",
name: "radarr",
}),
).toBe("radarr");
});
it("returns null when neither matches", () => {
expect(
matchIntegrationKindFromContainer({
image: "nginx:latest",
name: "web-proxy",
}),
).toBeNull();
});
it("excludes dockerHub even when image contains docker", () => {
expect(
matchIntegrationKindFromContainer({
image: "portainer/portainer-ce:latest",
name: "docker-proxy",
}),
).toBeNull();
});
it("excludes ical from container matching", () => {
expect(
matchIntegrationKindFromContainer({
image: "ical-relay:latest",
name: "ical",
}),
).toBeNull();
});
it("excludes github from container matching", () => {
expect(
matchIntegrationKindFromContainer({
image: "github-runner:latest",
name: "github-actions",
}),
).toBeNull();
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getIntegrationDefaultPort } from "../integration";
import { buildIntegrationUrl } from "../integration-url-template";
describe("buildIntegrationUrl", () => {
it("builds subdomain URL", () => {
expect(buildIntegrationUrl("sonarr", "homelab.local", "subdomain")).toBe("http://sonarr.homelab.local");
});
it("builds hostPort URL with default port", () => {
expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1:8989");
});
it("uses docker port over default port", () => {
expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort", 9999)).toBe("http://192.168.1.1:9999");
});
it("builds hostPort URL without port when none known", () => {
expect(buildIntegrationUrl("mock", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1");
});
it("strips trailing slashes from host", () => {
expect(buildIntegrationUrl("sonarr", "homelab.local/", "subdomain")).toBe("http://sonarr.homelab.local");
});
it("returns empty string for empty host", () => {
expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe("");
});
it("uses pi-hole slug for subdomain mode", () => {
expect(buildIntegrationUrl("piHole", "example.com", "subdomain")).toBe("http://pi-hole.example.com");
});
it("reads default ports from integration definitions", () => {
expect(getIntegrationDefaultPort("radarr")).toBe(7878);
expect(getIntegrationDefaultPort("prowlarr")).toBe(9696);
expect(getIntegrationDefaultPort("jellyfin")).toBe(8096);
});
});
@@ -76,6 +76,19 @@ export const featuredIntegrations: readonly IntegrationKind[] = [
"jellyfin",
];
export const hiddenFromOnboarding = new Set<IntegrationKind>([
"searchCh",
"github",
"dockerHub",
"gitlab",
"npm",
"codeberg",
"linuxServerIO",
"gitHubContainerRegistry",
"quay",
"mock",
]);
export const defaultBookmarkApps = [
{
name: "Homarr Docs",
+2 -1
View File
@@ -7,6 +7,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { extractContainerImageName } from "@homarr/definitions";
import type { ContainerState } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
@@ -23,7 +24,7 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
cacheDuration: dayjs.duration(20, "seconds"),
});
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
async function getContainersWithStatsAsync() {
const dockerInstances = DockerSingleton.getInstances();
+24 -1
View File
@@ -101,7 +101,27 @@
"configure": {
"progress": "{current} of {total}",
"skip": "Skip this integration",
"finishing": "Setting up your dashboard..."
"finishing": "Setting up your dashboard...",
"helpers": {
"tooltip": "URL helpers",
"description": "These helpers try to guess the URL for your services. If it doesn't work, just type the address manually."
}
},
"urlTemplate": {
"label": "Base URL",
"placeholder": "homelab.local or 192.168.1.1",
"hint": "Enter your base domain or IP to auto-fill integration URLs",
"preview": "e.g. {example}",
"mode": {
"subdomain": "Subdomain",
"hostPort": "Host:Port"
}
},
"docker": {
"detected": "Docker detected {count} services",
"detectedBreakdown": "{integrations} integrations · {apps} apps",
"appBadge": "App",
"appSectionLabel": "Also add {count} apps to dashboard"
}
},
"finish": {
@@ -744,6 +764,9 @@
},
"appHref": {
"placeholder": "Custom application URL"
},
"apiKeySettings": {
"label": "Get API key"
}
},
"action": {
+24 -1
View File
@@ -101,7 +101,27 @@
"configure": {
"progress": "{current} of {total}",
"skip": "Skip this integration",
"finishing": "Setting up your dashboard..."
"finishing": "Setting up your dashboard...",
"helpers": {
"tooltip": "URL helpers",
"description": "These helpers try to guess the URL for your services. If it doesn't work, just type the address manually."
}
},
"urlTemplate": {
"label": "Base URL",
"placeholder": "homelab.local or 192.168.1.1",
"hint": "Enter your base domain or IP to auto-fill integration URLs",
"preview": "e.g. {example}",
"mode": {
"subdomain": "Subdomain",
"hostPort": "Host:Port"
}
},
"docker": {
"detected": "Docker detected {count} services",
"detectedBreakdown": "{integrations} integrations · {apps} apps",
"appBadge": "App",
"appSectionLabel": "Also add {count} apps to dashboard"
}
},
"finish": {
@@ -744,6 +764,9 @@
},
"appHref": {
"placeholder": "Custom app URL"
},
"apiKeySettings": {
"label": "Get API key"
}
},
"action": {