feat: rebuild onboarding experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:11 +02:00
1 parent d091362db2
commit e41605640b
249 files changed
+8216 -14060

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
export type { DiscoveredService, DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types";
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const listContainersA = vi.fn();
const listContainersB = vi.fn();
vi.mock("../singleton", () => ({
DockerSingleton: {
getInstances: () => [
{ host: "good", instance: { listContainers: listContainersA } },
{ host: "bad", instance: { listContainers: listContainersB } },
],
},
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ warn: vi.fn() }),
}));
import { listDiscoveredContainersAsync } from "./list-discovered-containers";
describe("listDiscoveredContainersAsync", () => {
beforeEach(() => {
listContainersA.mockReset();
listContainersB.mockReset();
});
it("preserves successful hosts when another Docker host fails", async () => {
listContainersA.mockResolvedValue([
{
Id: "sonarr",
Labels: {
"homarr.name": "Sonarr",
"homarr.group": "Media",
"homarr.href": "http://sonarr:8989",
},
},
]);
listContainersB.mockRejectedValue(new Error("permission denied"));
const result = await listDiscoveredContainersAsync();
expect(result.services).toHaveLength(1);
expect(result.hosts).toEqual([
expect.objectContaining({ host: "good", status: "success" }),
{
host: "bad",
status: "unavailable",
reason: "permission denied",
containers: [],
services: [],
},
]);
});
it("times out a blackholed host without losing another host's result", async () => {
vi.useFakeTimers();
try {
listContainersA.mockResolvedValue([
{
Id: "sonarr",
Labels: {
"homarr.name": "Sonarr",
"homarr.group": "Media",
"homarr.href": "http://sonarr:8989",
},
},
]);
listContainersB.mockReturnValue(new Promise(() => undefined));
const resultPromise = listDiscoveredContainersAsync({}, 25);
await vi.advanceTimersByTimeAsync(25);
const result = await resultPromise;
expect(result.services).toHaveLength(1);
expect(result.hosts).toEqual([
expect.objectContaining({ host: "good", status: "success" }),
expect.objectContaining({ host: "bad", status: "unavailable", reason: "Docker discovery timed out for bad" }),
]);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,64 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { DockerSingleton } from "../singleton";
import type { ParseContainerLabelsOptions } from "./parse-container-labels";
import { parseContainerLabels } from "./parse-container-labels";
import type { DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types";
const logger = createLogger({ module: "dockerDiscovery" });
export const dockerDiscoveryHostTimeoutMs = 5_000;
const withTimeoutAsync = async <T>(promise: Promise<T>, timeoutMs: number, host: string) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(`Docker discovery timed out for ${host}`)), timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
};
const reasonFromError = (error: unknown) => {
if (error instanceof Error && error.message.trim()) return error.message;
return "Docker is not reachable";
};
export const listDiscoveredContainersAsync = async (
options: ParseContainerLabelsOptions = {},
timeoutMs = dockerDiscoveryHostTimeoutMs,
): Promise<DockerDiscoveryResult> => {
const dockerInstances = DockerSingleton.getInstances();
const settled = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }): Promise<DockerDiscoveryHostResult> => {
const containers = await withTimeoutAsync(instance.listContainers({ all: false }), timeoutMs, host);
const services = containers
.map((container) => parseContainerLabels(container, host, options))
.filter((service) => service !== null);
return { host, status: "success", containers, services };
}),
);
const hosts = settled.map<DockerDiscoveryHostResult>((result, index) => {
const host = dockerInstances[index]?.host ?? "unknown";
if (result.status === "fulfilled") return result.value;
logger.warn(new ErrorWithMetadata("Failed to list containers for discovery", { host }, { cause: result.reason }));
return {
host,
status: "unavailable",
reason: reasonFromError(result.reason),
containers: [],
services: [],
};
});
return {
hosts,
services: hosts.flatMap((result) => result.services),
};
};
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { parseContainerLabels } from "./parse-container-labels";
const container = (Labels: Record<string, string>, Id = "container-1") => ({ Id, Labels });
describe("parseContainerLabels", () => {
it("parses complete Homarr labels", () => {
expect(
parseContainerLabels(
container({
"homarr.name": "Sonarr",
"homarr.group": "Media",
"homarr.href": "http://sonarr:8989",
"homarr.icon": "sonarr.svg",
"homarr.description": "TV",
"homarr.ping": "http://sonarr:8989/ping",
"homarr.id": "sonarr-main",
"homarr.board": "dashboard",
"homarr.integration": "sonarr",
"homarr.widget": "calendar",
}),
"socket",
),
).toEqual({
sourceId: "docker:socket:sonarr-main",
containerId: "container-1",
host: "socket",
group: "Media",
name: "Sonarr",
href: "http://sonarr:8989",
icon: "sonarr.svg",
description: "TV",
pingUrl: "http://sonarr:8989/ping",
externalId: "sonarr-main",
boardName: "dashboard",
integrationKind: "sonarr",
widgetKind: "calendar",
});
});
it("supports Homepage labels when Homarr labels are absent", () => {
expect(
parseContainerLabels(
container({
"homepage.name": "Jellyfin",
"homepage.group": "Media",
"homepage.href": "http://jellyfin:8096",
"homepage.icon": "jellyfin.svg",
}),
"remote:2375",
),
).toMatchObject({
sourceId: "docker:remote:2375:container-1",
name: "Jellyfin",
group: "Media",
href: "http://jellyfin:8096",
icon: "jellyfin.svg",
});
});
it("keeps a labeled service without a group for root placement", () => {
expect(
parseContainerLabels(
container({
"homarr.name": "Status",
"homarr.href": "http://status:3001",
"homarr.widget": "clock",
}),
"socket",
),
).toMatchObject({ name: "Status", href: "http://status:3001", group: undefined, widgetKind: "clock" });
});
it("does not mix Homepage required fields into an explicitly Homarr-labeled service", () => {
expect(
parseContainerLabels(
container({
"homarr.name": "Sonarr",
"homepage.group": "Media",
"homepage.href": "http://sonarr:8989",
}),
"socket",
),
).toBeNull();
});
it("can disable Homepage fallback", () => {
expect(
parseContainerLabels(
container({
"homepage.name": "Sonarr",
"homepage.group": "Media",
"homepage.href": "http://sonarr:8989",
}),
"socket",
{ readHomepageLabels: false },
),
).toBeNull();
});
it("ignores hidden and incomplete containers", () => {
expect(
parseContainerLabels(
container({
"homarr.hide": "true",
"homarr.name": "Sonarr",
"homarr.group": "Media",
"homarr.href": "http://sonarr:8989",
}),
"socket",
),
).toBeNull();
expect(parseContainerLabels(container({ "homarr.name": "Sonarr" }), "socket")).toBeNull();
});
it("trims values and ignores invalid integration and widget kinds", () => {
expect(
parseContainerLabels(
container({
"homarr.name": " Sonarr ",
"homarr.group": " Media ",
"homarr.href": " http://sonarr:8989 ",
"homarr.integration": "not-real",
"homarr.widget": "not-real",
}),
"socket",
),
).toMatchObject({
name: "Sonarr",
group: "Media",
href: "http://sonarr:8989",
integrationKind: undefined,
widgetKind: undefined,
});
});
});
@@ -0,0 +1,65 @@
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 integrationKindSet = new Set<string>(integrationKinds);
const widgetKindSet = new Set<string>(widgetKinds);
const readLabel = (labels: Record<string, string>, key: string) => {
const value = labels[key]?.trim();
return value ? value : undefined;
};
const resolveLabelValue = (
labels: Record<string, string>,
homarrKey: string,
homepageKey: string,
useHomepageFallback: boolean,
) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined);
const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined =>
value && integrationKindSet.has(value) ? (value as IntegrationKind) : undefined;
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined =>
value && widgetKindSet.has(value) ? (value as WidgetKind) : 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 useHomepageFallback = readHomepageLabels && !readLabel(labels, dockerLabels.name);
const name = resolveLabelValue(labels, dockerLabels.name, homepageLabels.name, useHomepageFallback);
const href = resolveLabelValue(labels, dockerLabels.href, homepageLabels.href, useHomepageFallback);
const group = resolveLabelValue(labels, dockerLabels.group, homepageLabels.group, useHomepageFallback);
if (!name || !href) return null;
const externalId = readLabel(labels, dockerLabels.id) ?? container.Id;
return {
sourceId: `docker:${host}:${externalId}`,
containerId: container.Id,
host,
group,
name,
href,
icon: resolveLabelValue(labels, dockerLabels.icon, homepageLabels.icon, useHomepageFallback),
description: resolveLabelValue(labels, dockerLabels.description, homepageLabels.description, useHomepageFallback),
pingUrl: readLabel(labels, dockerLabels.ping),
externalId,
boardName: readLabel(labels, dockerLabels.board),
integrationKind: parseIntegrationKind(readLabel(labels, dockerLabels.integration)),
widgetKind: parseWidgetKind(readLabel(labels, dockerLabels.widget)),
};
};
+39
View File
@@ -0,0 +1,39 @@
import type { ContainerInfo } from "dockerode";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
export interface DiscoveredService {
sourceId: string;
containerId: string;
host: string;
group?: string;
name: string;
href: string;
icon?: string;
description?: string;
pingUrl?: string;
externalId: string;
boardName?: string;
integrationKind?: IntegrationKind;
widgetKind?: WidgetKind;
}
export type DockerDiscoveryHostResult =
| {
host: string;
status: "success";
containers: ContainerInfo[];
services: DiscoveredService[];
}
| {
host: string;
status: "unavailable";
reason: string;
containers: [];
services: [];
};
export interface DockerDiscoveryResult {
hosts: DockerDiscoveryHostResult[];
services: DiscoveredService[];
}
+1
View File
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
export type ContainerState = (typeof containerStates)[number];
export * from "./labels";
export * from "./discovery";
+18 -1
View File
@@ -1,4 +1,21 @@
export const dockerLabels = {
// Label to hide a container from Homarrs docker integration
hide: "homarr.hide",
group: "homarr.group",
name: "homarr.name",
href: "homarr.href",
icon: "homarr.icon",
description: "homarr.description",
ping: "homarr.ping",
id: "homarr.id",
board: "homarr.board",
integration: "homarr.integration",
widget: "homarr.widget",
} as const;
export const homepageLabels = {
group: "homepage.group",
name: "homepage.name",
href: "homepage.href",
icon: "homepage.icon",
description: "homepage.description",
} as const;