fix: address PR review feedback
- Show error notification on setup failure instead of silent reset - Fix toggleKind stale closure by using prop array directly - Remove duplicate translations from en-gb.json (falls back to en.json) - Align seed board columnCount to 12 (codebase default) - Extract shared placeAllWidgetsAsync helper to @homarr/db/queries - Remove redundant onboardingFormSchema; reuse formSchema for both paths - Fix lint errors (optional chain, unused imports, unnecessary conditions)
This commit is contained in:
15 files changed
+233
-338
No files matched your search
@@ -14,7 +14,7 @@ import {
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronDown, IconBrandDocker } from "@tabler/icons-react";
|
||||
import { IconBrandDocker, IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
@@ -128,21 +128,17 @@ export const DockerDiscoveryIndicator = ({
|
||||
onChange={() => onToggleAllApps(!appsEnabled)}
|
||||
styles={{ label: { fontSize: "var(--mantine-font-size-xs)" } }}
|
||||
/>
|
||||
{appsEnabled && apps.map((app) => (
|
||||
<Group key={app.containerId} gap="xs" wrap="nowrap" py={2}>
|
||||
<Avatar
|
||||
size="xs"
|
||||
radius="sm"
|
||||
src={app.iconUrl}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="xs" c="dimmed" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{appsEnabled &&
|
||||
apps.map((app) => (
|
||||
<Group key={app.containerId} gap="xs" wrap="nowrap" py={2}>
|
||||
<Avatar size="xs" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="xs" c="dimmed" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -21,15 +21,14 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconArrowRight, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
|
||||
import { buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { UrlTemplateMode } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
||||
|
||||
import { DockerDiscoveryIndicator } from "./docker-discovery-panel";
|
||||
|
||||
type Phase = "select" | "configure" | "done";
|
||||
@@ -69,7 +68,7 @@ export const InitIntegrations = () => {
|
||||
|
||||
const dockerApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!dockerData || dockerData.status !== "success" || dockerApplied.current) return;
|
||||
if (dockerData?.status !== "success" || dockerApplied.current) return;
|
||||
dockerApplied.current = true;
|
||||
|
||||
const newDrafts = new Map(drafts);
|
||||
@@ -132,6 +131,10 @@ export const InitIntegrations = () => {
|
||||
await setupIntegrations();
|
||||
router.refresh();
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("configure.error.title"),
|
||||
message: t("configure.error.message"),
|
||||
});
|
||||
setPhase("select");
|
||||
}
|
||||
};
|
||||
@@ -192,7 +195,9 @@ export const InitIntegrations = () => {
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{t("urlTemplate.label")}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("urlTemplate.label")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
placeholder={t("urlTemplate.placeholder")}
|
||||
@@ -216,7 +221,11 @@ export const InitIntegrations = () => {
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<IntegrationMultiSelectGrid selectedKinds={selectedKinds} onSelectionChange={handleSelectionChange} onboarding />
|
||||
<IntegrationMultiSelectGrid
|
||||
selectedKinds={selectedKinds}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onboarding
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
||||
@@ -280,11 +289,7 @@ const ConfigurePhase = ({
|
||||
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
||||
</Group>
|
||||
<Tooltip label={t("configure.helpers.tooltip")}>
|
||||
<ActionIcon
|
||||
variant={helpersOpen ? "light" : "subtle"}
|
||||
color="gray"
|
||||
onClick={toggleHelpers}
|
||||
>
|
||||
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
|
||||
<IconSettings size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -298,7 +303,11 @@ const ConfigurePhase = ({
|
||||
</Stack>
|
||||
|
||||
<Collapse in={helpersOpen}>
|
||||
<Stack gap="xs" p="xs" style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}>
|
||||
<Stack
|
||||
gap="xs"
|
||||
p="xs"
|
||||
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("configure.helpers.description")}
|
||||
</Text>
|
||||
@@ -322,7 +331,9 @@ const ConfigurePhase = ({
|
||||
</Group>
|
||||
{baseHost && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("urlTemplate.preview", { example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined) })}
|
||||
{t("urlTemplate.preview", {
|
||||
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -63,17 +63,6 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
const onboardingFormSchema = z
|
||||
.object({
|
||||
name: z.string().nonempty().max(127),
|
||||
url: z.string().nonempty(),
|
||||
secrets: z.array(z.object({ kind: z.string(), value: z.string() })),
|
||||
attemptSearchEngineCreation: z.boolean(),
|
||||
hasApp: z.boolean(),
|
||||
appHref: appHrefSchema,
|
||||
appId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const NewIntegrationForm = ({
|
||||
kind,
|
||||
initialUrl,
|
||||
@@ -93,7 +82,7 @@ export const NewIntegrationForm = ({
|
||||
if (hasUrlSecret) {
|
||||
url = "http://localhost";
|
||||
}
|
||||
const form = useZodForm(isOnboarding ? onboardingFormSchema : formSchema, {
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
name: initialName ?? getIntegrationName(kind),
|
||||
url,
|
||||
|
||||
@@ -68,7 +68,9 @@ export interface IntegrationGridItem {
|
||||
widgets: WidgetKind[];
|
||||
}
|
||||
|
||||
export const buildSortedIntegrations = (options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}): IntegrationGridItem[] =>
|
||||
export const buildSortedIntegrations = (
|
||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||
): IntegrationGridItem[] =>
|
||||
integrationKinds
|
||||
.filter((kind) => {
|
||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||
|
||||
@@ -34,18 +34,21 @@ export const IntegrationMultiSelectGrid = ({
|
||||
|
||||
const toggleKind = useCallback(
|
||||
(kind: IntegrationKind) => {
|
||||
const next = new Set(selectedKinds);
|
||||
if (next.has(kind)) {
|
||||
next.delete(kind);
|
||||
const current = new Set(selectedKindsArray);
|
||||
if (current.has(kind)) {
|
||||
current.delete(kind);
|
||||
} else {
|
||||
next.add(kind);
|
||||
current.add(kind);
|
||||
}
|
||||
onSelectionChange(Array.from(next));
|
||||
onSelectionChange(Array.from(current));
|
||||
},
|
||||
[selectedKinds, onSelectionChange],
|
||||
[selectedKindsArray, onSelectionChange],
|
||||
);
|
||||
|
||||
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration, onboarding }), [enableMockIntegration, onboarding]);
|
||||
const integrations = useMemo(
|
||||
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
|
||||
[enableMockIntegration, onboarding],
|
||||
);
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq, like, or } from "@homarr/db";
|
||||
import { placeAllWidgetsAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
apps,
|
||||
groups,
|
||||
@@ -18,19 +17,15 @@ import {
|
||||
onboarding,
|
||||
} from "@homarr/db/schema";
|
||||
import {
|
||||
defaultBookmarkApps,
|
||||
defaultWidgetConfigs,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
extractContainerImageName,
|
||||
getIconUrl,
|
||||
getWidgetKindsForIntegration,
|
||||
integrationKinds,
|
||||
integrationSecretKinds,
|
||||
matchIntegrationKindFromContainer,
|
||||
onboardingSteps,
|
||||
} from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { zodEnumFromArray } from "@homarr/validation/enums";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc";
|
||||
@@ -57,67 +52,6 @@ const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): Suggest
|
||||
return { url: "", publishedPort: null };
|
||||
};
|
||||
|
||||
const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
|
||||
|
||||
const getWidgetConfig = (kind: WidgetKind) => widgetConfigMap.get(kind);
|
||||
|
||||
interface PlaceWidgetContext {
|
||||
db: Database;
|
||||
boardId: string;
|
||||
sectionId: string;
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
rowMaxHeight: number;
|
||||
}
|
||||
|
||||
const placeWidgetAsync = async (
|
||||
ctx: PlaceWidgetContext,
|
||||
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 ctx.db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId: ctx.boardId,
|
||||
kind,
|
||||
options: options ?? emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
await ctx.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 ctx.db.insert(integrationItems).values({
|
||||
itemId,
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
|
||||
ctx.xOffset += itemWidth;
|
||||
};
|
||||
|
||||
export const onboardRouter = createTRPCRouter({
|
||||
currentStep: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getOnboardingOrFallbackAsync(ctx.db);
|
||||
@@ -211,7 +145,14 @@ export const onboardRouter = createTRPCRouter({
|
||||
}),
|
||||
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 }[],
|
||||
integrations: [] as {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
kind: IntegrationKind;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}[],
|
||||
apps: [] as { containerId: string; containerName: string; suggestedUrl: string; iconUrl: string | null }[],
|
||||
};
|
||||
|
||||
@@ -227,9 +168,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host }) => {
|
||||
const containerList = await instance.listContainers({ all: true });
|
||||
return containerList
|
||||
.filter((c) => !(dockerLabels.hide in c.Labels))
|
||||
.map((c) => ({ ...c, host }));
|
||||
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -240,9 +179,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
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 dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
|
||||
|
||||
const seenKinds = new Set<IntegrationKind>();
|
||||
const discoveredIntegrations = emptyResult.integrations;
|
||||
@@ -346,68 +283,12 @@ export const onboardRouter = createTRPCRouter({
|
||||
await db.delete(items).where(eq(items.boardId, board.id));
|
||||
}
|
||||
|
||||
const widgetCtx: PlaceWidgetContext = {
|
||||
await placeAllWidgetsAsync(
|
||||
db,
|
||||
boardId: board.id,
|
||||
sectionId: section.id,
|
||||
layoutId: layout.id,
|
||||
columnCount: layout.columnCount,
|
||||
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 = getWidgetConfig(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(
|
||||
widgetCtx,
|
||||
widgetKind,
|
||||
matchingIds,
|
||||
options,
|
||||
config && {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const app of allApps) {
|
||||
const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true });
|
||||
await placeWidgetAsync(widgetCtx, "app", [], appOptions, { 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) continue;
|
||||
if (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(widgetCtx, config.kind, [], options ? superjson.stringify(options) : undefined, {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
});
|
||||
}
|
||||
{ boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount },
|
||||
allIntegrations,
|
||||
allApps,
|
||||
);
|
||||
}
|
||||
|
||||
await nextOnboardingStepAsync(db, undefined);
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import superjson from "superjson";
|
||||
|
||||
import { createId, objectKeys } from "@homarr/common";
|
||||
import {
|
||||
createDocumentationLink,
|
||||
credentialsAdminGroup,
|
||||
defaultBookmarkApps,
|
||||
defaultWidgetConfigs,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
getIntegrationDefaultUrl,
|
||||
getIntegrationName,
|
||||
getWidgetKindsForIntegration,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
} from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { getMaxGroupPositionAsync } from "../queries";
|
||||
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
@@ -31,9 +25,7 @@ import {
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationItems,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
onboarding,
|
||||
@@ -265,7 +257,7 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
@@ -351,103 +343,12 @@ const seedBoardWidgetsAsync = async (db: Database) => {
|
||||
const allIntegrations = await db.query.integrations.findMany();
|
||||
const allApps = await db.query.apps.findMany();
|
||||
|
||||
const ctx = {
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
rowMaxHeight: 0,
|
||||
columnCount: layout.columnCount,
|
||||
};
|
||||
const count = await placeAllWidgetsAsync(
|
||||
db,
|
||||
{ boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount },
|
||||
allIntegrations,
|
||||
allApps,
|
||||
);
|
||||
|
||||
const placedWidgets = new Set<WidgetKind>();
|
||||
const widgetConfigMap = new Map(defaultWidgetConfigs.map((cfg) => [cfg.kind, cfg]));
|
||||
|
||||
const placeWidgetAsync = async (
|
||||
kind: WidgetKind,
|
||||
linkedIntegrationIds: string[],
|
||||
options?: string,
|
||||
size?: { width: number; height: number },
|
||||
) => {
|
||||
const width = size?.width ?? 2;
|
||||
const height = size?.height ?? 2;
|
||||
|
||||
if (ctx.xOffset + width > ctx.columnCount) {
|
||||
ctx.xOffset = 0;
|
||||
ctx.yOffset += ctx.rowMaxHeight;
|
||||
ctx.rowMaxHeight = 0;
|
||||
}
|
||||
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId: board.id,
|
||||
kind,
|
||||
options: options ?? emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId: section.id,
|
||||
layoutId: layout.id,
|
||||
xOffset: ctx.xOffset,
|
||||
yOffset: ctx.yOffset,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
for (const integrationId of linkedIntegrationIds) {
|
||||
await db.insert(integrationItems).values({ itemId, integrationId });
|
||||
}
|
||||
|
||||
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, height);
|
||||
ctx.xOffset += width;
|
||||
};
|
||||
|
||||
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(
|
||||
widgetKind,
|
||||
matchingIds,
|
||||
options,
|
||||
config && { width: config.width, height: config.height },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const app of allApps) {
|
||||
await placeWidgetAsync("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(config.kind, [], options ? superjson.stringify(options) : undefined, {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Placed ${placedWidgets.size + allApps.length} widgets on board`);
|
||||
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;
|
||||
};
|
||||
@@ -3,8 +3,7 @@ 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) ?? "";
|
||||
export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||
|
||||
const extractIconSlug = (iconUrl: string): string => {
|
||||
const filename = iconUrl.split("/").pop() ?? "";
|
||||
|
||||
@@ -4,8 +4,7 @@ import { getIntegrationDefaultPort } from "./integration";
|
||||
|
||||
export type UrlTemplateMode = "hostPort" | "subdomain";
|
||||
|
||||
const getSlugForKind = (kind: IntegrationKind): string =>
|
||||
integrationIconSlugs[kind] ?? kind.toLowerCase();
|
||||
const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind];
|
||||
|
||||
export const buildIntegrationUrl = (
|
||||
kind: IntegrationKind,
|
||||
|
||||
@@ -473,7 +473,7 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef
|
||||
|
||||
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
|
||||
const definition = integrationDefs[kind];
|
||||
if (!("apiKeySettingsPath" in definition) || !definition.apiKeySettingsPath) return null;
|
||||
if (!("apiKeySettingsPath" in definition)) return null;
|
||||
const base = integrationUrl.replace(/\/+$/, "");
|
||||
if (!base) return null;
|
||||
return `${base}${definition.apiKeySettingsPath}`;
|
||||
|
||||
@@ -24,8 +24,6 @@ describe("getIntegrationApiKeyUrl", () => {
|
||||
});
|
||||
|
||||
it("strips trailing slash from base URL", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe(
|
||||
"http://sonarr.local/settings/general",
|
||||
);
|
||||
expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe("http://sonarr.local/settings/general");
|
||||
});
|
||||
});
|
||||
@@ -90,40 +90,6 @@
|
||||
"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...",
|
||||
"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",
|
||||
"preview": "e.g. {example}",
|
||||
"mode": {
|
||||
"subdomain": "Subdomain",
|
||||
"hostPort": "Host:Port"
|
||||
}
|
||||
},
|
||||
"docker": {
|
||||
"detected": "Docker detected {count} services",
|
||||
"detectedBreakdown": "{integrations} integrations · {apps} apps",
|
||||
"appBadge": "App",
|
||||
"appSectionLabel": "Also add {count} apps to dashboard"
|
||||
}
|
||||
},
|
||||
"finish": {
|
||||
"title": "Finish set-up",
|
||||
"subtitle": "Congratulations, you are ready to go!",
|
||||
@@ -764,9 +730,6 @@
|
||||
},
|
||||
"appHref": {
|
||||
"placeholder": "Custom application URL"
|
||||
},
|
||||
"apiKeySettings": {
|
||||
"label": "Get API key"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
@@ -102,6 +102,10 @@
|
||||
"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."
|
||||
|
||||
Reference in new issue
Block a user