feat: rebuild onboarding experience
This commit is contained in:
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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user