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
|
### 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
|
### Security
|
||||||
Mounting docker sockets can be risky, as they permit full control over your docker service.
|
Mounting docker sockets can be risky, as they permit full control over your docker service.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ interface IntegrationDraft {
|
|||||||
initialUrl: string;
|
initialUrl: string;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
dockerPort?: number | null;
|
dockerPort?: number | null;
|
||||||
source: "manual" | "docker";
|
source: "manual" | "docker" | "label";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InitIntegrations = () => {
|
export const InitIntegrations = () => {
|
||||||
@@ -65,6 +65,7 @@ export const InitIntegrations = () => {
|
|||||||
|
|
||||||
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
||||||
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
||||||
|
const { mutateAsync: syncLabelApps } = clientApi.onboard.syncDockerLabelApps.useMutation();
|
||||||
|
|
||||||
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -98,7 +99,7 @@ export const InitIntegrations = () => {
|
|||||||
initialUrl: item.suggestedUrl,
|
initialUrl: item.suggestedUrl,
|
||||||
initialName: item.containerName,
|
initialName: item.containerName,
|
||||||
dockerPort: item.publishedPort,
|
dockerPort: item.publishedPort,
|
||||||
source: "docker",
|
source: item.source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
newKinds.add(item.kind);
|
newKinds.add(item.kind);
|
||||||
@@ -132,7 +133,8 @@ export const InitIntegrations = () => {
|
|||||||
const newDrafts = new Map(drafts);
|
const newDrafts = new Map(drafts);
|
||||||
for (const kind of selectedKinds) {
|
for (const kind of selectedKinds) {
|
||||||
const existing = newDrafts.get(kind);
|
const existing = newDrafts.get(kind);
|
||||||
if (baseHost) {
|
const useUrlTemplate = baseHost && existing?.source !== "label";
|
||||||
|
if (useUrlTemplate) {
|
||||||
newDrafts.set(kind, {
|
newDrafts.set(kind, {
|
||||||
kind,
|
kind,
|
||||||
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
||||||
@@ -152,10 +154,32 @@ export const InitIntegrations = () => {
|
|||||||
const finishSetupAsync = async () => {
|
const finishSetupAsync = async () => {
|
||||||
setPhase("done");
|
setPhase("done");
|
||||||
try {
|
try {
|
||||||
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
const selectedApps = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
||||||
if (appsToCreate.length > 0) {
|
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(
|
await createApps(
|
||||||
appsToCreate.map((app) => {
|
imageApps.map((app) => {
|
||||||
const href = baseHost
|
const href = baseHost
|
||||||
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
||||||
: app.suggestedUrl;
|
: app.suggestedUrl;
|
||||||
@@ -167,6 +191,7 @@ export const InitIntegrations = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await setupIntegrations();
|
await setupIntegrations();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -336,9 +361,16 @@ export const InitIntegrations = () => {
|
|||||||
>
|
>
|
||||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
{app.containerName}
|
<Text size="xs" fw={500} lineClamp={1}>
|
||||||
</Text>
|
{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)" />}
|
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||||
</Group>
|
</Group>
|
||||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||||
@@ -359,8 +391,14 @@ export const InitIntegrations = () => {
|
|||||||
{t("action.skip")}
|
{t("action.skip")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleContinueToConfig}
|
onClick={() => {
|
||||||
disabled={selectedKinds.length === 0 || undefined}
|
if (selectedKinds.length === 0) {
|
||||||
|
void finishSetupAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleContinueToConfig();
|
||||||
|
}}
|
||||||
|
disabled={(selectedKinds.length === 0 && selectedAppCount === 0) || undefined}
|
||||||
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
@@ -404,7 +442,8 @@ const ConfigurePhase = ({
|
|||||||
const currentKind = selectedKinds[currentIndex]!;
|
const currentKind = selectedKinds[currentIndex]!;
|
||||||
const draft = drafts.get(currentKind);
|
const draft = drafts.get(currentKind);
|
||||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
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)
|
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
||||||
: draft?.initialUrl;
|
: draft?.initialUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import { encryptSecret } from "@homarr/common/server";
|
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 { placeAllWidgetsAsync } from "@homarr/db/queries";
|
||||||
|
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||||
import {
|
import {
|
||||||
apps,
|
apps,
|
||||||
|
boards,
|
||||||
groups,
|
groups,
|
||||||
icons,
|
icons,
|
||||||
integrationItems,
|
integrationItems,
|
||||||
@@ -33,6 +36,8 @@ import { MissingSecretError, testConnectionAsync } from "../integration/integrat
|
|||||||
import { mapTestConnectionError } from "../integration/map-test-connection-error";
|
import { mapTestConnectionError } from "../integration/map-test-connection-error";
|
||||||
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
|
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "onboardDockerDiscovery" });
|
||||||
|
|
||||||
interface PortInfo {
|
interface PortInfo {
|
||||||
IP?: string;
|
IP?: string;
|
||||||
PublicPort?: number;
|
PublicPort?: number;
|
||||||
@@ -43,6 +48,30 @@ interface SuggestedUrlResult {
|
|||||||
publishedPort: number | null;
|
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 buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
|
||||||
const port = ports?.[0];
|
const port = ports?.[0];
|
||||||
if (port?.PublicPort) {
|
if (port?.PublicPort) {
|
||||||
@@ -145,51 +174,91 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
|
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
|
||||||
const emptyResult = {
|
const emptyResult = {
|
||||||
integrations: [] as {
|
integrations: [] as DiscoveredIntegration[],
|
||||||
containerId: string;
|
apps: [] as DiscoveredApp[],
|
||||||
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;
|
|
||||||
}[],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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();
|
const dockerInstances = DockerSingleton.getInstances();
|
||||||
if (dockerInstances.length === 0) {
|
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(
|
const results = await Promise.allSettled(
|
||||||
dockerInstances.map(async ({ instance, host }) => {
|
dockerInstances.map(async ({ instance, host }) => {
|
||||||
const containerList = await instance.listContainers({ all: false });
|
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 };
|
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 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) =>
|
const cdnIconUrl = (slug: string) =>
|
||||||
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
|
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
|
||||||
|
|
||||||
@@ -223,6 +292,7 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
suggestedUrl,
|
suggestedUrl,
|
||||||
publishedPort,
|
publishedPort,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
|
source: "docker",
|
||||||
});
|
});
|
||||||
} else if (!kind && dbIcon) {
|
} else if (!kind && dbIcon) {
|
||||||
discoveredApps.push({
|
discoveredApps.push({
|
||||||
@@ -231,19 +301,61 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
suggestedUrl,
|
suggestedUrl,
|
||||||
publishedPort,
|
publishedPort,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
|
source: "docker",
|
||||||
|
host: container.host,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
|
||||||
return {
|
return {
|
||||||
status: "success" as const,
|
status: hasResults ? ("success" as const) : ("empty" as const),
|
||||||
integrations: discoveredIntegrations,
|
integrations: discoveredIntegrations,
|
||||||
apps: discoveredApps,
|
apps: discoveredApps,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.warn("Docker discovery failed during onboarding", { cause: error });
|
||||||
return { status: "unavailable" as const, ...emptyResult };
|
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
|
createAppsFromDiscovery: onboardingProcedure
|
||||||
.requiresStep("integrations")
|
.requiresStep("integrations")
|
||||||
.input(
|
.input(
|
||||||
@@ -257,9 +369,17 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (input.length === 0) return;
|
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";
|
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||||
await ctx.db.insert(apps).values(
|
await ctx.db.insert(apps).values(
|
||||||
input.map((app) => ({
|
appsToCreate.map((app) => ({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: app.name,
|
name: app.name,
|
||||||
iconUrl: app.iconUrl ?? defaultIcon,
|
iconUrl: app.iconUrl ?? defaultIcon,
|
||||||
@@ -270,16 +390,26 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
|
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
|
||||||
const db = ctx.db;
|
const db = ctx.db;
|
||||||
|
const serverSettings = await getServerSettingsAsync(db);
|
||||||
|
|
||||||
const allIntegrations = await db.query.integrations.findMany();
|
const allIntegrations = await db.query.integrations.findMany();
|
||||||
const allApps = await db.query.apps.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: {
|
with: {
|
||||||
sections: true,
|
sections: true,
|
||||||
layouts: 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) {
|
if (!board) {
|
||||||
await nextOnboardingStepAsync(db, undefined);
|
await nextOnboardingStepAsync(db, undefined);
|
||||||
@@ -297,15 +427,16 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
const layout = board.layouts[0];
|
const layout = board.layouts[0];
|
||||||
|
|
||||||
if (section && layout) {
|
if (section && layout) {
|
||||||
const existingItems = await db.query.items.findMany({
|
const emptySectionLayouts = await db.query.itemLayouts.findMany({
|
||||||
where: eq(items.boardId, board.id),
|
where: eq(itemLayouts.sectionId, section.id),
|
||||||
|
columns: { itemId: true },
|
||||||
});
|
});
|
||||||
for (const item of existingItems) {
|
const emptySectionItemIds = emptySectionLayouts.map((entry) => entry.itemId);
|
||||||
await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
|
|
||||||
await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
|
if (emptySectionItemIds.length > 0) {
|
||||||
}
|
await db.delete(integrationItems).where(inArray(integrationItems.itemId, emptySectionItemIds));
|
||||||
if (existingItems.length > 0) {
|
await db.delete(itemLayouts).where(inArray(itemLayouts.itemId, emptySectionItemIds));
|
||||||
await db.delete(items).where(eq(items.boardId, board.id));
|
await db.delete(items).where(inArray(items.id, emptySectionItemIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
await placeAllWidgetsAsync(
|
await placeAllWidgetsAsync(
|
||||||
@@ -317,7 +448,7 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
columnCount: layout.columnCount,
|
columnCount: layout.columnCount,
|
||||||
},
|
},
|
||||||
allIntegrations,
|
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,
|
"when": 1780764500000,
|
||||||
"tag": "0041_merge_custom_widget_url",
|
"tag": "0041_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1780764500000,
|
||||||
"tag": "0007_merge_custom_widget_url",
|
"tag": "0007_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1780764500000,
|
||||||
"tag": "0039_merge_custom_widget_url",
|
"tag": "0039_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1780764600000,
|
||||||
|
"tag": "0040_add_docker_app_source",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,7 @@ export const {
|
|||||||
cronJobConfigurations,
|
cronJobConfigurations,
|
||||||
customWidgetDefinitions,
|
customWidgetDefinitions,
|
||||||
customWidgetSecrets,
|
customWidgetSecrets,
|
||||||
|
dockerAppSources,
|
||||||
} = schema;
|
} = schema;
|
||||||
|
|
||||||
export type User = InferSelectModel<typeof schema.users>;
|
export type User = InferSelectModel<typeof schema.users>;
|
||||||
|
|||||||
@@ -547,6 +547,33 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
|
|||||||
isEnabled: boolean().default(true).notNull(),
|
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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
@@ -826,3 +853,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
|
|||||||
references: [customWidgetDefinitions.id],
|
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(),
|
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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
@@ -825,3 +852,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
|
|||||||
references: [customWidgetDefinitions.id],
|
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(),
|
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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
@@ -815,3 +842,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
|
|||||||
references: [customWidgetDefinitions.id],
|
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": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/core": "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": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/dockerode": "catalog:",
|
"@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 type ContainerState = (typeof containerStates)[number];
|
||||||
export * from "./labels";
|
export * from "./labels";
|
||||||
|
export * from "./discovery";
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
export const dockerLabels = {
|
export const dockerLabels = {
|
||||||
// Label to hide a container from Homarrs docker integration
|
|
||||||
hide: "homarr.hide",
|
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;
|
} as const;
|
||||||
@@ -5,6 +5,7 @@ export const defaultServerSettingsKeys = [
|
|||||||
"analytics",
|
"analytics",
|
||||||
"crawlingAndIndexing",
|
"crawlingAndIndexing",
|
||||||
"board",
|
"board",
|
||||||
|
"docker",
|
||||||
"user",
|
"user",
|
||||||
"appearance",
|
"appearance",
|
||||||
"culture",
|
"culture",
|
||||||
@@ -30,6 +31,14 @@ export const defaultServerSettings = {
|
|||||||
enableStatusByDefault: true,
|
enableStatusByDefault: true,
|
||||||
forceDisableStatus: false,
|
forceDisableStatus: false,
|
||||||
},
|
},
|
||||||
|
docker: {
|
||||||
|
targetBoardName: null as string | null,
|
||||||
|
readHomepageLabels: true,
|
||||||
|
pruneRemoved: false,
|
||||||
|
createIntegrations: true,
|
||||||
|
defaultItemWidth: 1,
|
||||||
|
defaultItemHeight: 1,
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
enableGravatar: true,
|
enableGravatar: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+12
-3
@@ -1769,9 +1769,18 @@ importers:
|
|||||||
'@homarr/core':
|
'@homarr/core':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../core
|
version: link:../core
|
||||||
|
'@homarr/db':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../db
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../definitions
|
||||||
dockerode:
|
dockerode:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
|
superjson:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 2.2.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@homarr/tsconfig':
|
'@homarr/tsconfig':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
@@ -1782,6 +1791,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 6.0.3
|
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:
|
packages/form:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -21785,7 +21797,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
msw: 2.14.6(@types/node@24.13.2)(typescript@6.0.3)
|
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)
|
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':
|
'@vitest/pretty-format@4.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -29630,7 +29641,6 @@ snapshots:
|
|||||||
terser: 5.46.1
|
terser: 5.46.1
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
yaml: 2.9.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)):
|
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:
|
dependencies:
|
||||||
@@ -29693,7 +29703,6 @@ snapshots:
|
|||||||
jsdom: 29.1.1(@noble/hashes@2.0.1)
|
jsdom: 29.1.1(@noble/hashes@2.0.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
optional: true
|
|
||||||
|
|
||||||
vue-component-type-helpers@3.3.4: {}
|
vue-component-type-helpers@3.3.4: {}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user