Compare commits

...
Author SHA1 Message Date
Thomas Camlong 4040f28048 fix(onboarding): avoid mutating emptyResult in docker discovery catch path 2026-07-02 10:47:50 +02:00
ajnart be3d522659 style: auto-fix lint and format 2026-07-02 08:47:21 +00:00
Thomas Camlong 2dcd81676c fix(onboarding): address PR review feedback on docker label discovery
Fix app-only continue flow, handle stale label sync, per-layout yOffset,
blank label normalization, updatedAt on source upsert, docs link, and
forceDisableStatus for ping-enabled apps.
2026-07-02 10:45:47 +02:00
ajnart cf655b8c8e style: auto-fix lint and format 2026-07-01 20:32:06 +00:00
Thomas Camlong 05e7d8c529 feat(onboarding): add Docker label discovery during init
Port label-based container discovery from config-portability and wire it
into the onboarding integrations step. Labeled containers take priority
over image matching, sync into category sections, and preserve board
layout when placing integration widgets.
2026-07-01 22:30:44 +02:00
26 changed files with 1672 additions and 59 deletions

No files matched your search

@@ -0,0 +1,54 @@
---
title: 'Docker labels'
description: 'Configure Homarr apps automatically using Docker container labels during onboarding.'
hide_title: true
---
# Docker label discovery
Homarr can read `homarr.*` labels on your containers to pre-populate apps during onboarding. Homepage-style `homepage.*` labels are supported as a fallback when no `homarr.name` label is set.
## Required labels
| Label | Description |
| --- | --- |
| `homarr.name` | Display name for the app |
| `homarr.group` | Category section name on the board |
| `homarr.href` | URL opened when clicking the app |
## Optional labels
| Label | Description |
| --- | --- |
| `homarr.icon` | Icon URL |
| `homarr.description` | App description |
| `homarr.ping` | Ping URL for status checks |
| `homarr.id` | Stable external ID (defaults to container ID) |
| `homarr.board` | Target board name |
| `homarr.integration` | Integration kind (e.g. `sonarr`) for onboarding integration setup |
| `homarr.widget` | Widget kind hint (parsed, not auto-placed yet) |
| `homarr.hide` | Hide container from discovery |
## Homepage fallback
When `homarr.name` is absent, Homarr reads `homepage.name`, `homepage.group`, `homepage.href`, `homepage.icon`, and `homepage.description` if homepage label reading is enabled (default: on).
## Example
```yaml
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
labels:
homarr.name: "Sonarr"
homarr.group: "Media"
homarr.href: "http://sonarr:8989"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
homarr.integration: "sonarr"
```
## Onboarding behavior
During the integrations step, Homarr scans labeled containers first. Labeled services appear with their configured name, group, and URL. Unlabeled running containers still use image-based discovery as a fallback.
When you finish onboarding, selected label-based apps are synced into category sections on your board. Integration widgets are placed separately in the default section.
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const dockerLabelsIntegration: IntegrationDefinition = {
name: "Docker labels",
description: "Configure Homarr apps automatically using Docker container labels.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
path: "../../integrations/docker-labels",
};
+4 -2
View File
@@ -93,9 +93,11 @@ They are then available in the app list and can be used as normally added apps.
### Configuration
Currently Homarr supports one label for configuration of the containers:
Homarr supports Docker labels for automatic app discovery during onboarding. See [Docker labels](../docker-labels/index.mdx) for the full reference.
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
Currently Homarr supports one label for hiding containers:
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget, tools page, and label discovery.
### Security
Mounting docker sockets can be risky, as they permit full control over your docker service.
@@ -50,7 +50,7 @@ interface IntegrationDraft {
initialUrl: string;
initialName?: string;
dockerPort?: number | null;
source: "manual" | "docker";
source: "manual" | "docker" | "label";
}
export const InitIntegrations = () => {
@@ -65,6 +65,7 @@ export const InitIntegrations = () => {
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
const { mutateAsync: syncLabelApps } = clientApi.onboard.syncDockerLabelApps.useMutation();
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
undefined,
@@ -98,7 +99,7 @@ export const InitIntegrations = () => {
initialUrl: item.suggestedUrl,
initialName: item.containerName,
dockerPort: item.publishedPort,
source: "docker",
source: item.source,
});
}
newKinds.add(item.kind);
@@ -132,7 +133,8 @@ export const InitIntegrations = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
const existing = newDrafts.get(kind);
if (baseHost) {
const useUrlTemplate = baseHost && existing?.source !== "label";
if (useUrlTemplate) {
newDrafts.set(kind, {
kind,
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
@@ -152,10 +154,32 @@ export const InitIntegrations = () => {
const finishSetupAsync = async () => {
setPhase("done");
try {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
const selectedApps = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
const labelApps = selectedApps.filter((app) => app.source === "label");
const imageApps = selectedApps.filter((app) => app.source !== "label");
if (labelApps.length > 0) {
const labelAppsWithHost = labelApps.filter((app): app is typeof app & { host: string } => Boolean(app.host));
const missingHostCount = labelApps.length - labelAppsWithHost.length;
if (missingHostCount > 0) {
throw new Error("Some selected Docker label apps are missing host metadata");
}
if (labelAppsWithHost.length > 0) {
const syncResult = await syncLabelApps(
labelAppsWithHost.map((app) => ({
containerId: app.containerId,
host: app.host,
})),
);
if (syncResult.notFound > 0) {
throw new Error("Some selected Docker label apps were not found");
}
}
}
if (imageApps.length > 0) {
await createApps(
appsToCreate.map((app) => {
imageApps.map((app) => {
const href = baseHost
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
: app.suggestedUrl;
@@ -167,6 +191,7 @@ export const InitIntegrations = () => {
}),
);
}
await setupIntegrations();
router.refresh();
} catch {
@@ -336,9 +361,16 @@ export const InitIntegrations = () => {
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500} lineClamp={1}>
{app.containerName}
</Text>
{app.group && (
<Text size="xs" c="dimmed" lineClamp={1}>
{app.group}
</Text>
)}
</Stack>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
@@ -359,8 +391,14 @@ export const InitIntegrations = () => {
{t("action.skip")}
</Button>
<Button
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0 || undefined}
onClick={() => {
if (selectedKinds.length === 0) {
void finishSetupAsync();
return;
}
handleContinueToConfig();
}}
disabled={(selectedKinds.length === 0 && selectedAppCount === 0) || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
suppressHydrationWarning
>
@@ -404,7 +442,8 @@ const ConfigurePhase = ({
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const computedUrl = baseHost
const useUrlTemplate = baseHost && draft?.source !== "label";
const computedUrl = useUrlTemplate
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
+170 -39
View File
@@ -3,10 +3,13 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { eq, like, or } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, inArray, like, or } from "@homarr/db";
import { placeAllWidgetsAsync } from "@homarr/db/queries";
import { getServerSettingsAsync } from "@homarr/db/queries";
import {
apps,
boards,
groups,
icons,
integrationItems,
@@ -33,6 +36,8 @@ import { MissingSecretError, testConnectionAsync } from "../integration/integrat
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
const logger = createLogger({ module: "onboardDockerDiscovery" });
interface PortInfo {
IP?: string;
PublicPort?: number;
@@ -43,6 +48,30 @@ interface SuggestedUrlResult {
publishedPort: number | null;
}
type DockerDiscoverySource = "label" | "docker";
interface DiscoveredIntegration {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
}
interface DiscoveredApp {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
host?: string;
}
const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
const port = ports?.[0];
if (port?.PublicPort) {
@@ -145,51 +174,91 @@ 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;
publishedPort: number | null;
iconUrl: string | null;
}[],
integrations: [] as DiscoveredIntegration[],
apps: [] as DiscoveredApp[],
};
try {
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
const { DockerSingleton, dockerLabels, listDiscoveredContainersAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const labeledContainerIds = new Set(labeledServices.map((service) => service.containerId));
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations: DiscoveredIntegration[] = [];
const discoveredApps: DiscoveredApp[] = [];
for (const service of labeledServices) {
if (service.integrationKind && !seenKinds.has(service.integrationKind)) {
seenKinds.add(service.integrationKind);
discoveredIntegrations.push({
containerId: service.containerId,
containerName: service.name,
kind: service.integrationKind,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
});
continue;
}
discoveredApps.push({
containerId: service.containerId,
containerName: service.name,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
host: service.host,
});
}
const dockerInstances = DockerSingleton.getInstances();
if (dockerInstances.length === 0) {
return { status: "empty" as const, ...emptyResult };
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: hasResults ? ("success" as const) : ("empty" as const),
integrations: discoveredIntegrations,
apps: discoveredApps,
};
}
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containerList = await instance.listContainers({ all: false });
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
return containerList
.filter((container) => !(dockerLabels.hide in container.Labels))
.filter((container) => !labeledContainerIds.has(container.Id))
.map((container) => ({ ...container, host }));
}),
);
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
if (containers.length === 0) {
logger.warn("Failed to list containers for onboarding discovery", {
host: dockerInstances[index]?.host,
cause: result.reason,
});
return [];
});
if (containers.length === 0 && discoveredIntegrations.length === 0 && discoveredApps.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
const likeQueries = containers.map((container) =>
like(icons.name, `%${extractContainerImageName(container.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;
const cdnIconUrl = (slug: string) =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
@@ -223,6 +292,7 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
});
} else if (!kind && dbIcon) {
discoveredApps.push({
@@ -231,19 +301,61 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
host: container.host,
});
}
}
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: "success" as const,
status: hasResults ? ("success" as const) : ("empty" as const),
integrations: discoveredIntegrations,
apps: discoveredApps,
};
} catch {
} catch (error) {
logger.warn("Docker discovery failed during onboarding", { cause: error });
return { status: "unavailable" as const, ...emptyResult };
}
}),
syncDockerLabelApps: onboardingProcedure
.requiresStep("integrations")
.input(
z.array(
z.object({
containerId: z.string(),
host: z.string(),
}),
),
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) {
return { created: 0, updated: 0, skipped: 0, notFound: 0 };
}
const { listDiscoveredContainersAsync, syncDiscoveredServicesAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const selectedKeys = new Set(input.map((entry) => `${entry.host}/${entry.containerId}`));
const selectedServices = labeledServices.filter((service) =>
selectedKeys.has(`${service.host}/${service.containerId}`),
);
const syncResult = await syncDiscoveredServicesAsync(selectedServices, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
return {
...syncResult,
notFound: input.length - selectedServices.length,
};
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("integrations")
.input(
@@ -257,9 +369,17 @@ export const onboardRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) return;
const existingApps = await ctx.db.query.apps.findMany({ columns: { href: true } });
const existingHrefs = new Set(
existingApps.map((app) => app.href).filter((href): href is string => Boolean(href)),
);
const appsToCreate = input.filter((app) => !app.href || !existingHrefs.has(app.href));
if (appsToCreate.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) => ({
appsToCreate.map((app) => ({
id: createId(),
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
@@ -270,16 +390,26 @@ export const onboardRouter = createTRPCRouter({
}),
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
const serverSettings = await getServerSettingsAsync(db);
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const labelSyncedAppIds = new Set(
(await db.query.dockerAppSources.findMany({ columns: { appId: true } })).map((source) => source.appId),
);
const appsForWidgetPlacement = allApps.filter((app) => !labelSyncedAppIds.has(app.id));
const board = await db.query.boards.findFirst({
const boardList = await db.query.boards.findMany({
orderBy: asc(boards.name),
with: {
sections: true,
layouts: true,
},
});
const targetBoardName = serverSettings.docker.targetBoardName;
const board = targetBoardName
? (boardList.find((entry) => entry.name === targetBoardName) ?? boardList.at(0))
: boardList.at(0);
if (!board) {
await nextOnboardingStepAsync(db, undefined);
@@ -297,15 +427,16 @@ 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),
const emptySectionLayouts = await db.query.itemLayouts.findMany({
where: eq(itemLayouts.sectionId, section.id),
columns: { itemId: true },
});
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 emptySectionItemIds = emptySectionLayouts.map((entry) => entry.itemId);
if (emptySectionItemIds.length > 0) {
await db.delete(integrationItems).where(inArray(integrationItems.itemId, emptySectionItemIds));
await db.delete(itemLayouts).where(inArray(itemLayouts.itemId, emptySectionItemIds));
await db.delete(items).where(inArray(items.id, emptySectionItemIds));
}
await placeAllWidgetsAsync(
@@ -317,7 +448,7 @@ export const onboardRouter = createTRPCRouter({
columnCount: layout.columnCount,
},
allIntegrations,
allApps,
appsForWidgetPlacement,
);
}
@@ -0,0 +1,17 @@
CREATE TABLE `docker_app_source` (
`host` varchar(512) NOT NULL,
`container_id` varchar(128) NOT NULL,
`external_id` varchar(512) NOT NULL,
`app_id` varchar(64) NOT NULL,
`board_id` varchar(64) NOT NULL,
`item_id` varchar(64),
`integration_id` varchar(64),
`updated_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
);
--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -295,6 +295,13 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1780764600000,
"tag": "0042_add_docker_app_source",
"breakpoints": true
}
]
}
@@ -0,0 +1,17 @@
CREATE TABLE "docker_app_source" (
"host" varchar(512) NOT NULL,
"container_id" varchar(128) NOT NULL,
"external_id" varchar(512) NOT NULL,
"app_id" varchar(64) NOT NULL,
"board_id" varchar(64) NOT NULL,
"item_id" varchar(64),
"integration_id" varchar(64),
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
);
--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
@@ -57,6 +57,13 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780764600000,
"tag": "0008_add_docker_app_source",
"breakpoints": true
}
]
}
@@ -0,0 +1,17 @@
CREATE TABLE `docker_app_source` (
`host` text NOT NULL,
`container_id` text NOT NULL,
`external_id` text NOT NULL,
`app_id` text NOT NULL,
`board_id` text NOT NULL,
`item_id` text,
`integration_id` text,
`updated_at` integer NOT NULL,
PRIMARY KEY(`host`, `container_id`),
FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -281,6 +281,13 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1780764600000,
"tag": "0040_add_docker_app_source",
"breakpoints": true
}
]
}
+1
View File
@@ -51,6 +51,7 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
dockerAppSources,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
+46
View File
@@ -547,6 +547,33 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = mysqlTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -826,3 +853,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+46
View File
@@ -546,6 +546,33 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = pgTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -825,3 +852,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+46
View File
@@ -536,6 +536,33 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const dockerAppSources = sqliteTable(
"docker_app_source",
{
host: text().notNull(),
containerId: text().notNull(),
externalId: text().notNull(),
appId: text()
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: text().references(() => items.id, { onDelete: "set null" }),
integrationId: text().references(() => integrations.id, { onDelete: "set null" }),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -815,3 +842,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+6 -2
View File
@@ -25,11 +25,15 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"dockerode": "catalog:"
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"dockerode": "catalog:",
"superjson": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -0,0 +1,472 @@
import type { ContainerInfo } from "dockerode";
import { describe, expect, test } from "vitest";
import { dockerLabels, homepageLabels } from "../../labels";
import { parseContainerLabels } from "../parse-container-labels";
const createContainer = (
labels: Record<string, string>,
id = "container-abc123",
): Pick<ContainerInfo, "Id" | "Labels"> => ({
Id: id,
Labels: labels,
});
describe("parseContainerLabels", () => {
describe("homarr native labels", () => {
test("parses all homarr labels into DiscoveredService", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "https://jellyfin.local",
[dockerLabels.icon]: "https://cdn.example/icon.png",
[dockerLabels.description]: "Media server",
[dockerLabels.ping]: "https://jellyfin.local/health",
[dockerLabels.id]: "jellyfin-1",
[dockerLabels.board]: "homelab",
[dockerLabels.integration]: "jellyfin",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Media",
name: "Jellyfin",
href: "https://jellyfin.local",
icon: "https://cdn.example/icon.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
externalId: "jellyfin-1",
boardName: "homelab",
integrationKind: "jellyfin",
widgetKind: "mediaServer",
});
});
test("parses minimal required homarr labels (name, group, href)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Networking",
[dockerLabels.name]: "Pi-hole",
[dockerLabels.href]: "http://pihole:80/admin",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Networking",
name: "Pi-hole",
href: "http://pihole:80/admin",
externalId: "container-abc123",
});
});
test("uses container ID as externalId when homarr.id is absent", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
},
"sha256:deadbeef1234",
),
"socket",
);
expect(result?.externalId).toBe("sha256:deadbeef1234");
});
test("uses homarr.id as externalId when present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
[dockerLabels.id]: "plex-server-main",
}),
"socket",
);
expect(result?.externalId).toBe("plex-server-main");
});
});
describe("homepage fallback labels", () => {
test("uses homepage labels when homarr.name is absent", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
[homepageLabels.description]: "TV shows",
}),
"192.168.1.10:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "192.168.1.10:2375",
group: "Downloads",
name: "Sonarr",
href: "https://sonarr.local",
icon: "https://cdn.example/sonarr.png",
description: "TV shows",
externalId: "container-abc123",
});
});
test("does not use homepage fallback when readHomepageLabels is false", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
}),
"socket",
{ readHomepageLabels: false },
);
expect(result).toBeNull();
});
test("homepage labels have no ping, board, integration, or widget support", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Emby",
[homepageLabels.href]: "http://emby:8096",
}),
"socket",
);
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
});
});
describe("label priority and mixing", () => {
test("prefers homarr labels over homepage when both are present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Homarr Group",
[dockerLabels.name]: "Homarr Name",
[dockerLabels.href]: "https://homarr.local",
[homepageLabels.group]: "Homepage Group",
[homepageLabels.name]: "Homepage Name",
[homepageLabels.href]: "https://homepage.local",
}),
"socket",
);
expect(result?.group).toBe("Homarr Group");
expect(result?.name).toBe("Homarr Name");
expect(result?.href).toBe("https://homarr.local");
});
test("disables homepage fallback entirely when homarr.name is present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "MyApp",
[dockerLabels.href]: "http://myapp:3000",
[homepageLabels.group]: "FromHomepage",
}),
"socket",
);
expect(result).toBeNull();
});
test("uses homarr icon with homepage name/group/href when homarr.name absent but homarr.icon present", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Jellyfin",
[homepageLabels.href]: "http://jellyfin:8096",
[dockerLabels.icon]: "https://custom-icon.example/jf.png",
}),
"socket",
);
expect(result?.name).toBe("Jellyfin");
expect(result?.icon).toBe("https://custom-icon.example/jf.png");
});
});
describe("homarr.hide", () => {
test("skips container when homarr.hide is present with value 'true'", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "true",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is present with empty value", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is any string (key presence is enough)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "anything",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("missing required labels", () => {
test("returns null when group is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "NoGroup",
[dockerLabels.href]: "https://nogroup.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when name is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.href]: "https://noname.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when href is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "NoHref",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when homepage labels are incomplete (missing group)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.name]: "Incomplete",
[homepageLabels.href]: "http://incomplete.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("whitespace handling", () => {
test("trims whitespace from all label values", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: " Media ",
[dockerLabels.name]: " Jellyfin ",
[dockerLabels.href]: " https://jellyfin.local ",
[dockerLabels.icon]: " https://icon.png ",
[dockerLabels.description]: " A media server ",
}),
"socket",
);
expect(result?.group).toBe("Media");
expect(result?.name).toBe("Jellyfin");
expect(result?.href).toBe("https://jellyfin.local");
expect(result?.icon).toBe("https://icon.png");
expect(result?.description).toBe("A media server");
});
test("treats whitespace-only values as empty (missing)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: " ",
[dockerLabels.href]: "https://example.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("integration and widget kind validation", () => {
test("parses valid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Radarr",
[dockerLabels.href]: "http://radarr:7878",
[dockerLabels.integration]: "radarr",
}),
"socket",
);
expect(result?.integrationKind).toBe("radarr");
});
test("ignores invalid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom App",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.integration]: "nonExistentIntegration",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
test("parses valid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "http://jellyfin:8096",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result?.widgetKind).toBe("mediaServer");
});
test("ignores invalid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.widget]: "totallyFakeWidget",
}),
"socket",
);
expect(result?.widgetKind).toBeUndefined();
});
test("handles empty integration kind gracefully", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "App",
[dockerLabels.href]: "http://app:3000",
[dockerLabels.integration]: "",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
});
describe("real-world scenarios", () => {
test("homepage-only service (common migration use case)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Monitoring",
[homepageLabels.name]: "Grafana",
[homepageLabels.href]: "http://grafana:3000",
[homepageLabels.icon]: "grafana",
[homepageLabels.description]: "Metrics and dashboards",
}),
"tcp://192.168.1.50:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "tcp://192.168.1.50:2375",
group: "Monitoring",
name: "Grafana",
href: "http://grafana:3000",
icon: "grafana",
description: "Metrics and dashboards",
externalId: "container-abc123",
});
});
test("service targeting a specific board", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Infrastructure",
[dockerLabels.name]: "Traefik",
[dockerLabels.href]: "http://traefik:8080",
[dockerLabels.board]: "networking-board",
[dockerLabels.icon]: "https://cdn.example/traefik.svg",
}),
"socket",
);
expect(result?.boardName).toBe("networking-board");
});
test("service with all optional fields empty uses defaults", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Tools",
[dockerLabels.name]: "Portainer",
[dockerLabels.href]: "http://portainer:9000",
},
"abc123def456",
),
"socket",
);
expect(result?.icon).toBeUndefined();
expect(result?.description).toBeUndefined();
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
expect(result?.externalId).toBe("abc123def456");
});
test("container with no labels returns null", () => {
const result = parseContainerLabels(createContainer({}), "socket");
expect(result).toBeNull();
});
});
});
+8
View File
@@ -0,0 +1,8 @@
export type { DiscoveredService } from "./types";
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
export {
syncDiscoveredServicesAsync,
type SyncDiscoveredServicesOptions,
type SyncDiscoveredServicesResult,
} from "./sync-discovered-services";
@@ -0,0 +1,45 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { dockerLabels, homepageLabels } from "../labels";
import { DockerSingleton } from "../singleton";
import type { ParseContainerLabelsOptions } from "./parse-container-labels";
import { parseContainerLabels } from "./parse-container-labels";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscovery" });
const hasDiscoveryLabels = (labels: Record<string, string>) =>
Object.values(dockerLabels).some((label) => label !== dockerLabels.hide && label in labels) ||
Object.values(homepageLabels).some((label) => label in labels);
export const listDiscoveredContainersAsync = async (
options: ParseContainerLabelsOptions = {},
): Promise<DiscoveredService[]> => {
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containers = await instance.listContainers({ all: true });
return containers
.filter((container) => hasDiscoveryLabels(container.Labels ?? {}))
.map((container) => parseContainerLabels(container, host, options))
.filter((service): service is DiscoveredService => service !== null);
}),
);
return results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
logger.warn(
new ErrorWithMetadata(
"Failed to list containers for discovery",
{ host: dockerInstances[index]?.host },
{ cause: result.reason },
),
);
return [];
});
};
@@ -0,0 +1,116 @@
import type { ContainerInfo } from "dockerode";
import { integrationKinds, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { dockerLabels, homepageLabels } from "../labels";
import type { DiscoveredService } from "./types";
export interface ParseContainerLabelsOptions {
readHomepageLabels?: boolean;
}
const homarrLabelKeys = {
group: dockerLabels.group,
name: dockerLabels.name,
href: dockerLabels.href,
icon: dockerLabels.icon,
description: dockerLabels.description,
ping: dockerLabels.ping,
id: dockerLabels.id,
board: dockerLabels.board,
integration: dockerLabels.integration,
widget: dockerLabels.widget,
} as const;
const homepageLabelKeys = {
group: homepageLabels.group,
name: homepageLabels.name,
href: homepageLabels.href,
icon: homepageLabels.icon,
description: homepageLabels.description,
} as const;
const integrationKindSet = new Set<string>(integrationKinds);
const widgetKindSet = new Set<string>(widgetKinds);
const readLabel = (labels: Record<string, string>, key: string) => {
const value = labels[key]?.trim();
if (!value) {
return undefined;
}
return value;
};
const resolveLabelValue = (
labels: Record<string, string>,
homarrKey: string,
homepageKey: string,
useHomepageFallback: boolean,
) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined);
export const parseContainerLabels = (
container: Pick<ContainerInfo, "Id" | "Labels">,
host: string,
options: ParseContainerLabelsOptions = {},
): DiscoveredService | null => {
const labels = container.Labels ?? {};
if (dockerLabels.hide in labels) {
return null;
}
const readHomepageLabels = options.readHomepageLabels ?? true;
const hasHomarrName = Boolean(readLabel(labels, dockerLabels.name));
const useHomepageFallback = readHomepageLabels && !hasHomarrName;
const name = resolveLabelValue(labels, homarrLabelKeys.name, homepageLabelKeys.name, useHomepageFallback);
const href = resolveLabelValue(labels, homarrLabelKeys.href, homepageLabelKeys.href, useHomepageFallback);
const group = resolveLabelValue(labels, homarrLabelKeys.group, homepageLabelKeys.group, useHomepageFallback);
if (!name || !href || !group) {
return null;
}
const icon =
readLabel(labels, homarrLabelKeys.icon) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.icon) : undefined);
const description =
readLabel(labels, homarrLabelKeys.description) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.description) : undefined);
const pingUrl = readLabel(labels, homarrLabelKeys.ping);
const boardName = readLabel(labels, homarrLabelKeys.board);
const externalId = readLabel(labels, homarrLabelKeys.id) ?? container.Id;
const integrationKind = parseIntegrationKind(readLabel(labels, homarrLabelKeys.integration));
const widgetKind = parseWidgetKind(readLabel(labels, homarrLabelKeys.widget));
return {
containerId: container.Id,
host,
group,
name,
href,
icon,
description,
pingUrl,
externalId,
boardName,
integrationKind,
widgetKind,
};
};
const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined => {
if (!value || !integrationKindSet.has(value)) {
return undefined;
}
return value as IntegrationKind;
};
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined => {
if (!value || !widgetKindSet.has(value)) {
return undefined;
}
return value as WidgetKind;
};
@@ -0,0 +1,471 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { emptySuperJSON } from "@homarr/definitions";
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
import { and, asc, db, eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { apps, boards, dockerAppSources, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscoverySync" });
export interface SyncDiscoveredServicesOptions {
targetBoardName?: string | null;
enableStatusByDefault?: boolean;
forceDisableStatus?: boolean;
defaultItemWidth?: number;
defaultItemHeight?: number;
}
export interface SyncDiscoveredServicesResult {
created: number;
updated: number;
skipped: number;
}
interface SyncMetadata {
appId: string;
itemId?: string;
}
interface AppItemOptions {
appId: string;
openInNewTab: boolean;
showTitle: boolean;
descriptionDisplayMode: "hidden";
layout: "column";
pingEnabled: boolean;
}
type AppRow = InferSelectModel<typeof apps>;
type BoardWithRelations = NonNullable<Awaited<ReturnType<typeof loadBoardsWithRelationsAsync>>>[number];
type BoardItem = BoardWithRelations["items"][number];
const createSyncKey = (service: DiscoveredService) => `${service.host}/${service.containerId}`;
const appsMatchByHref = (app: Pick<AppRow, "href">, href: string) => app.href === href;
export const syncDiscoveredServicesAsync = async (
discoveredServices: DiscoveredService[],
options: SyncDiscoveredServicesOptions = {},
): Promise<SyncDiscoveredServicesResult> => {
const itemWidth = options.defaultItemWidth ?? 1;
const itemHeight = options.defaultItemHeight ?? 1;
const result: SyncDiscoveredServicesResult = { created: 0, updated: 0, skipped: 0 };
const syncMetadata = new Map<string, SyncMetadata>();
const existingApps = await db.query.apps.findMany();
const existingSources = await db.query.dockerAppSources.findMany();
const boardList = await loadBoardsWithRelationsAsync();
const appsToInsert: InferInsertModel<typeof apps>[] = [];
const appsToUpdate: { id: string; values: Partial<InferInsertModel<typeof apps>> }[] = [];
const sectionsToInsert: InferInsertModel<typeof sections>[] = [];
const itemsToInsert: InferInsertModel<typeof items>[] = [];
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const pendingSectionsByBoard = new Map<string, InferInsertModel<typeof sections>[]>();
const sectionYOffsetByBoard = new Map<string, number>();
const itemYOffsetBySection = new Map<string, number>();
const pendingItemLayouts: InferInsertModel<typeof itemLayouts>[] = [];
for (const service of discoveredServices) {
const syncKey = createSyncKey(service);
if (syncMetadata.has(syncKey)) {
result.skipped += 1;
continue;
}
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) {
logger.warn("No target board found for discovered service", { syncKey, boardName: service.boardName });
result.skipped += 1;
continue;
}
const firstLayout = getFirstLayout(board.layouts);
if (!firstLayout) {
logger.warn("Board has no layouts for discovered service", { boardId: board.id, syncKey });
result.skipped += 1;
continue;
}
const existingSource = existingSources.find(
(source) => source.host === service.host && source.externalId === service.externalId,
);
const existingApp = existingSource
? existingApps.find((app) => app.id === existingSource.appId)
: existingApps.find((app) => appsMatchByHref(app, service.href));
let appId: string;
let wasCreated = false;
let wasUpdated = false;
if (existingApp) {
appId = existingApp.id;
const updateValues = buildAppUpdateValues(service, existingApp);
if (updateValues) {
appsToUpdate.push({ id: appId, values: updateValues });
wasUpdated = true;
Object.assign(existingApp, updateValues);
}
} else {
appId = createId();
const newApp = buildAppInsert(service, appId);
appsToInsert.push(newApp);
existingApps.push(newApp as AppRow);
wasCreated = true;
}
const categorySection = findOrCreateCategorySection(
board,
service.group,
sectionsToInsert,
pendingSectionsByBoard,
sectionYOffsetByBoard,
);
const existingItem = findAppItemForApp(board.items, itemsToInsert, pendingItemLayouts, appId, categorySection.id);
if (!existingItem) {
const newItemId = createId();
const newItemLayouts = board.layouts.map((layout) => ({
itemId: newItemId,
sectionId: categorySection.id,
layoutId: layout.id,
xOffset: 0,
yOffset: getNextItemYOffset(
board,
categorySection.id,
layout.id,
itemYOffsetBySection,
pendingItemLayouts,
itemHeight,
),
width: itemWidth,
height: itemHeight,
}));
itemsToInsert.push({
id: newItemId,
boardId: board.id,
kind: "app",
options: SuperJSON.stringify(buildAppItemOptions(appId, service, options)),
advancedOptions: SuperJSON.stringify({
title: null,
customCssClasses: [],
borderColor: "",
}),
});
itemLayoutsToInsert.push(...newItemLayouts);
pendingItemLayouts.push(...newItemLayouts);
syncMetadata.set(syncKey, { appId, itemId: newItemId });
wasCreated = true;
} else {
syncMetadata.set(syncKey, { appId, itemId: existingItem.id });
}
if (wasCreated) {
result.created += 1;
} else if (wasUpdated) {
result.updated += 1;
} else {
result.skipped += 1;
}
}
const sourcesToUpsert: InferInsertModel<typeof dockerAppSources>[] = [];
for (const [syncKey, meta] of syncMetadata.entries()) {
const service = discoveredServices.find((svc) => createSyncKey(svc) === syncKey);
if (!service) continue;
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) continue;
sourcesToUpsert.push({
host: service.host,
containerId: service.containerId,
externalId: service.externalId,
appId: meta.appId,
boardId: board.id,
itemId: meta.itemId ?? null,
});
}
if (
appsToInsert.length === 0 &&
appsToUpdate.length === 0 &&
sectionsToInsert.length === 0 &&
itemsToInsert.length === 0 &&
sourcesToUpsert.length === 0
) {
return result;
}
const insertCollection = createDbInsertCollectionForTransaction(["apps", "sections", "items", "itemLayouts"]);
insertCollection.apps.push(...appsToInsert);
insertCollection.sections.push(...sectionsToInsert);
insertCollection.items.push(...itemsToInsert);
insertCollection.itemLayouts.push(...itemLayoutsToInsert);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
for (const { id, values } of appsToUpdate) {
await innerDb
.update(apps as never)
.set(values)
.where(eq(apps.id, id));
}
await insertCollection.insertAllAsync(innerDb);
const table = dockerAppSources as never;
for (const source of sourcesToUpsert) {
const existing = await innerDb.query.dockerAppSources.findFirst({
where: and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)),
});
if (existing) {
await innerDb
.update(table)
.set({
appId: source.appId,
boardId: source.boardId,
itemId: source.itemId,
externalId: source.externalId,
updatedAt: new Date(),
})
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)));
} else {
await innerDb.insert(table).values(source as never);
}
}
},
handleSync(innerDb) {
for (const { id, values } of appsToUpdate) {
innerDb
.update(apps as never)
.set(values)
.where(eq(apps.id, id))
.run();
}
insertCollection.insertAll(innerDb);
for (const source of sourcesToUpsert) {
try {
innerDb.insert(dockerAppSources).values(source).run();
} catch {
innerDb
.update(dockerAppSources)
.set({
appId: source.appId,
boardId: source.boardId,
itemId: source.itemId,
externalId: source.externalId,
updatedAt: new Date(),
})
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)))
.run();
}
}
},
});
return result;
};
const loadBoardsWithRelationsAsync = async () =>
db.query.boards.findMany({
orderBy: asc(boards.name),
with: {
layouts: true,
sections: true,
items: {
with: {
layouts: true,
},
},
},
});
const resolveTargetBoard = (
boardList: BoardWithRelations[],
service: DiscoveredService,
defaultBoardName: string | null | undefined,
) => {
const boardName = service.boardName ?? defaultBoardName ?? undefined;
if (boardName) {
return boardList.find((board) => board.name === boardName) ?? boardList.at(0);
}
return boardList.at(0);
};
const getFirstLayout = (boardLayouts: InferSelectModel<typeof layouts>[]) =>
[...boardLayouts].sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint).at(0);
const buildAppInsert = (service: DiscoveredService, appId: string): InferInsertModel<typeof apps> => ({
id: appId,
name: service.name,
description: service.description ?? null,
iconUrl: service.icon ?? "",
href: service.href,
pingUrl: service.pingUrl ?? null,
});
const buildAppUpdateValues = (
service: DiscoveredService,
existingApp: AppRow,
): Partial<InferInsertModel<typeof apps>> | null => {
const values: Partial<InferInsertModel<typeof apps>> = {};
if (existingApp.name !== service.name) {
values.name = service.name;
}
if (existingApp.href !== service.href) {
values.href = service.href;
}
if (service.icon !== undefined && existingApp.iconUrl !== service.icon) {
values.iconUrl = service.icon;
}
if (service.description !== undefined && existingApp.description !== service.description) {
values.description = service.description;
}
if (service.pingUrl !== undefined && existingApp.pingUrl !== service.pingUrl) {
values.pingUrl = service.pingUrl;
}
return Object.keys(values).length > 0 ? values : null;
};
const buildAppItemOptions = (
appId: string,
service: DiscoveredService,
options: SyncDiscoveredServicesOptions,
): AppItemOptions => ({
appId,
openInNewTab: true,
showTitle: true,
descriptionDisplayMode: "hidden",
layout: "column",
pingEnabled:
!(options.forceDisableStatus ?? false) && (Boolean(service.pingUrl) || (options.enableStatusByDefault ?? true)),
});
const findOrCreateCategorySection = (
board: BoardWithRelations,
groupName: string,
sectionsToInsert: InferInsertModel<typeof sections>[],
pendingSectionsByBoard: Map<string, InferInsertModel<typeof sections>[]>,
sectionYOffsetByBoard: Map<string, number>,
): InferInsertModel<typeof sections> & { id: string } => {
const pendingForBoard = pendingSectionsByBoard.get(board.id) ?? [];
const pendingMatch = pendingForBoard.find((section) => section.kind === "category" && section.name === groupName);
if (pendingMatch) {
return pendingMatch as InferInsertModel<typeof sections> & { id: string };
}
const existingSection = board.sections.find((section) => section.kind === "category" && section.name === groupName);
if (existingSection) {
return existingSection;
}
const categorySections = board.sections.filter((section) => section.kind === "category");
const pendingCategorySections = pendingForBoard.filter((section) => section.kind === "category");
const maxYOffset = Math.max(
0,
...categorySections.map((section) => section.yOffset ?? 0),
...pendingCategorySections.map((section) => section.yOffset ?? 0),
sectionYOffsetByBoard.get(board.id) ?? 0,
);
const yOffset = maxYOffset + 1;
sectionYOffsetByBoard.set(board.id, yOffset);
const newSection: InferInsertModel<typeof sections> = {
id: createId(),
boardId: board.id,
kind: "category",
xOffset: 0,
yOffset,
name: groupName,
options: emptySuperJSON,
};
sectionsToInsert.push(newSection);
board.sections.push(newSection as BoardWithRelations["sections"][number]);
pendingSectionsByBoard.set(board.id, [...pendingForBoard, newSection]);
return newSection as InferInsertModel<typeof sections> & { id: string };
};
const findAppItemForApp = (
boardItems: BoardItem[],
pendingItems: InferInsertModel<typeof items>[],
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
appId: string,
sectionId: string,
) => {
const dbMatch = boardItems.find((item) => itemMatchesAppInSection(item, appId, sectionId));
if (dbMatch) {
return dbMatch;
}
const pendingMatch = pendingItems.find((item) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options ?? emptySuperJSON);
if (itemOptions.appId !== appId) {
return false;
}
return pendingLayouts.some((layout) => layout.itemId === item.id && layout.sectionId === sectionId);
});
return pendingMatch ? { id: pendingMatch.id } : undefined;
};
const itemMatchesAppInSection = (item: BoardItem, appId: string, sectionId: string) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options);
if (itemOptions.appId !== appId) {
return false;
}
return item.layouts.some((layout) => layout.sectionId === sectionId);
};
const getNextItemYOffset = (
board: BoardWithRelations,
sectionId: string,
layoutId: string,
itemYOffsetBySection: Map<string, number>,
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
itemHeight: number,
) => {
const sectionKey = `${board.id}/${sectionId}/${layoutId}`;
const trackedYOffset = itemYOffsetBySection.get(sectionKey);
if (trackedYOffset !== undefined) {
const nextYOffset = trackedYOffset + itemHeight;
itemYOffsetBySection.set(sectionKey, nextYOffset);
return trackedYOffset;
}
const dbLayouts = board.items.flatMap((item) =>
item.layouts.filter((layout) => layout.sectionId === sectionId && layout.layoutId === layoutId),
);
const pendingSectionLayouts = pendingLayouts.filter(
(layout) => layout.sectionId === sectionId && layout.layoutId === layoutId,
);
const maxYOffset = [...dbLayouts, ...pendingSectionLayouts].reduce(
(max, layout) => Math.max(max, layout.yOffset + layout.height),
0,
);
itemYOffsetBySection.set(sectionKey, maxYOffset + itemHeight);
return maxYOffset;
};
+16
View File
@@ -0,0 +1,16 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
export interface DiscoveredService {
containerId: string;
host: string;
group: string;
name: string;
href: string;
icon?: string;
description?: string;
pingUrl?: string;
externalId: string;
boardName?: string;
integrationKind?: IntegrationKind;
widgetKind?: WidgetKind;
}
+1
View File
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
export type ContainerState = (typeof containerStates)[number];
export * from "./labels";
export * from "./discovery";
+18 -1
View File
@@ -1,4 +1,21 @@
export const dockerLabels = {
// Label to hide a container from Homarrs docker integration
hide: "homarr.hide",
group: "homarr.group",
name: "homarr.name",
href: "homarr.href",
icon: "homarr.icon",
description: "homarr.description",
ping: "homarr.ping",
id: "homarr.id",
board: "homarr.board",
integration: "homarr.integration",
widget: "homarr.widget",
} as const;
export const homepageLabels = {
group: "homepage.group",
name: "homepage.name",
href: "homepage.href",
icon: "homepage.icon",
description: "homepage.description",
} as const;
+9
View File
@@ -5,6 +5,7 @@ export const defaultServerSettingsKeys = [
"analytics",
"crawlingAndIndexing",
"board",
"docker",
"user",
"appearance",
"culture",
@@ -30,6 +31,14 @@ export const defaultServerSettings = {
enableStatusByDefault: true,
forceDisableStatus: false,
},
docker: {
targetBoardName: null as string | null,
readHomepageLabels: true,
pruneRemoved: false,
createIntegrations: true,
defaultItemWidth: 1,
defaultItemHeight: 1,
},
user: {
enableGravatar: true,
},
+12 -3
View File
@@ -1769,9 +1769,18 @@ importers:
'@homarr/core':
specifier: workspace:^0.1.0
version: link:../core
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
dockerode:
specifier: 'catalog:'
version: 5.0.1
superjson:
specifier: 'catalog:'
version: 2.2.6
devDependencies:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
@@ -1782,6 +1791,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 6.0.3
vitest:
specifier: 'catalog:'
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
packages/form:
dependencies:
@@ -21785,7 +21797,6 @@ snapshots:
optionalDependencies:
msw: 2.14.6(@types/node@24.13.2)(typescript@6.0.3)
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
optional: true
'@vitest/pretty-format@4.1.9':
dependencies:
@@ -29630,7 +29641,6 @@ snapshots:
terser: 5.46.1
tsx: 4.21.0
yaml: 2.9.0
optional: true
vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
@@ -29693,7 +29703,6 @@ snapshots:
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
optional: true
vue-component-type-helpers@3.3.4: {}