feat(onboarding): enhance integration setup with Docker discovery and URL templating (#5607)
This commit is contained in:
1 parent
0a6467979a
commit
dd7a4908a3
68 files changed
+2922
-434
No files matched your search
@@ -76,6 +76,9 @@ const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, N
|
||||
settings: true,
|
||||
},
|
||||
settings: {
|
||||
integrations: true,
|
||||
},
|
||||
integrations: {
|
||||
finish: true,
|
||||
},
|
||||
};
|
||||
@@ -1,12 +1,57 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import { onboardingSteps } from "@homarr/definitions";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { eq, like, or } from "@homarr/db";
|
||||
import { placeAllWidgetsAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
apps,
|
||||
groups,
|
||||
icons,
|
||||
integrationItems,
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
itemLayouts,
|
||||
items,
|
||||
onboarding,
|
||||
} from "@homarr/db/schema";
|
||||
import {
|
||||
everyoneGroup,
|
||||
extractContainerImageName,
|
||||
getIconUrl,
|
||||
integrationKinds,
|
||||
integrationSecretKinds,
|
||||
matchIntegrationKindFromContainer,
|
||||
onboardingSteps,
|
||||
} from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { zodEnumFromArray } from "@homarr/validation/enums";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc";
|
||||
import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection";
|
||||
import { mapTestConnectionError } from "../integration/map-test-connection-error";
|
||||
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
|
||||
|
||||
interface PortInfo {
|
||||
IP?: string;
|
||||
PublicPort?: number;
|
||||
}
|
||||
|
||||
interface SuggestedUrlResult {
|
||||
url: string;
|
||||
publishedPort: number | null;
|
||||
}
|
||||
|
||||
const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
|
||||
const port = ports?.[0];
|
||||
if (port?.PublicPort) {
|
||||
const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : host;
|
||||
return { url: `http://${ip}:${port.PublicPort}`, publishedPort: port.PublicPort };
|
||||
}
|
||||
return { url: "", publishedPort: null };
|
||||
};
|
||||
|
||||
export const onboardRouter = createTRPCRouter({
|
||||
currentStep: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getOnboardingOrFallbackAsync(ctx.db);
|
||||
@@ -33,4 +78,249 @@ export const onboardRouter = createTRPCRouter({
|
||||
step: "start",
|
||||
});
|
||||
}),
|
||||
createIntegration: onboardingProcedure
|
||||
.requiresStep("integrations")
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().nonempty().max(127),
|
||||
url: z.string().nonempty(),
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await testConnectionAsync({
|
||||
id: "new",
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
secrets: input.secrets,
|
||||
}).catch((error) => {
|
||||
if (!(error instanceof MissingSecretError)) throw error;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: mapTestConnectionError(result.error) };
|
||||
}
|
||||
|
||||
const integrationId = createId();
|
||||
const appId = createId();
|
||||
|
||||
await ctx.db.insert(apps).values({
|
||||
id: appId,
|
||||
name: input.name,
|
||||
iconUrl: getIconUrl(input.kind),
|
||||
href: input.url,
|
||||
pingUrl: input.url,
|
||||
description: null,
|
||||
});
|
||||
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
});
|
||||
|
||||
if (input.secrets.length >= 1) {
|
||||
await ctx.db.insert(integrationSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}),
|
||||
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
|
||||
const emptyResult = {
|
||||
integrations: [] as {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
kind: IntegrationKind;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}[],
|
||||
apps: [] as {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}[],
|
||||
};
|
||||
|
||||
try {
|
||||
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
|
||||
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
if (dockerInstances.length === 0) {
|
||||
return { status: "empty" as const, ...emptyResult };
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host }) => {
|
||||
const containerList = await instance.listContainers({ all: false });
|
||||
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
|
||||
}),
|
||||
);
|
||||
|
||||
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
|
||||
|
||||
if (containers.length === 0) {
|
||||
return { status: "empty" as const, ...emptyResult };
|
||||
}
|
||||
|
||||
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
|
||||
const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
|
||||
|
||||
const seenKinds = new Set<IntegrationKind>();
|
||||
const discoveredIntegrations = emptyResult.integrations;
|
||||
const discoveredApps = emptyResult.apps;
|
||||
|
||||
const cdnIconUrl = (slug: string) =>
|
||||
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
|
||||
|
||||
const strictIconMatch = (imageName: string) => {
|
||||
const normalized = imageName.toLowerCase().trim();
|
||||
if (normalized.length < 3) return null;
|
||||
const exact = dbIcons.find((icon) => icon.name.toLowerCase() === normalized);
|
||||
if (exact) return exact.url;
|
||||
return dbIcons.find((icon) => icon.name.toLowerCase().startsWith(normalized))?.url ?? null;
|
||||
};
|
||||
|
||||
for (const container of containers) {
|
||||
const imageName = extractContainerImageName(container.Image);
|
||||
const containerName = container.Names[0]?.split("/")[1] ?? "Unknown";
|
||||
const dbIcon = strictIconMatch(imageName);
|
||||
const iconUrl = dbIcon ?? cdnIconUrl(imageName.toLowerCase());
|
||||
|
||||
const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host);
|
||||
|
||||
const kind = matchIntegrationKindFromContainer({
|
||||
image: container.Image,
|
||||
name: containerName,
|
||||
});
|
||||
|
||||
if (kind && !seenKinds.has(kind)) {
|
||||
seenKinds.add(kind);
|
||||
discoveredIntegrations.push({
|
||||
containerId: container.Id,
|
||||
containerName,
|
||||
kind,
|
||||
suggestedUrl,
|
||||
publishedPort,
|
||||
iconUrl,
|
||||
});
|
||||
} else if (!kind && dbIcon) {
|
||||
discoveredApps.push({
|
||||
containerId: container.Id,
|
||||
containerName,
|
||||
suggestedUrl,
|
||||
publishedPort,
|
||||
iconUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success" as const,
|
||||
integrations: discoveredIntegrations,
|
||||
apps: discoveredApps,
|
||||
};
|
||||
} catch {
|
||||
return { status: "unavailable" as const, ...emptyResult };
|
||||
}
|
||||
}),
|
||||
createAppsFromDiscovery: onboardingProcedure
|
||||
.requiresStep("integrations")
|
||||
.input(
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string().nonempty().max(127),
|
||||
href: z.string().nullable(),
|
||||
iconUrl: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.length === 0) return;
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
await ctx.db.insert(apps).values(
|
||||
input.map((app) => ({
|
||||
id: createId(),
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl ?? defaultIcon,
|
||||
href: app.href,
|
||||
description: null,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
|
||||
const db = ctx.db;
|
||||
|
||||
const allIntegrations = await db.query.integrations.findMany();
|
||||
const allApps = await db.query.apps.findMany();
|
||||
|
||||
const board = await db.query.boards.findFirst({
|
||||
with: {
|
||||
sections: true,
|
||||
layouts: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
await nextOnboardingStepAsync(db, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const everyoneGroupRow = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
});
|
||||
if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) {
|
||||
await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id));
|
||||
}
|
||||
|
||||
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
|
||||
const layout = board.layouts[0];
|
||||
|
||||
if (section && layout) {
|
||||
const existingItems = await db.query.items.findMany({
|
||||
where: eq(items.boardId, board.id),
|
||||
});
|
||||
for (const item of existingItems) {
|
||||
await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
|
||||
await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
|
||||
}
|
||||
if (existingItems.length > 0) {
|
||||
await db.delete(items).where(eq(items.boardId, board.id));
|
||||
}
|
||||
|
||||
await placeAllWidgetsAsync(
|
||||
db,
|
||||
{
|
||||
boardId: board.id,
|
||||
sectionId: section.id,
|
||||
layoutId: layout.id,
|
||||
columnCount: layout.columnCount,
|
||||
},
|
||||
allIntegrations,
|
||||
allApps,
|
||||
);
|
||||
}
|
||||
|
||||
await nextOnboardingStepAsync(db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
|
||||
export const removeTrailingSlash = (path: string) => {
|
||||
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
|
||||
let end = path.length;
|
||||
while (end > 0 && path.charAt(end - 1) === "/") {
|
||||
end--;
|
||||
}
|
||||
return path.slice(0, end);
|
||||
};
|
||||
|
||||
export const extractBaseUrlFromHeaders = (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createId, objectKeys } from "@homarr/common";
|
||||
import {
|
||||
createDocumentationLink,
|
||||
credentialsAdminGroup,
|
||||
defaultBookmarkApps,
|
||||
everyoneGroup,
|
||||
getIntegrationDefaultUrl,
|
||||
getIntegrationName,
|
||||
@@ -15,7 +16,7 @@ import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { getMaxGroupPositionAsync } from "../queries";
|
||||
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
@@ -46,6 +47,9 @@ export const seedDataAsync = async (db: Database) => {
|
||||
await seedServerSettingsAsync(db);
|
||||
await seedDefaultSearchEnginesAsync(db);
|
||||
await seedDefaultIntegrationsAsync(db);
|
||||
await seedDefaultAppsAsync(db);
|
||||
await seedDefaultBoardAsync(db);
|
||||
await seedBoardWidgetsAsync(db);
|
||||
|
||||
if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) {
|
||||
await seedDemoUserAsync(db);
|
||||
@@ -217,6 +221,64 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
|
||||
console.log(`Created ${createdCount} default integrations through seeding process`);
|
||||
};
|
||||
|
||||
const seedDefaultAppsAsync = async (db: Database) => {
|
||||
const existingApps = await db.$count(apps);
|
||||
if (existingApps > 0) {
|
||||
console.log("Skipping seeding of default apps as some already exist");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const app of defaultBookmarkApps) {
|
||||
await db.insert(apps).values({
|
||||
id: createId(),
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
href: app.href,
|
||||
});
|
||||
}
|
||||
console.log(`Created ${defaultBookmarkApps.length} default apps through seeding process`);
|
||||
};
|
||||
|
||||
const seedDefaultBoardAsync = async (db: Database) => {
|
||||
const existingBoard = await db.query.boards.findFirst();
|
||||
|
||||
if (existingBoard) {
|
||||
console.log("Skipping seeding of default board as one already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "dashboard",
|
||||
isPublic: false,
|
||||
});
|
||||
await db.insert(sections).values({
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const everyoneGroupRow = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
});
|
||||
if (everyoneGroupRow) {
|
||||
await db.update(groups).set({ homeBoardId: boardId }).where(eq(groups.id, everyoneGroupRow.id));
|
||||
console.log("Set default board as home board for everyone group");
|
||||
}
|
||||
|
||||
console.log("Created default board 'dashboard' through seed");
|
||||
};
|
||||
|
||||
interface DemoWidget {
|
||||
kind: WidgetKind;
|
||||
width: number;
|
||||
@@ -475,3 +537,32 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
"Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
|
||||
);
|
||||
};
|
||||
|
||||
const seedBoardWidgetsAsync = async (db: Database) => {
|
||||
const existingItems = await db.$count(items);
|
||||
if (existingItems > 0) {
|
||||
console.log("Skipping seeding of board widgets as some already exist");
|
||||
return;
|
||||
}
|
||||
|
||||
const board = await db.query.boards.findFirst({
|
||||
with: { sections: true, layouts: true },
|
||||
});
|
||||
if (!board) return;
|
||||
|
||||
const section = board.sections.find((sec) => sec.kind === "empty");
|
||||
const layout = board.layouts[0];
|
||||
if (!section || !layout) return;
|
||||
|
||||
const allIntegrations = await db.query.integrations.findMany();
|
||||
const allApps = await db.query.apps.findMany();
|
||||
|
||||
const count = await placeAllWidgetsAsync(
|
||||
db,
|
||||
{ boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount },
|
||||
allIntegrations,
|
||||
allApps,
|
||||
);
|
||||
|
||||
console.log(`Placed ${count} widgets on board`);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./item";
|
||||
export * from "./server-setting";
|
||||
export * from "./group";
|
||||
export * from "./widget-placement";
|
||||
@@ -0,0 +1,148 @@
|
||||
import superjson from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import {
|
||||
defaultBookmarkApps,
|
||||
defaultWidgetConfigs,
|
||||
emptySuperJSON,
|
||||
getWidgetKindsForIntegration,
|
||||
} from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { integrationItems, itemLayouts, items } from "../schema";
|
||||
import type { Integration } from "../schema";
|
||||
|
||||
const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
|
||||
|
||||
interface BoardTarget {
|
||||
boardId: string;
|
||||
sectionId: string;
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
interface PlacementContext extends BoardTarget {
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
rowMaxHeight: number;
|
||||
}
|
||||
|
||||
const placeWidgetAsync = async (
|
||||
db: Database,
|
||||
ctx: PlacementContext,
|
||||
kind: WidgetKind,
|
||||
linkedIntegrationIds: string[],
|
||||
options?: string,
|
||||
size?: { width: number; height: number },
|
||||
) => {
|
||||
const itemWidth = size?.width ?? 2;
|
||||
const itemHeight = size?.height ?? 2;
|
||||
|
||||
if (ctx.xOffset + itemWidth > ctx.columnCount) {
|
||||
ctx.xOffset = 0;
|
||||
ctx.yOffset += ctx.rowMaxHeight;
|
||||
ctx.rowMaxHeight = 0;
|
||||
}
|
||||
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId: ctx.boardId,
|
||||
kind,
|
||||
options: options ?? emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
xOffset: ctx.xOffset,
|
||||
yOffset: ctx.yOffset,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
});
|
||||
|
||||
for (const integrationId of linkedIntegrationIds) {
|
||||
await db.insert(integrationItems).values({ itemId, integrationId });
|
||||
}
|
||||
|
||||
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
|
||||
ctx.xOffset += itemWidth;
|
||||
};
|
||||
|
||||
interface App {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const placeAllWidgetsAsync = async (
|
||||
db: Database,
|
||||
target: BoardTarget,
|
||||
allIntegrations: Integration[],
|
||||
allApps: App[],
|
||||
) => {
|
||||
const ctx: PlacementContext = {
|
||||
...target,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
rowMaxHeight: 0,
|
||||
};
|
||||
|
||||
const placedWidgets = new Set<WidgetKind>();
|
||||
|
||||
for (const integration of allIntegrations) {
|
||||
for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) {
|
||||
if (placedWidgets.has(widgetKind)) continue;
|
||||
const config = widgetConfigMap.get(widgetKind);
|
||||
if (config?.skip) continue;
|
||||
placedWidgets.add(widgetKind);
|
||||
|
||||
const matchingIds = allIntegrations
|
||||
.filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind))
|
||||
.map((row) => row.id);
|
||||
|
||||
const options = config?.options ? superjson.stringify(config.options) : undefined;
|
||||
await placeWidgetAsync(
|
||||
db,
|
||||
ctx,
|
||||
widgetKind,
|
||||
matchingIds,
|
||||
options,
|
||||
config && { width: config.width, height: config.height },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const app of allApps) {
|
||||
await placeWidgetAsync(
|
||||
db,
|
||||
ctx,
|
||||
"app",
|
||||
[],
|
||||
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
|
||||
{ width: 1, height: 1 },
|
||||
);
|
||||
}
|
||||
|
||||
const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
|
||||
const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id);
|
||||
|
||||
for (const config of defaultWidgetConfigs) {
|
||||
if (config.skip || placedWidgets.has(config.kind)) continue;
|
||||
placedWidgets.add(config.kind);
|
||||
|
||||
let options = config.options ? { ...config.options } : undefined;
|
||||
if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) {
|
||||
options = { ...options, items: bookmarkAppIds };
|
||||
}
|
||||
|
||||
await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
});
|
||||
}
|
||||
|
||||
return placedWidgets.size + allApps.length;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
|
||||
import { integrationDefs } from "./integration";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
|
||||
export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||
|
||||
const extractIconSlug = (iconUrl: string): string => {
|
||||
const filename = iconUrl.split("/").pop() ?? "";
|
||||
return filename.replace(/\.(svg|png)$/, "").toLowerCase();
|
||||
};
|
||||
|
||||
export const integrationIconSlugs: Record<IntegrationKind, string> = Object.fromEntries(
|
||||
objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]),
|
||||
) as Record<IntegrationKind, string>;
|
||||
|
||||
const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> = {
|
||||
piHole: ["pihole", "pi-hole"],
|
||||
adGuardHome: ["adguardhome", "adguard-home", "adguard"],
|
||||
homeAssistant: ["homeassistant", "home-assistant", "hass"],
|
||||
openmediavault: ["omv"],
|
||||
qBittorrent: ["qbittorrent"],
|
||||
sabNzbd: ["sabnzbd"],
|
||||
nzbGet: ["nzbget"],
|
||||
unifiController: ["unifi", "unifi-controller"],
|
||||
jellyseerr: ["jellyseerr"],
|
||||
overseerr: ["overseerr"],
|
||||
dashDot: ["dashdot", "dash-dot", "dash."],
|
||||
speedtestTracker: ["speedtest-tracker"],
|
||||
searchCh: ["search-ch"],
|
||||
gitHubContainerRegistry: ["ghcr"],
|
||||
linuxServerIO: ["linuxserver"],
|
||||
coolify: ["coolify"],
|
||||
truenas: ["truenas"],
|
||||
};
|
||||
|
||||
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
|
||||
const normalized = search.toLowerCase().trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
for (const kind of objectKeys(integrationDefs)) {
|
||||
if (kind.toLowerCase() === normalized) return kind;
|
||||
if (integrationDefs[kind].name.toLowerCase() === normalized) return kind;
|
||||
}
|
||||
|
||||
for (const kind of objectKeys(integrationDefs)) {
|
||||
if (integrationIconSlugs[kind] === normalized) return kind;
|
||||
}
|
||||
|
||||
for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) {
|
||||
if (aliases.some((alias) => alias === normalized)) return kind;
|
||||
}
|
||||
|
||||
for (const kind of objectKeys(integrationDefs)) {
|
||||
if (normalized.includes(kind.toLowerCase())) return kind;
|
||||
if (normalized.includes(integrationIconSlugs[kind])) return kind;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const notDockerDiscoverable = new Set<IntegrationKind>([
|
||||
"dockerHub",
|
||||
"github",
|
||||
"gitlab",
|
||||
"npm",
|
||||
"codeberg",
|
||||
"linuxServerIO",
|
||||
"gitHubContainerRegistry",
|
||||
"quay",
|
||||
"ical",
|
||||
"searchCh",
|
||||
"mock",
|
||||
]);
|
||||
|
||||
interface ContainerMatchInput {
|
||||
image: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
|
||||
const imageName = extractContainerImageName(container.image);
|
||||
const fromImage = matchIntegrationKind(imageName);
|
||||
if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage;
|
||||
|
||||
const fromName = matchIntegrationKind(container.name);
|
||||
if (fromName && !notDockerDiscoverable.has(fromName)) return fromName;
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -12,5 +12,8 @@ export * from "./docs";
|
||||
export * from "./cookie";
|
||||
export * from "./search-engine";
|
||||
export * from "./onboarding";
|
||||
export * from "./widget-integration-map";
|
||||
export * from "./emptysuperjson";
|
||||
export * from "./hotkeys";
|
||||
export * from "./docker-integration-match";
|
||||
export * from "./integration-url-template";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { removeTrailingSlash } from "@homarr/common";
|
||||
|
||||
import { integrationIconSlugs } from "./docker-integration-match";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import { getIntegrationDefaultPort } from "./integration";
|
||||
|
||||
export type UrlTemplateMode = "hostPort" | "subdomain";
|
||||
|
||||
const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind];
|
||||
|
||||
const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => {
|
||||
const modeBuilders: Record<UrlTemplateMode, () => string> = {
|
||||
subdomain: () => `https://${slug}.${host}`,
|
||||
hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`),
|
||||
};
|
||||
return modeBuilders[mode]();
|
||||
};
|
||||
|
||||
export const buildIntegrationUrl = (
|
||||
kind: IntegrationKind,
|
||||
baseHost: string,
|
||||
mode: UrlTemplateMode,
|
||||
dockerPort?: number,
|
||||
): string => {
|
||||
const host = removeTrailingSlash(baseHost.trim());
|
||||
if (!host) return "";
|
||||
const port = dockerPort ?? getIntegrationDefaultPort(kind);
|
||||
return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined);
|
||||
};
|
||||
|
||||
export const buildAppUrl = (
|
||||
containerName: string,
|
||||
baseHost: string,
|
||||
mode: UrlTemplateMode,
|
||||
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);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import { objectKeys, removeTrailingSlash } from "@homarr/common";
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
|
||||
import { createDocumentationLink } from "./docs";
|
||||
@@ -27,7 +27,9 @@ interface integrationDefinition {
|
||||
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
|
||||
category: AtLeastOneOf<IntegrationCategory>;
|
||||
documentationUrl: string | null;
|
||||
defaultUrl?: string; // optional default URL for the integration
|
||||
defaultUrl?: string;
|
||||
defaultPort?: number;
|
||||
apiKeySettingsPath?: string;
|
||||
}
|
||||
|
||||
export const integrationDefs = {
|
||||
@@ -37,6 +39,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
|
||||
category: ["downloadClient", "usenet"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"),
|
||||
defaultPort: 8080,
|
||||
apiKeySettingsPath: "/config/general/",
|
||||
},
|
||||
nzbGet: {
|
||||
name: "NZBGet",
|
||||
@@ -44,6 +48,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
|
||||
category: ["downloadClient", "usenet"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/nzbget"),
|
||||
defaultPort: 6789,
|
||||
},
|
||||
deluge: {
|
||||
name: "Deluge",
|
||||
@@ -51,6 +56,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/deluge"),
|
||||
defaultPort: 8112,
|
||||
},
|
||||
transmission: {
|
||||
name: "Transmission",
|
||||
@@ -58,6 +64,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/transmission"),
|
||||
defaultPort: 9091,
|
||||
},
|
||||
qBittorrent: {
|
||||
name: "qBittorrent",
|
||||
@@ -65,6 +72,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"),
|
||||
defaultPort: 8080,
|
||||
apiKeySettingsPath: "/#/settings/webui",
|
||||
},
|
||||
aria2: {
|
||||
name: "Aria2",
|
||||
@@ -72,6 +81,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
|
||||
category: ["downloadClient", "torrent", "miscellaneous"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/aria2"),
|
||||
defaultPort: 6800,
|
||||
},
|
||||
slskd: {
|
||||
name: "Slskd",
|
||||
@@ -79,6 +89,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg",
|
||||
category: ["downloadClient", "miscellaneous"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/slskd"),
|
||||
defaultPort: 5030,
|
||||
},
|
||||
sonarr: {
|
||||
name: "Sonarr",
|
||||
@@ -86,6 +97,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
|
||||
defaultPort: 8989,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
radarr: {
|
||||
name: "Radarr",
|
||||
@@ -93,6 +106,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
|
||||
defaultPort: 7878,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
lidarr: {
|
||||
name: "Lidarr",
|
||||
@@ -100,6 +115,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/lidarr"),
|
||||
defaultPort: 8686,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
readarr: {
|
||||
name: "Readarr",
|
||||
@@ -107,6 +124,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
|
||||
defaultPort: 8787,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
prowlarr: {
|
||||
name: "Prowlarr",
|
||||
@@ -114,6 +133,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
|
||||
category: ["indexerManager"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"),
|
||||
defaultPort: 9696,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
jellyfin: {
|
||||
name: "Jellyfin",
|
||||
@@ -121,6 +142,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
|
||||
category: ["mediaService", "mediaRelease"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"),
|
||||
defaultPort: 8096,
|
||||
apiKeySettingsPath: "/web/index.html#!/dashboard/keys",
|
||||
},
|
||||
emby: {
|
||||
name: "Emby",
|
||||
@@ -128,6 +151,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
|
||||
category: ["mediaService", "mediaRelease"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/emby"),
|
||||
defaultPort: 8096,
|
||||
},
|
||||
plex: {
|
||||
name: "Plex",
|
||||
@@ -135,6 +159,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
|
||||
category: ["mediaService", "mediaRelease"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/plex"),
|
||||
defaultPort: 32400,
|
||||
},
|
||||
jellyseerr: {
|
||||
name: "Jellyseerr",
|
||||
@@ -142,6 +167,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"),
|
||||
defaultPort: 5055,
|
||||
apiKeySettingsPath: "/settings",
|
||||
},
|
||||
seerr: {
|
||||
name: "Seerr",
|
||||
@@ -149,6 +176,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/seerr"),
|
||||
defaultPort: 5055,
|
||||
apiKeySettingsPath: "/settings",
|
||||
},
|
||||
overseerr: {
|
||||
name: "Overseerr",
|
||||
@@ -156,6 +185,8 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/overseerr"),
|
||||
defaultPort: 5055,
|
||||
apiKeySettingsPath: "/settings",
|
||||
},
|
||||
piHole: {
|
||||
name: "Pi-hole",
|
||||
@@ -163,6 +194,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
|
||||
category: ["dnsHole"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
adGuardHome: {
|
||||
name: "AdGuard Home",
|
||||
@@ -170,6 +202,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
|
||||
category: ["dnsHole"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"),
|
||||
defaultPort: 3000,
|
||||
},
|
||||
homeAssistant: {
|
||||
name: "Home Assistant",
|
||||
@@ -177,6 +210,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
|
||||
category: ["smartHomeServer", "calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"),
|
||||
defaultPort: 8123,
|
||||
},
|
||||
openmediavault: {
|
||||
name: "OpenMediaVault",
|
||||
@@ -184,6 +218,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
dashDot: {
|
||||
name: "Dash.",
|
||||
@@ -198,6 +233,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: null,
|
||||
defaultPort: 61208,
|
||||
},
|
||||
tdarr: {
|
||||
name: "Tdarr",
|
||||
@@ -205,6 +241,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
|
||||
category: ["mediaTranscoding"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
|
||||
defaultPort: 8265,
|
||||
},
|
||||
proxmox: {
|
||||
name: "Proxmox",
|
||||
@@ -212,6 +249,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/proxmox"),
|
||||
defaultPort: 8006,
|
||||
},
|
||||
nextcloud: {
|
||||
name: "Nextcloud",
|
||||
@@ -219,6 +257,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
|
||||
category: ["calendar", "notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"),
|
||||
defaultPort: 443,
|
||||
},
|
||||
unifiController: {
|
||||
name: "Unifi Controller",
|
||||
@@ -226,6 +265,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["networkController"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
|
||||
defaultPort: 8443,
|
||||
},
|
||||
opnsense: {
|
||||
name: "OPNsense",
|
||||
@@ -233,6 +273,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
|
||||
category: ["firewall"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
|
||||
defaultPort: 443,
|
||||
},
|
||||
github: {
|
||||
name: "Github",
|
||||
@@ -304,6 +345,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
|
||||
category: ["notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
ical: {
|
||||
name: "iCal",
|
||||
@@ -318,6 +360,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
|
||||
category: ["notes"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
|
||||
defaultPort: 8080,
|
||||
},
|
||||
truenas: {
|
||||
name: "TrueNAS",
|
||||
@@ -325,6 +368,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
unraid: {
|
||||
name: "Unraid",
|
||||
@@ -332,6 +376,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
coolify: {
|
||||
name: "Coolify",
|
||||
@@ -340,12 +385,12 @@ export const integrationDefs = {
|
||||
category: ["healthMonitoring"],
|
||||
// @ts-expect-error - docs page will be created when integration is merged
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
|
||||
defaultPort: 8000,
|
||||
},
|
||||
searchCh: {
|
||||
name: "Search.ch",
|
||||
secretKinds: [[]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png",
|
||||
defaultUrl: "https://search.ch",
|
||||
category: ["timetable"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/search-ch"),
|
||||
},
|
||||
@@ -355,6 +400,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg",
|
||||
category: ["photoService"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
|
||||
defaultPort: 2283,
|
||||
},
|
||||
tracearr: {
|
||||
name: "Tracearr",
|
||||
@@ -362,6 +408,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg",
|
||||
category: ["mediaMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
|
||||
defaultPort: 7040,
|
||||
},
|
||||
speedtestTracker: {
|
||||
name: "Speedtest Tracker",
|
||||
@@ -369,6 +416,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
|
||||
category: ["speedtest"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
umami: {
|
||||
name: "Umami",
|
||||
@@ -377,6 +425,7 @@ export const integrationDefs = {
|
||||
category: ["analytics"],
|
||||
defaultUrl: "https://api.umami.is/v1",
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
|
||||
defaultPort: 3000,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
@@ -418,6 +467,19 @@ export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
|
||||
return "defaultUrl" in definition ? definition.defaultUrl : undefined;
|
||||
};
|
||||
|
||||
export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undefined => {
|
||||
const definition = integrationDefs[kind];
|
||||
return "defaultPort" in definition ? definition.defaultPort : undefined;
|
||||
};
|
||||
|
||||
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
|
||||
const definition = integrationDefs[kind];
|
||||
if (!("apiKeySettingsPath" in definition)) return null;
|
||||
const base = removeTrailingSlash(integrationUrl);
|
||||
if (!base) return null;
|
||||
return `${base}${definition.apiKeySettingsPath}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all integration kinds that share a category, typed only by the kinds belonging to the category
|
||||
* @param category Category to filter by, belonging to IntegrationCategory
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const;
|
||||
export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const;
|
||||
export type OnboardingStep = (typeof onboardingSteps)[number];
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getIntegrationApiKeyUrl } from "../integration";
|
||||
|
||||
describe("getIntegrationApiKeyUrl", () => {
|
||||
it("builds URL for sonarr", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe(
|
||||
"http://192.168.1.10:8989/settings/general",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds URL for jellyfin", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://media.local:8096", "jellyfin")).toBe(
|
||||
"http://media.local:8096/web/index.html#!/dashboard/keys",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for integrations without apiKeySettingsPath", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty URL", () => {
|
||||
expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull();
|
||||
});
|
||||
|
||||
it("strips trailing slash from base URL", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe("http://sonarr.local/settings/general");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractContainerImageName,
|
||||
matchIntegrationKind,
|
||||
matchIntegrationKindFromContainer,
|
||||
} from "../docker-integration-match";
|
||||
|
||||
describe("extractContainerImageName", () => {
|
||||
it("extracts name from full image reference", () => {
|
||||
expect(extractContainerImageName("linuxserver/sonarr:latest")).toBe("sonarr");
|
||||
});
|
||||
|
||||
it("extracts name from ghcr image", () => {
|
||||
expect(extractContainerImageName("ghcr.io/org/radarr:v5")).toBe("radarr");
|
||||
});
|
||||
|
||||
it("handles plain image name", () => {
|
||||
expect(extractContainerImageName("jellyfin")).toBe("jellyfin");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(extractContainerImageName("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchIntegrationKind", () => {
|
||||
it("matches exact kind name", () => {
|
||||
expect(matchIntegrationKind("sonarr")).toBe("sonarr");
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
expect(matchIntegrationKind("Sonarr")).toBe("sonarr");
|
||||
});
|
||||
|
||||
it("matches icon slug for pi-hole", () => {
|
||||
expect(matchIntegrationKind("pi-hole")).toBe("piHole");
|
||||
});
|
||||
|
||||
it("matches alias pihole", () => {
|
||||
expect(matchIntegrationKind("pihole")).toBe("piHole");
|
||||
});
|
||||
|
||||
it("matches alias adguardhome", () => {
|
||||
expect(matchIntegrationKind("adguardhome")).toBe("adGuardHome");
|
||||
});
|
||||
|
||||
it("matches alias homeassistant", () => {
|
||||
expect(matchIntegrationKind("homeassistant")).toBe("homeAssistant");
|
||||
});
|
||||
|
||||
it("matches substring in image name", () => {
|
||||
expect(matchIntegrationKind("hotio-sonarr")).toBe("sonarr");
|
||||
});
|
||||
|
||||
it("returns null for unknown", () => {
|
||||
expect(matchIntegrationKind("nginx")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(matchIntegrationKind("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchIntegrationKindFromContainer", () => {
|
||||
it("matches from image name first", () => {
|
||||
expect(
|
||||
matchIntegrationKindFromContainer({
|
||||
image: "linuxserver/sonarr:latest",
|
||||
name: "my-sonarr",
|
||||
}),
|
||||
).toBe("sonarr");
|
||||
});
|
||||
|
||||
it("falls back to container name", () => {
|
||||
expect(
|
||||
matchIntegrationKindFromContainer({
|
||||
image: "custom/myapp:latest",
|
||||
name: "radarr",
|
||||
}),
|
||||
).toBe("radarr");
|
||||
});
|
||||
|
||||
it("returns null when neither matches", () => {
|
||||
expect(
|
||||
matchIntegrationKindFromContainer({
|
||||
image: "nginx:latest",
|
||||
name: "web-proxy",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("excludes dockerHub even when image contains docker", () => {
|
||||
expect(
|
||||
matchIntegrationKindFromContainer({
|
||||
image: "portainer/portainer-ce:latest",
|
||||
name: "docker-proxy",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("excludes ical from container matching", () => {
|
||||
expect(
|
||||
matchIntegrationKindFromContainer({
|
||||
image: "ical-relay:latest",
|
||||
name: "ical",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("excludes github from container matching", () => {
|
||||
expect(
|
||||
matchIntegrationKindFromContainer({
|
||||
image: "github-runner:latest",
|
||||
name: "github-actions",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getIntegrationDefaultPort } from "../integration";
|
||||
import { buildIntegrationUrl } from "../integration-url-template";
|
||||
|
||||
describe("buildIntegrationUrl", () => {
|
||||
it("builds subdomain URL", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "homelab.local", "subdomain")).toBe("https://sonarr.homelab.local");
|
||||
});
|
||||
|
||||
it("builds hostPort URL with default port", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1:8989");
|
||||
});
|
||||
|
||||
it("uses docker port over default port", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort", 9999)).toBe("http://192.168.1.1:9999");
|
||||
});
|
||||
|
||||
it("builds hostPort URL without port when none known", () => {
|
||||
expect(buildIntegrationUrl("mock", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1");
|
||||
});
|
||||
|
||||
it("strips trailing slashes from host", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "homelab.local/", "subdomain")).toBe("https://sonarr.homelab.local");
|
||||
expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local");
|
||||
});
|
||||
|
||||
it("returns empty string for empty host", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe("");
|
||||
});
|
||||
|
||||
it("uses pi-hole slug for subdomain mode", () => {
|
||||
expect(buildIntegrationUrl("piHole", "example.com", "subdomain")).toBe("https://pi-hole.example.com");
|
||||
});
|
||||
|
||||
it("reads default ports from integration definitions", () => {
|
||||
expect(getIntegrationDefaultPort("radarr")).toBe(7878);
|
||||
expect(getIntegrationDefaultPort("prowlarr")).toBe(9696);
|
||||
expect(getIntegrationDefaultPort("jellyfin")).toBe(8096);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createDocumentationLink } from "./docs";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import { getIntegrationKindsByCategory } from "./integration";
|
||||
import type { WidgetKind } from "./widget";
|
||||
|
||||
/**
|
||||
* Static mapping of widget kinds to the integration kinds they support.
|
||||
* Mirrors the supportedIntegrations declared in each widget definition
|
||||
* but lives in @homarr/definitions so server-side code (API layer)
|
||||
* can use it without importing @homarr/widgets.
|
||||
*/
|
||||
export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly IntegrationKind[]>> = {
|
||||
dnsHoleSummary: getIntegrationKindsByCategory("dnsHole"),
|
||||
dnsHoleControls: getIntegrationKindsByCategory("dnsHole"),
|
||||
"smartHome-entityState": getIntegrationKindsByCategory("smartHomeServer"),
|
||||
"smartHome-executeAutomation": getIntegrationKindsByCategory("smartHomeServer"),
|
||||
mediaServer: getIntegrationKindsByCategory("mediaService"),
|
||||
calendar: getIntegrationKindsByCategory("calendar"),
|
||||
downloads: getIntegrationKindsByCategory("downloadClient"),
|
||||
"mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"),
|
||||
"mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"),
|
||||
mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"),
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["emby", "jellyfin", "plex"],
|
||||
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
|
||||
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
|
||||
coolify: ["coolify"],
|
||||
timetable: ["searchCh"],
|
||||
"immich-serverStats": ["immich"],
|
||||
"immich-albumCarousel": ["immich"],
|
||||
tracearr: ["tracearr"],
|
||||
speedtestTracker: ["speedtestTracker"],
|
||||
anchorNote: ["anchor"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
const result: WidgetKind[] = [];
|
||||
for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) {
|
||||
if (supportedIntegrations.includes(integrationKind)) {
|
||||
result.push(widgetKind as WidgetKind);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export interface DefaultWidgetConfig {
|
||||
kind: WidgetKind;
|
||||
width: number;
|
||||
height: number;
|
||||
options?: Record<string, unknown>;
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
||||
{ kind: "clock", width: 2, height: 1 },
|
||||
{ kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
|
||||
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||
];
|
||||
|
||||
export const generalWidgets: WidgetKind[] = defaultWidgetConfigs
|
||||
.filter((config) => !config.skip)
|
||||
.map((config) => config.kind);
|
||||
|
||||
export const featuredIntegrations: readonly IntegrationKind[] = [
|
||||
"sonarr",
|
||||
"radarr",
|
||||
"prowlarr",
|
||||
"sabNzbd",
|
||||
"qBittorrent",
|
||||
"seerr",
|
||||
"jellyfin",
|
||||
];
|
||||
|
||||
export const hiddenFromOnboarding = new Set<IntegrationKind>([
|
||||
"searchCh",
|
||||
"github",
|
||||
"dockerHub",
|
||||
"gitlab",
|
||||
"npm",
|
||||
"codeberg",
|
||||
"linuxServerIO",
|
||||
"gitHubContainerRegistry",
|
||||
"quay",
|
||||
"mock",
|
||||
]);
|
||||
|
||||
export const defaultBookmarkApps = [
|
||||
{
|
||||
name: "Homarr Docs",
|
||||
href: createDocumentationLink("/docs/getting-started"),
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
},
|
||||
{
|
||||
name: "Homarr GitHub",
|
||||
href: "https://github.com/homarr-labs/homarr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/github.svg",
|
||||
},
|
||||
{
|
||||
name: "Help Translate",
|
||||
href: createDocumentationLink("/docs/community/translations"),
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
},
|
||||
{
|
||||
name: "Support Homarr",
|
||||
href: "https://opencollective.com/homarr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg",
|
||||
},
|
||||
];
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -29,96 +30,119 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
);
|
||||
|
||||
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAddNewApp = () => {
|
||||
openQuickAddAppModal({
|
||||
onClose(app) {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch />}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{innerProps.withCreate && (
|
||||
<Card
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleAddNewApp} variant="light" size="xs" fullWidth>
|
||||
{t("app.action.create.action")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Grid>
|
||||
{innerProps.withCreate && (
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<IconPlus size={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" fullWidth>
|
||||
{t("app.action.create.action")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
)}
|
||||
{filteredApps.map((app) => (
|
||||
<Card
|
||||
key={app.id}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleSelect(app)} variant="light" size="xs" fullWidth>
|
||||
{t("app.action.select.action", { app: app.name })}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<Grid.Col key={app.id} span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<img src={app.iconUrl} alt={app.name} width={24} height={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={() => handleSelect(app)} variant="light" size="xs" mt="auto" fullWidth>
|
||||
{t("app.action.select.action", { app: app.name })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Grid.Col span={12}>
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
size: "xl",
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
@@ -54,4 +54,5 @@ export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, i
|
||||
defaultTitle(t) {
|
||||
return t("board.action.quickCreateApp.modal.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,2 +1,4 @@
|
||||
export { ModalProvider, useModalAction, useConfirmModal } from "./src";
|
||||
export { createModal } from "./src/creator";
|
||||
export { ModalFormFooter } from "./src/modal-form-footer";
|
||||
export { modalSizeForm, modalSizeSelect } from "./src/modal-sizes";
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface ModalFormFooterProps {
|
||||
onCancel: () => void;
|
||||
submitLabel?: string;
|
||||
cancelLabel?: string;
|
||||
loading?: boolean;
|
||||
leftSection?: ReactNode;
|
||||
}
|
||||
|
||||
const ActionButtons = ({
|
||||
onCancel,
|
||||
submitLabel,
|
||||
cancelLabel,
|
||||
loading,
|
||||
}: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading">) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={onCancel} variant="subtle" color="gray">
|
||||
{cancelLabel ?? t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{submitLabel ?? t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalFormFooter = ({ onCancel, submitLabel, cancelLabel, loading, leftSection }: ModalFormFooterProps) => {
|
||||
const actionButtons = (
|
||||
<ActionButtons onCancel={onCancel} submitLabel={submitLabel} cancelLabel={cancelLabel} loading={loading} />
|
||||
);
|
||||
|
||||
if (leftSection) {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
{leftSection}
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
{actionButtons}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return <Group justify="end">{actionButtons}</Group>;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { modalSizeForm, modalSizeSelect } from "@homarr/ui";
|
||||
@@ -69,7 +69,7 @@ export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImpo
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack mb="sm">
|
||||
<Stack w="100%" mb="sm">
|
||||
<ImportSettingsCard
|
||||
settings={settings}
|
||||
updateSetting={(setting, value) => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelect
|
||||
}
|
||||
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
||||
<Card w="100%" withBorder={false}>
|
||||
<Stack gap="sm">
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center">
|
||||
|
||||
@@ -17,7 +17,7 @@ interface ImportSettingsCardProps {
|
||||
export const ImportSettingsCard = ({ settings, updateSetting }: ImportSettingsCardProps) => {
|
||||
const tImportSettings = useScopedI18n("init.step.import.importSettings");
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
||||
<Card w="100%" withBorder={false}>
|
||||
<Stack gap="sm">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{tImportSettings("title")}</Text>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface ImportSummaryCardProps {
|
||||
export const ImportSummaryCard = ({ counts, onSubmit, loading }: ImportSummaryCardProps) => {
|
||||
const tSummary = useScopedI18n("init.step.import.summary");
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
||||
<Card w="100%" withBorder={false}>
|
||||
<Stack gap="sm">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{tSummary("title")}</Text>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db, like, or } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { extractContainerImageName } from "@homarr/definitions";
|
||||
import type { ContainerState, Port } from "@homarr/docker";
|
||||
import { dockerLabels, DockerSingleton } from "@homarr/docker";
|
||||
|
||||
@@ -180,7 +181,7 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
|
||||
cacheDuration: dayjs.duration(20, "seconds"),
|
||||
});
|
||||
|
||||
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
|
||||
|
||||
async function getContainersWithStatsAsync() {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
|
||||
@@ -90,6 +90,48 @@
|
||||
"title": "Settings",
|
||||
"subtitle": "Configure server settings."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"subtitle": "Select the services you use.",
|
||||
"description": "Choose the integrations you'd like to connect. We'll set up your dashboard with matching widgets automatically.",
|
||||
"action": {
|
||||
"continue": "Continue",
|
||||
"skip": "Skip for now"
|
||||
},
|
||||
"configure": {
|
||||
"progress": "{current} of {total}",
|
||||
"skip": "Skip this integration",
|
||||
"finishing": "Setting up your dashboard...",
|
||||
"error": {
|
||||
"title": "Setup failed",
|
||||
"message": "Something went wrong while setting up your dashboard. Please try again."
|
||||
},
|
||||
"helpers": {
|
||||
"tooltip": "URL helpers",
|
||||
"description": "These helpers try to guess the URL for your services. If it doesn't work, just type the address manually."
|
||||
}
|
||||
},
|
||||
"urlTemplate": {
|
||||
"label": "Base URL",
|
||||
"placeholder": "homelab.local or 192.168.1.1",
|
||||
"hint": "Enter your base domain or IP to auto-fill integration URLs",
|
||||
"warning": "Setting your base URL now will auto-fill all integration URLs, saving you time during setup",
|
||||
"preview": "e.g. {example}",
|
||||
"mode": {
|
||||
"subdomain": "Subdomain",
|
||||
"hostPort": "Host:Port"
|
||||
}
|
||||
},
|
||||
"docker": {
|
||||
"detected": "Docker detected {count} services",
|
||||
"detectedBreakdown": "{integrations} integrations · {apps} apps",
|
||||
"summary": "Homarr detected {apps} apps, {integrations} of which have integrations built-in",
|
||||
"appBadge": "App",
|
||||
"appSectionLabel": "Apps to add to dashboard ({count} selected)",
|
||||
"scanning": "Scanning Docker...",
|
||||
"scanningDescription": "Looking for running containers on your system"
|
||||
}
|
||||
},
|
||||
"finish": {
|
||||
"title": "Finish setup",
|
||||
"subtitle": "You are ready to go!",
|
||||
@@ -731,11 +773,46 @@
|
||||
},
|
||||
"appHref": {
|
||||
"placeholder": "Custom app URL"
|
||||
},
|
||||
"apiKeySettings": {
|
||||
"label": "Get API key"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "New integration"
|
||||
},
|
||||
"grid": {
|
||||
"widgetCount": "{count} widgets",
|
||||
"noWidgets": "No widgets",
|
||||
"detected": "Detected via Docker"
|
||||
},
|
||||
"category": {
|
||||
"dnsHole": "DNS",
|
||||
"mediaService": "Media",
|
||||
"calendar": "Calendar",
|
||||
"mediaSearch": "Search",
|
||||
"mediaRelease": "Releases",
|
||||
"mediaRequest": "Requests",
|
||||
"downloadClient": "Downloads",
|
||||
"usenet": "Usenet",
|
||||
"torrent": "Torrent",
|
||||
"miscellaneous": "Misc",
|
||||
"smartHomeServer": "Smart Home",
|
||||
"indexerManager": "Indexer",
|
||||
"healthMonitoring": "Health",
|
||||
"search": "Search",
|
||||
"mediaTranscoding": "Transcoding",
|
||||
"networkController": "Network",
|
||||
"releasesProvider": "Releases",
|
||||
"notifications": "Notifications",
|
||||
"firewall": "Firewall",
|
||||
"timetable": "Timetable",
|
||||
"photoService": "Photos",
|
||||
"notes": "Notes",
|
||||
"mediaMonitoring": "Monitoring",
|
||||
"speedtest": "Speed Test",
|
||||
"analytics": "Analytics"
|
||||
},
|
||||
"testConnection": {
|
||||
"action": {
|
||||
"create": "Test connection and create",
|
||||
@@ -1068,6 +1145,7 @@
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
"backToOverview": "Back to overview",
|
||||
"skip": "Skip",
|
||||
"change": "Change",
|
||||
"create": "Create",
|
||||
"createAnother": "Create and start over",
|
||||
|
||||
@@ -16,3 +16,9 @@ export { MaskedImage } from "./masked-image";
|
||||
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||
export { LanguageIcon } from "./language-icon";
|
||||
export { Link } from "./link";
|
||||
export {
|
||||
SelectGridLayout,
|
||||
selectGridCardHeight,
|
||||
selectGridCols,
|
||||
selectGridScrollMaxHeight,
|
||||
} from "../select-grid-layout";
|
||||
@@ -3,6 +3,7 @@ import type { MantineProviderProps } from "@mantine/core";
|
||||
import { theme } from "./theme";
|
||||
|
||||
export { theme } from "./theme";
|
||||
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
|
||||
export * from "./components";
|
||||
|
||||
export const uiConfiguration = {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { Input, ScrollArea, SimpleGrid, Stack } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
export const selectGridCols = { base: 2, xs: 3, sm: 4, md: 5 };
|
||||
export const selectGridCardHeight = 140;
|
||||
export const selectGridScrollMaxHeight = "70vh";
|
||||
|
||||
interface SelectGridLayoutProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
onSearchKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
disableScroll?: boolean;
|
||||
disableAutoFocus?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SelectGridLayout = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
placeholder,
|
||||
onSearchKeyDown,
|
||||
disableScroll = false,
|
||||
disableAutoFocus = false,
|
||||
children,
|
||||
}: SelectGridLayoutProps) => {
|
||||
const grid = (
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{children}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.currentTarget.value)}
|
||||
leftSection={<IconSearch />}
|
||||
placeholder={placeholder}
|
||||
data-autofocus={disableAutoFocus ? undefined : true}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
|
||||
{disableScroll ? grid : <ScrollArea.Autosize mah={selectGridScrollMaxHeight}>{grid}</ScrollArea.Autosize>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createTheme, rem } from "@mantine/core";
|
||||
|
||||
import { modalComponent } from "./theme/modal";
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: "red",
|
||||
autoContrast: true,
|
||||
@@ -99,5 +101,6 @@ export const theme = createTheme({
|
||||
overlayProps: { radius: "sm", blur: 2 },
|
||||
},
|
||||
},
|
||||
Modal: modalComponent,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MantineTheme, MantineThemeComponent, ModalProps } from "@mantine/core";
|
||||
|
||||
export const modalSizeSelect = "xxl";
|
||||
export const modalSizeForm = "lg";
|
||||
|
||||
const fixedModalSizes = new Set([modalSizeSelect, modalSizeForm]);
|
||||
|
||||
const modalWidth = "min(var(--modal-size), calc(100vw - 2 * var(--mantine-spacing-md)))";
|
||||
|
||||
export const modalComponent: MantineThemeComponent = {
|
||||
vars: (_theme: MantineTheme, props: Partial<ModalProps>) => {
|
||||
if (props.size === modalSizeSelect) {
|
||||
return { root: { "--modal-size": "75rem" } };
|
||||
}
|
||||
return { root: {} };
|
||||
},
|
||||
styles: (_theme: MantineTheme, props: Partial<ModalProps>) => {
|
||||
const isFixedSize = typeof props.size === "string" && fixedModalSizes.has(props.size);
|
||||
if (!isFixedSize) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
content: {
|
||||
width: modalWidth,
|
||||
minWidth: modalWidth,
|
||||
},
|
||||
body: {
|
||||
width: "100%",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
||||
import { Group, Loader, Select, Stack } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface InnerProps {
|
||||
@@ -47,6 +47,7 @@ export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps })
|
||||
label={t("app.action.select.label")}
|
||||
searchable
|
||||
clearable
|
||||
data-autofocus
|
||||
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
|
||||
nothingFoundMessage={t("app.action.select.notFound")}
|
||||
renderOption={renderSelectOption}
|
||||
@@ -61,19 +62,13 @@ export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps })
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
<ModalFormFooter onCancel={actions.closeModal} submitLabel={confirmLabel} loading={loading} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.label"),
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
const iconProps = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Button, CloseButton, ColorInput, Group, Input, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
@@ -34,6 +34,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("item.edit.field.title.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("title")}
|
||||
rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />}
|
||||
/>
|
||||
@@ -53,12 +54,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.saveChanges")}</Button>
|
||||
</Group>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
@@ -66,7 +62,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.advancedOptions.title");
|
||||
},
|
||||
size: "lg",
|
||||
size: modalSizeForm,
|
||||
transitionProps: {
|
||||
duration: 0,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import { z } from "zod/v4";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zodErrorMap } from "@homarr/validation/form/i18n";
|
||||
@@ -145,9 +145,10 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Group justify="space-between">
|
||||
{showAppTab ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
@@ -163,17 +164,31 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
>
|
||||
{t("item.edit.advancedOptions.label")}
|
||||
</Button>
|
||||
{!showAppTab && (
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
) : (
|
||||
<ModalFormFooter
|
||||
onCancel={actions.closeModal}
|
||||
leftSection={
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess(options) {
|
||||
setAdvancedOptions(options);
|
||||
innerProps.onSuccessfulEdit({
|
||||
...innerProps.value,
|
||||
advancedOptions: options,
|
||||
});
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("item.edit.advancedOptions.label")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -214,5 +229,5 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: "lg",
|
||||
size: modalSizeForm,
|
||||
});
|
||||
Reference in new issue
Block a user