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:
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;
|
||||
};
|
||||
@@ -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]();
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user