feat(onboarding): enhance integration setup with Docker discovery and URL templating (#5607)
This commit is contained in:
1 parent
0a6467979a
commit
dd7a4908a3
68 files changed
+2922
-434
No files matched your search
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
};
|
||||
@@ -12,5 +12,8 @@ export * from "./docs";
|
||||
export * from "./cookie";
|
||||
export * from "./search-engine";
|
||||
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,41 @@
|
||||
import { removeTrailingSlash } from "@homarr/common";
|
||||
|
||||
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];
|
||||
|
||||
const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => {
|
||||
const modeBuilders: Record<UrlTemplateMode, () => string> = {
|
||||
subdomain: () => `https://${slug}.${host}`,
|
||||
hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`),
|
||||
};
|
||||
return modeBuilders[mode]();
|
||||
};
|
||||
|
||||
export const buildIntegrationUrl = (
|
||||
kind: IntegrationKind,
|
||||
baseHost: string,
|
||||
mode: UrlTemplateMode,
|
||||
dockerPort?: number,
|
||||
): string => {
|
||||
const host = removeTrailingSlash(baseHost.trim());
|
||||
if (!host) return "";
|
||||
const port = dockerPort ?? getIntegrationDefaultPort(kind);
|
||||
return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined);
|
||||
};
|
||||
|
||||
export const buildAppUrl = (
|
||||
containerName: string,
|
||||
baseHost: string,
|
||||
mode: UrlTemplateMode,
|
||||
dockerPort?: number,
|
||||
): string => {
|
||||
const host = removeTrailingSlash(baseHost.trim());
|
||||
if (!host) return "";
|
||||
const slug = containerName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
return buildUrl(slug, host, mode, dockerPort);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import { objectKeys, removeTrailingSlash } from "@homarr/common";
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
|
||||
import { createDocumentationLink } from "./docs";
|
||||
@@ -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,8 @@ 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,
|
||||
apiKeySettingsPath: "/#/settings/webui",
|
||||
},
|
||||
aria2: {
|
||||
name: "Aria2",
|
||||
@@ -72,6 +81,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 +89,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 +97,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 +106,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 +115,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 +124,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 +133,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 +142,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 +151,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 +159,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 +167,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 +176,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 +185,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 +194,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 +202,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 +210,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 +218,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 +233,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 +241,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 +249,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 +257,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 +265,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 +273,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 +345,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 +360,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 +368,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 +376,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 +385,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 +400,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 +408,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 +416,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 +425,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 +467,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)) return null;
|
||||
const base = removeTrailingSlash(integrationUrl);
|
||||
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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const;
|
||||
export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const;
|
||||
export type OnboardingStep = (typeof onboardingSteps)[number];
|
||||
@@ -0,0 +1,29 @@
|
||||
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,41 @@
|
||||
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("https://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("https://sonarr.homelab.local");
|
||||
expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://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("https://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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createDocumentationLink } from "./docs";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import { getIntegrationKindsByCategory } from "./integration";
|
||||
import type { WidgetKind } from "./widget";
|
||||
|
||||
/**
|
||||
* Static mapping of widget kinds to the integration kinds they support.
|
||||
* Mirrors the supportedIntegrations declared in each widget definition
|
||||
* but lives in @homarr/definitions so server-side code (API layer)
|
||||
* can use it without importing @homarr/widgets.
|
||||
*/
|
||||
export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly IntegrationKind[]>> = {
|
||||
dnsHoleSummary: getIntegrationKindsByCategory("dnsHole"),
|
||||
dnsHoleControls: getIntegrationKindsByCategory("dnsHole"),
|
||||
"smartHome-entityState": getIntegrationKindsByCategory("smartHomeServer"),
|
||||
"smartHome-executeAutomation": getIntegrationKindsByCategory("smartHomeServer"),
|
||||
mediaServer: getIntegrationKindsByCategory("mediaService"),
|
||||
calendar: getIntegrationKindsByCategory("calendar"),
|
||||
downloads: getIntegrationKindsByCategory("downloadClient"),
|
||||
"mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"),
|
||||
"mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"),
|
||||
mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"),
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["emby", "jellyfin", "plex"],
|
||||
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
|
||||
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
|
||||
coolify: ["coolify"],
|
||||
timetable: ["searchCh"],
|
||||
"immich-serverStats": ["immich"],
|
||||
"immich-albumCarousel": ["immich"],
|
||||
tracearr: ["tracearr"],
|
||||
speedtestTracker: ["speedtestTracker"],
|
||||
anchorNote: ["anchor"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
const result: WidgetKind[] = [];
|
||||
for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) {
|
||||
if (supportedIntegrations.includes(integrationKind)) {
|
||||
result.push(widgetKind as WidgetKind);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export interface DefaultWidgetConfig {
|
||||
kind: WidgetKind;
|
||||
width: number;
|
||||
height: number;
|
||||
options?: Record<string, unknown>;
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
||||
{ kind: "clock", width: 2, height: 1 },
|
||||
{ kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
|
||||
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||
];
|
||||
|
||||
export const generalWidgets: WidgetKind[] = defaultWidgetConfigs
|
||||
.filter((config) => !config.skip)
|
||||
.map((config) => config.kind);
|
||||
|
||||
export const featuredIntegrations: readonly IntegrationKind[] = [
|
||||
"sonarr",
|
||||
"radarr",
|
||||
"prowlarr",
|
||||
"sabNzbd",
|
||||
"qBittorrent",
|
||||
"seerr",
|
||||
"jellyfin",
|
||||
];
|
||||
|
||||
export const hiddenFromOnboarding = new Set<IntegrationKind>([
|
||||
"searchCh",
|
||||
"github",
|
||||
"dockerHub",
|
||||
"gitlab",
|
||||
"npm",
|
||||
"codeberg",
|
||||
"linuxServerIO",
|
||||
"gitHubContainerRegistry",
|
||||
"quay",
|
||||
"mock",
|
||||
]);
|
||||
|
||||
export const defaultBookmarkApps = [
|
||||
{
|
||||
name: "Homarr Docs",
|
||||
href: createDocumentationLink("/docs/getting-started"),
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
},
|
||||
{
|
||||
name: "Homarr GitHub",
|
||||
href: "https://github.com/homarr-labs/homarr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/github.svg",
|
||||
},
|
||||
{
|
||||
name: "Help Translate",
|
||||
href: createDocumentationLink("/docs/community/translations"),
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
},
|
||||
{
|
||||
name: "Support Homarr",
|
||||
href: "https://opencollective.com/homarr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg",
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user