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

@@ -8,10 +8,24 @@ export type UrlTemplateMode = "hostPort" | "subdomain";
const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind];
const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => {
const normalizeBaseHost = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
return { hostname: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined };
} catch {
return null;
}
};
const buildUrl = (slug: string, rawHost: string, mode: UrlTemplateMode, port?: number): string => {
const base = normalizeBaseHost(rawHost);
if (!base) return "";
const effectivePort = port ?? base.port;
const modeBuilders: Record<UrlTemplateMode, () => string> = {
subdomain: () => `https://${slug}.${host}`,
hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`),
subdomain: () => (base.hostname.startsWith("[") ? "" : `https://${slug}.${base.hostname}`),
hostPort: () => (effectivePort ? `http://${base.hostname}:${effectivePort}` : `http://${base.hostname}`),
};
return modeBuilders[mode]();
};
@@ -23,7 +37,6 @@ export const buildIntegrationUrl = (
dockerPort?: number,
): string => {
const host = removeTrailingSlash(baseHost.trim());
if (!host) return "";
const port = dockerPort ?? getIntegrationDefaultPort(kind);
return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined);
};
@@ -35,7 +48,6 @@ export const buildAppUrl = (
dockerPort?: number,
): string => {
const host = removeTrailingSlash(baseHost.trim());
if (!host) return "";
const slug = containerName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
return buildUrl(slug, host, mode, dockerPort);
};
+26 -7
View File
@@ -222,6 +222,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"),
defaultPort: 5380,
},
homeAssistant: {
name: "Home Assistant",
@@ -230,6 +231,7 @@ export const integrationDefs = {
category: ["smartHomeServer", "calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"),
defaultPort: 8123,
apiKeySettingsPath: "/profile/security",
},
openmediavault: {
name: "OpenMediaVault",
@@ -245,13 +247,14 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"),
defaultPort: 3001,
},
glances: {
name: "Glances",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
category: ["healthMonitoring"],
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/glances"),
defaultPort: 61208,
},
tdarr: {
@@ -293,6 +296,7 @@ export const integrationDefs = {
category: ["firewall"],
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
defaultPort: 443,
apiKeySettingsPath: "/system_usermanager.php",
},
ntfy: {
name: "ntfy",
@@ -301,6 +305,7 @@ export const integrationDefs = {
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
defaultPort: 80,
apiKeySettingsPath: "/account",
},
gotify: {
name: "Gotify",
@@ -308,6 +313,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg",
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
defaultPort: 80,
},
ical: {
name: "iCal",
@@ -323,6 +329,7 @@ export const integrationDefs = {
category: ["notes"],
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
defaultPort: 8080,
apiKeySettingsPath: "/settings",
},
truenas: {
name: "TrueNAS",
@@ -347,6 +354,7 @@ export const integrationDefs = {
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
defaultPort: 80,
apiKeySettingsPath: "/Settings/ManagementAccess",
},
coolify: {
name: "Coolify",
@@ -355,6 +363,7 @@ export const integrationDefs = {
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
defaultPort: 8000,
apiKeySettingsPath: "/security/api-tokens",
},
immich: {
name: "Immich",
@@ -363,13 +372,14 @@ export const integrationDefs = {
category: ["photoService"],
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
defaultPort: 2283,
apiKeySettingsPath: "/user-settings",
},
paperlessNgx: {
name: "Paperless-ngx",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
category: ["documents"],
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/paperless-ngx"),
defaultPort: 8000,
},
patchmon: {
@@ -388,6 +398,7 @@ export const integrationDefs = {
category: ["mediaMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
defaultPort: 7040,
apiKeySettingsPath: "/settings",
},
speedtestTracker: {
name: "Speedtest Tracker",
@@ -396,21 +407,23 @@ export const integrationDefs = {
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
defaultPort: 80,
apiKeySettingsPath: "/admin/api-tokens",
},
uptimeKuma: {
name: "Uptime Kuma",
secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg",
category: ["uptimeMonitoring"],
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/uptime-kuma"),
defaultPort: 3001,
apiKeySettingsPath: "/settings/api-keys",
},
audiobookshelf: {
name: "Audiobookshelf",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg",
category: ["mediaLibrary"],
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/audiobookshelf"),
defaultPort: 13378,
apiKeySettingsPath: "/account",
},
@@ -419,7 +432,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
category: ["mediaLibrary", "mediaService"],
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/navidrome"),
defaultPort: 4533,
},
umami: {
@@ -436,7 +449,8 @@ export const integrationDefs = {
secretKinds: [["username", "password"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
category: ["ups"],
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/peanut"),
defaultPort: 8080,
},
beszel: {
name: "Beszel",
@@ -452,6 +466,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
defaultPort: 8000,
},
traefik: {
name: "Traefik",
@@ -466,7 +481,8 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
defaultPort: 8001,
},
wud: {
name: "What's Up Docker",
@@ -524,6 +540,9 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef
return "defaultPort" in definition ? definition.defaultPort : undefined;
};
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null =>
integrationDefs[kind].documentationUrl;
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
const definition = integrationDefs[kind];
if (!("apiKeySettingsPath" in definition)) return null;
+4 -1
View File
@@ -1,2 +1,5 @@
export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const;
export const onboardingSteps = ["start", "user", "group", "setup", "finish"] as const;
export type OnboardingStep = (typeof onboardingSteps)[number];
export const onboardingLayoutPresets = ["balanced", "wide", "focused"] as const;
export type OnboardingLayoutPreset = (typeof onboardingLayoutPresets)[number];
@@ -1,8 +1,16 @@
import { describe, expect, it } from "vitest";
import { getIntegrationApiKeyUrl } from "../integration";
import { getIntegrationApiKeyUrl, integrationDefs } from "../integration";
describe("getIntegrationApiKeyUrl", () => {
it("links every user-facing integration to its Homarr setup guide", () => {
expect(
Object.entries(integrationDefs)
.filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl)
.map(([kind]) => kind),
).toEqual([]);
});
it("builds URL for sonarr", () => {
expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe(
"http://192.168.1.10:8989/settings/general",
@@ -21,10 +29,28 @@ describe("getIntegrationApiKeyUrl", () => {
);
});
it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
it.each([
["homeAssistant", "http://homeassistant.local:8123", "http://homeassistant.local:8123/profile/security"],
["opnsense", "https://firewall.local", "https://firewall.local/system_usermanager.php"],
["ntfy", "https://ntfy.local", "https://ntfy.local/account"],
["anchor", "http://anchor.local:8080", "http://anchor.local:8080/settings"],
["unraid", "http://tower.local", "http://tower.local/Settings/ManagementAccess"],
["coolify", "https://coolify.local", "https://coolify.local/security/api-tokens"],
["immich", "http://immich.local:2283", "http://immich.local:2283/user-settings"],
["tracearr", "http://tracearr.local:7040", "http://tracearr.local:7040/settings"],
["speedtestTracker", "http://speedtest.local", "http://speedtest.local/admin/api-tokens"],
["uptimeKuma", "http://uptime.local:3001", "http://uptime.local:3001/settings/api-keys"],
] as const)("builds a verified credential URL for %s", (kind, baseUrl, expected) => {
expect(getIntegrationApiKeyUrl(baseUrl, kind)).toBe(expected);
});
it.each(["plex", "piHole", "truenas"] as const)(
"returns null when %s has no version-safe credential path",
(kind) => {
expect(getIntegrationApiKeyUrl("http://localhost", kind)).toBeNull();
},
);
it("returns null for empty URL", () => {
expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull();
});
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { getIntegrationDefaultPort } from "../integration";
import { buildIntegrationUrl } from "../integration-url-template";
import { buildAppUrl, buildIntegrationUrl } from "../integration-url-template";
describe("buildIntegrationUrl", () => {
it("builds subdomain URL", () => {
@@ -25,6 +25,31 @@ describe("buildIntegrationUrl", () => {
expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local");
});
it("accepts full URLs but ignores their path when suggesting service URLs", () => {
expect(buildIntegrationUrl("sonarr", "https://home.example.com/homarr", "hostPort")).toBe(
"http://home.example.com:8989",
);
expect(buildAppUrl("My Service", "http://home.example.com/path", "subdomain")).toBe(
"https://my-service.home.example.com",
);
});
it("replaces a supplied base port instead of producing a double port", () => {
expect(buildIntegrationUrl("sonarr", "https://home.example.com:8443/homarr", "hostPort")).toBe(
"http://home.example.com:8989",
);
expect(buildAppUrl("My Service", "https://home.example.com:8443/homarr", "hostPort")).toBe(
"http://home.example.com:8443",
);
});
it("formats IPv6 hosts safely in host-port mode", () => {
expect(buildIntegrationUrl("sonarr", "https://[2001:db8::1]:8443/homarr", "hostPort")).toBe(
"http://[2001:db8::1]:8989",
);
expect(buildIntegrationUrl("sonarr", "[2001:db8::1]", "subdomain")).toBe("");
});
it("returns empty string for empty host", () => {
expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe("");
});
@@ -37,5 +62,11 @@ describe("buildIntegrationUrl", () => {
expect(getIntegrationDefaultPort("radarr")).toBe(7878);
expect(getIntegrationDefaultPort("prowlarr")).toBe(9696);
expect(getIntegrationDefaultPort("jellyfin")).toBe(8096);
expect(getIntegrationDefaultPort("technitiumDns")).toBe(5380);
expect(getIntegrationDefaultPort("dashDot")).toBe(3001);
expect(getIntegrationDefaultPort("gotify")).toBe(80);
expect(getIntegrationDefaultPort("peaNut")).toBe(8080);
expect(getIntegrationDefaultPort("gluetun")).toBe(8000);
expect(getIntegrationDefaultPort("archiveTeamWarrior")).toBe(8001);
});
});
@@ -63,6 +63,40 @@ export const widgetIntegrationLimits: Partial<Record<WidgetKind, number>> = {
audioStats: 1,
};
export type WidgetIntegrationIssue =
| { code: "integration-not-supported" }
| { code: "integration-required" }
| { code: "incompatible-integration"; incompatibleKinds: IntegrationKind[] }
| { code: "integration-limit"; limit: number };
export const getWidgetIntegrationIssue = (
widgetKind: WidgetKind,
integrationKinds: readonly IntegrationKind[],
): WidgetIntegrationIssue | null => {
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY;
if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit };
if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" };
if (
supportedIntegrations !== undefined &&
integrationKinds.length === 0 &&
!widgetKindsWithOptionalIntegrations.has(widgetKind)
) {
return { code: "integration-required" };
}
const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null;
};
export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => {
if (issue.code === "integration-limit") {
return `${widgetKind} supports at most ${issue.limit} integration${issue.limit === 1 ? "" : "s"}`;
}
if (issue.code === "integration-not-supported") return `${widgetKind} does not support integrations`;
if (issue.code === "integration-required") return `${widgetKind} requires an integration`;
return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`;
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
const result: WidgetKind[] = [];
for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) {