Compare commits

...
Author SHA1 Message Date
Meier Lukas 0513a8e6f5 feat(integrations): add suggestions to manage home page 2025-06-12 22:36:37 +02:00
4 changed files with 299 additions and 3 deletions

No files matched your search

+37 -1
View File
@@ -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() {
),
)}
</SimpleGrid>
{suggestions.length >= 1 && (
<>
<Space h="md" />
<Stack gap="sm">
<Stack gap={0}>
<Title order={3}>Suggested integrations</Title>
<Text c="dimmed">Based on your Docker containers</Text>
</Stack>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{suggestions.slice(0, 6).map((suggestion) => (
<Card key={suggestion.kind} component={Link} href="#" radius="lg">
<Group justify="space-between">
<Group>
<IntegrationAvatar kind={suggestion.kind} size="sm" />
<Stack gap={0}>
<Text size="md" fw={500}>
{getIntegrationName(suggestion.kind)}
</Text>
<Text c="dimmed" size="sm">
Show active media streams
</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
))}
</SimpleGrid>
</Stack>
</>
)}
</>
);
}
@@ -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<Record<IntegrationKind, IntegrationSuggestion[]>>,
);
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;
+94
View File
@@ -16,6 +16,7 @@ interface integrationDefinition {
iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>;
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<string, integrationDefinition>;
+2 -1
View File
@@ -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(",");