Merge origin/feat/add-integration-from-dashboard into insiders/ui-ux
This commit is contained in:
64 files changed
+2669
-455
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);
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user