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(",");