Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4040f28048 | ||
|
|
be3d522659 | ||
|
|
2dcd81676c | ||
|
|
cf655b8c8e | ||
|
|
05e7d8c529 |
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",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const {
|
||||
cronJobConfigurations,
|
||||
customWidgetDefinitions,
|
||||
customWidgetSecrets,
|
||||
dockerAppSources,
|
||||
} = schema;
|
||||
|
||||
export type User = InferSelectModel<typeof schema.users>;
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
|
||||
|
||||
export type ContainerState = (typeof containerStates)[number];
|
||||
export * from "./labels";
|
||||
export * from "./discovery";
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Generated
+12
-3
@@ -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: {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user