diff --git a/apps/nextjs/src/app/[locale]/manage/page.tsx b/apps/nextjs/src/app/[locale]/manage/page.tsx
index ed0cec2bc..d0ced6fe7 100644
--- a/apps/nextjs/src/app/[locale]/manage/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/page.tsx
@@ -1,13 +1,16 @@
import Link from "next/link";
-import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
+import { Card, Group, SimpleGrid, Space, Stack, Text, Title } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
+import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
+import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
+import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
@@ -30,6 +33,8 @@ export async function generateMetadata() {
export default async function ManagementPage() {
const statistics = await api.home.getStats();
const session = await auth();
+ const isAdmin = session?.user.permissions.includes("admin") ?? false;
+ const suggestions = isAdmin ? await api.integration.suggestedIntegrations().catch(catchTrpcNotFound) : [];
const t = await getScopedI18n("management.page.home");
const links: LinkProps[] = [
@@ -102,6 +107,37 @@ export default async function ManagementPage() {
),
)}
+ {suggestions.length >= 1 && (
+ <>
+
+
+
+ Suggested integrations
+ Based on your Docker containers
+
+
+ {suggestions.slice(0, 6).map((suggestion) => (
+
+
+
+
+
+
+ {getIntegrationName(suggestion.kind)}
+
+
+ Show active media streams
+
+
+
+
+
+
+ ))}
+
+
+ >
+ )}
>
);
}
diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts
index 5c898fbef..67e808cbe 100644
--- a/packages/api/src/router/integration/integration-router.ts
+++ b/packages/api/src/router/integration/integration-router.ts
@@ -14,15 +14,19 @@ import {
integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema";
-import type { IntegrationSecretKind } from "@homarr/definitions";
+import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationKindsByCategory,
+ getIntegrationName,
getPermissionsWithParents,
integrationDefs,
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
+import type { ContainerInfo, Port } from "@homarr/docker";
+import { DockerSingleton } from "@homarr/docker";
+import { env } from "@homarr/docker/env";
import { createIntegrationAsync } from "@homarr/integrations";
import { logger } from "@homarr/log";
import { byIdSchema } from "@homarr/validation/common";
@@ -527,8 +531,169 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
+ suggestedIntegrations: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
+ if (!env.ENABLE_DOCKER)
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Docker is not enabled",
+ });
+
+ const dockerInstances = DockerSingleton.getInstances();
+ if (dockerInstances.length === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No Docker instances found",
+ });
+ }
+
+ // TODO: remove those that were already added
+ const containers = await Promise.all(
+ dockerInstances.map(({ instance, host, isSocket }) =>
+ instance.listContainers().then((containers) =>
+ containers.map((containerInfo) => ({
+ info: containerInfo,
+ host,
+ isSocket,
+ })),
+ ),
+ ),
+ ).then((results) => results.flat());
+
+ // Default docker hostname consist out of first 12 characters of the container id
+ const homarrContainer = containers.find((container) => container.info.Id.slice(0, 12) === process.env.HOSTNAME);
+
+ const suggestions = containers
+ .map((container) => getSuggestionFromContainer(container, homarrContainer))
+ .filter((suggestion) => suggestion !== null);
+
+ const groupedSuggestions = suggestions.reduce(
+ (acc, suggestion) => {
+ acc[suggestion.kind] ??= [];
+ // is defined above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ acc[suggestion.kind]!.push(suggestion);
+ return acc;
+ },
+ {} as Partial>,
+ );
+
+ return Object.entries(groupedSuggestions)
+ .map(([kind, suggestions]) => ({
+ kind: kind as IntegrationKind,
+ suggestions,
+ }))
+ .sort((itemA, itemB) => {
+ if (itemA.suggestions.length !== itemB.suggestions.length)
+ return itemB.suggestions.length - itemA.suggestions.length;
+
+ const createdAtA = Math.max(...itemA.suggestions.map((suggestion) => suggestion.createdAt?.getTime() ?? 0));
+ const createdAtB = Math.max(...itemB.suggestions.map((suggestion) => suggestion.createdAt?.getTime() ?? 0));
+ return createdAtB - createdAtA;
+ });
+ }),
});
+interface IntegrationSuggestion {
+ containerId: string;
+ name: string;
+ internalUrl: string | null;
+ externalUrl: string | null;
+ kind: IntegrationKind;
+ createdAt?: Date;
+}
+
+interface ContainerInfoWithHost {
+ info: ContainerInfo;
+ host: string;
+ isSocket: boolean | undefined;
+}
+
+const getSuggestionFromContainer = (
+ container: ContainerInfoWithHost,
+ homarrContainer: ContainerInfoWithHost | undefined,
+): IntegrationSuggestion | null => {
+ const integrationKind = getSuggestionKind(container.info);
+
+ if (!integrationKind) return null;
+
+ return {
+ containerId: container.info.Id,
+ name: getIntegrationName(integrationKind),
+ kind: integrationKind,
+ internalUrl: getInternalUrl(container, homarrContainer),
+ externalUrl: getExternalUrl(container),
+ createdAt: container.info.Created ? new Date(container.info.Created * 1000) : undefined,
+ };
+};
+
+const getSuggestionKind = (container: ContainerInfo): IntegrationKind | null => {
+ const [image, _tag] = container.Image.split(":");
+ if (!image) return null;
+ return (
+ objectEntries(integrationDefs).find(
+ ([, integration]) =>
+ "containerImages" in integration &&
+ integration.containerImages.some((containerImage) => containerImage.toLowerCase() === image.toLowerCase()),
+ )?.[0] ?? null
+ );
+};
+
+const calculatePortScore = (port: Port): number => {
+ let score = 0;
+
+ if (port.PublicPort) score += 10; // Public ports are more likely to be used
+ if (port.PrivatePort % 1000 === 80) score += 5; // Default http ports are most likely suffixed with 80 like 8080 or 80
+ if (port.PrivatePort % 1000 === 443) score += 10; // Default https ports are most likely suffixed with 443 like 8443 or 443
+ return score;
+};
+
+const getPort = (container: ContainerInfoWithHost): Port | null => {
+ const ports = container.info.Ports.filter((port) => port.Type === "tcp");
+ return (
+ ports
+ .map((port) => ({ port, score: calculatePortScore(port) }))
+ .sort((portA, portB) => portB.score - portA.score)
+ .at(0)?.port ?? null
+ );
+};
+
+const getInternalUrl = (
+ container: ContainerInfoWithHost,
+ homarrContainer: ContainerInfoWithHost | undefined,
+): string | null => {
+ const port = getPort(container);
+ if (!port) return null;
+ const protocol = port.PrivatePort % 1000 === 443 ? "https" : "http";
+
+ if (homarrContainer) {
+ const matchingNetwork = Object.entries(homarrContainer.info.NetworkSettings.Networks).find(
+ ([_key, homarrNetwork]) =>
+ Object.entries(container.info.NetworkSettings.Networks).some(
+ ([_name, network]) => homarrNetwork.NetworkID === network.NetworkID,
+ ),
+ )?.[1];
+ if (matchingNetwork) return `${protocol}://${matchingNetwork.IPAddress}:${port.PrivatePort}`;
+ }
+
+ // TODO: Let user specify the internal ip-address for integrations --> use as default before falling back to host.docker.internal
+ if (container.host === homarrContainer?.host && container.isSocket === homarrContainer.isSocket) {
+ return `${protocol}://host.docker.internal:${port.PublicPort}`;
+ }
+
+ // TODO: Change this
+ return `${protocol}://localhost:${port.PublicPort}`;
+};
+
+const getExternalUrl = (container: ContainerInfoWithHost): string | null => {
+ const port = getPort(container);
+ if (!port) return null;
+ const protocol = port.PrivatePort % 1000 === 443 ? "https" : "http";
+
+ // TODO: Use some labels like "traefik.host" to determine the external url
+ // For now we just use localhost as external url
+ return `${protocol}://localhost:${port.PublicPort}`;
+};
+
interface UpdateSecretInput {
integrationId: string;
value: string;
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index a363a6cac..fae79fd09 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -16,6 +16,7 @@ interface integrationDefinition {
iconUrl: string;
secretKinds: AtLeastOneOf; // at least one secret kind set is required
category: AtLeastOneOf;
+ containerImages?: string[]; // Optional, used for container images to suggest in the UI
}
export const integrationDefs = {
@@ -24,30 +25,45 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
category: ["downloadClient", "usenet"],
+ containerImages: ["linuxserver/sabnzbd", "lscr.io/linuxserver/sabnzbd", "ghcr.io/linuxserver/sabnzbd"],
},
nzbGet: {
name: "NZBGet",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
category: ["downloadClient", "usenet"],
+ containerImages: [
+ "nzbgetcom/nzbget",
+ "ghcr.io/nzbgetcom/nzbget",
+ "linuxserver/nzbget",
+ "lscr.io/linuxserver/nzbget",
+ "ghcr.io/linuxserver/nzbget",
+ ],
},
deluge: {
name: "Deluge",
secretKinds: [["password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
category: ["downloadClient", "torrent"],
+ containerImages: ["linuxserver/deluge", "lscr.io/linuxserver/deluge", "ghcr.io/linuxserver/deluge"],
},
transmission: {
name: "Transmission",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
category: ["downloadClient", "torrent"],
+ containerImages: [
+ "linuxserver/transmission",
+ "lscr.io/linuxserver/transmission",
+ "ghcr.io/linuxserver/transmission",
+ ],
},
qBittorrent: {
name: "qBittorrent",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
+ containerImages: ["linuxserver/qbittorrent", "lscr.io/linuxserver/qbittorrent", "ghcr.io/linuxserver/qbittorrent"],
},
aria2: {
name: "Aria2",
@@ -60,78 +76,144 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar"],
+ containerImages: [
+ "hotio/sonarr",
+ "ghcr.io/hotio/sonarr",
+ "linuxserver/sonarr",
+ "lscr.io/linuxserver/sonarr",
+ "ghcr.io/linuxserver/sonarr",
+ ],
},
radarr: {
name: "Radarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar"],
+ containerImages: [
+ "hotio/radarr",
+ "ghcr.io/hotio/radarr",
+ "linuxserver/radarr",
+ "lscr.io/linuxserver/radarr",
+ "ghcr.io/linuxserver/radarr",
+ ],
},
lidarr: {
name: "Lidarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
category: ["calendar"],
+ containerImages: [
+ "hotio/lidarr",
+ "ghcr.io/hotio/lidarr",
+ "linuxserver/lidarr",
+ "lscr.io/linuxserver/lidarr",
+ "ghcr.io/linuxserver/lidarr",
+ ],
},
readarr: {
name: "Readarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
category: ["calendar"],
+ containerImages: [
+ "hotio/readarr",
+ "ghcr.io/hotio/readarr",
+ "linuxserver/readarr",
+ "lscr.io/linuxserver/readarr",
+ "ghcr.io/linuxserver/readarr",
+ ],
},
prowlarr: {
name: "Prowlarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
category: ["indexerManager"],
+ containerImages: [
+ "hotio/prowlarr",
+ "ghcr.io/hotio/prowlarr",
+ "linuxserver/prowlarr",
+ "lscr.io/linuxserver/prowlarr",
+ "ghcr.io/linuxserver/prowlarr",
+ ],
},
jellyfin: {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService"],
+ containerImages: [
+ "jellyfin/jellyfin",
+ "ghcr.io/jellyfin/jellyfin",
+ "linuxserver/jellyfin",
+ "lscr.io/linuxserver/jellyfin",
+ "ghcr.io/linuxserver/jellyfin",
+ ],
},
emby: {
name: "Emby",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService"],
+ containerImages: [
+ "emby/embyserver",
+ "emby/embyserver_arm64v8",
+ "emby/embyserver_arm32v7",
+ "linuxserver/emby",
+ "lscr.io/linuxserver/emby",
+ "ghcr.io/linuxserver/emby",
+ ],
},
plex: {
name: "Plex",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService"],
+ containerImages: ["plexinc/pms-docker", "linuxserver/plex", "lscr.io/linuxserver/plex", "ghcr.io/linuxserver/plex"],
},
jellyseerr: {
name: "Jellyseerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
+ containerImages: ["fallenbagel/jellyseerr", "ghcr.io/fallenbagel/jellyseerr"],
},
overseerr: {
name: "Overseerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
+ containerImages: [
+ "sct/overseerr",
+ "linuxserver/overseerr",
+ "lscr.io/linuxserver/overseerr",
+ "ghcr.io/linuxserver/overseerr",
+ ],
},
piHole: {
name: "Pi-hole",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
category: ["dnsHole"],
+ containerImages: ["pihole/pihole"],
},
adGuardHome: {
name: "AdGuard Home",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
category: ["dnsHole"],
+ containerImages: ["adguard/adguardhome"],
},
homeAssistant: {
name: "Home Assistant",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
category: ["smartHomeServer"],
+ containerImages: [
+ "homeassistant/home-assistant",
+ "linuxserver/home-assistant",
+ "lscr.io/linuxserver/home-assistant",
+ "ghcr.io/linuxserver/home-assistant",
+ ],
},
openmediavault: {
name: "OpenMediaVault",
@@ -144,12 +226,14 @@ export const integrationDefs = {
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
category: ["healthMonitoring"],
+ containerImages: ["mauricenino/dashdot", "ghcr.io/mauricenino/dashdot"],
},
tdarr: {
name: "Tdarr",
secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
category: ["mediaTranscoding"],
+ containerImages: ["haveagitgat/tdarr", "ghcr.io/haveagitgat/tdarr"],
},
proxmox: {
name: "Proxmox",
@@ -168,6 +252,16 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
+ containerImages: [
+ "jacobalberty/unifi",
+ "ghcr.io/jacobalberty/unifi",
+ "linuxserver/unifi-controller",
+ "lscr.io/linuxserver/unifi-controller",
+ "ghcr.io/linuxserver/unifi-controller",
+ "linuxserver/unifi-network-application",
+ "lscr.io/linuxserver/unifi-network-application",
+ "ghcr.io/linuxserver/unifi-network-application",
+ ],
},
} as const satisfies Record;
diff --git a/packages/docker/src/singleton.ts b/packages/docker/src/singleton.ts
index 86ce9661e..93c3d060b 100644
--- a/packages/docker/src/singleton.ts
+++ b/packages/docker/src/singleton.ts
@@ -5,6 +5,7 @@ import { env } from "./env";
export interface DockerInstance {
host: string;
instance: Docker;
+ isSocket?: boolean;
}
export class DockerSingleton {
@@ -14,7 +15,7 @@ export class DockerSingleton {
const hostVariable = env.DOCKER_HOSTNAMES;
const portVariable = env.DOCKER_PORTS;
if (hostVariable === undefined || portVariable === undefined) {
- return [{ host: "socket", instance: new Docker() }];
+ return [{ host: "socket", instance: new Docker(), isSocket: true }];
}
const hostnames = hostVariable.split(",");
const ports = portVariable.split(",");