From 1fcc149c911f36d94f4cf7eb994b16c93bc2306c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 25 May 2026 12:42:55 +0200 Subject: [PATCH] feat(seed): add demo user seeding with pre-placed widgets on dashboard board - seedBoardWidgetsAsync always places widgets (clock, weather, bookmarks, integration-linked, app widgets) on the default board - seedDemoUserAsync creates demo user + admin group, skips onboarding, reuses the existing dashboard board instead of creating a duplicate - Fix broken open-collective icon URL in defaultBookmarkApps --- packages/db/migrations/seed.ts | 156 +++++++++++++++--- .../definitions/src/widget-integration-map.ts | 2 +- 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 2a657b8fe..ceb269a3c 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,14 +1,20 @@ +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 ".."; @@ -25,7 +31,10 @@ import { groupMembers, groupPermissions, groups, + integrationItems, integrations, + itemLayouts, + items, layouts, onboarding, searchEngines, @@ -42,6 +51,7 @@ export const seedDataAsync = async (db: Database) => { await seedDefaultIntegrationsAsync(db); await seedDefaultAppsAsync(db); await seedDefaultBoardAsync(db); + await seedBoardWidgetsAsync(db); if (process.env.DEMO_MODE === "true") { await seedDemoUserAsync(db); @@ -312,30 +322,132 @@ const seedDemoUserAsync = async (db: Database) => { previousStep: "settings", }); - const boardId = createId(); - await db.insert(boards).values({ - id: boardId, - name: "default", - isPublic: false, - creatorId: userId, - }); - await db.insert(sections).values({ - id: createId(), - kind: "empty", - xOffset: 0, - yOffset: 0, - boardId, - }); - await db.insert(layouts).values({ - id: createId(), - name: "Base", - columnCount: 12, - breakpoint: 0, - boardId, - }); - await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); + const board = await db.query.boards.findFirst(); + if (board) { + await db.update(users).set({ homeBoardId: board.id }).where(eq(users.id, userId)); + } console.log( "Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.", ); }; + +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 ctx = { + xOffset: 0, + yOffset: 0, + rowMaxHeight: 0, + columnCount: layout.columnCount, + }; + + const placedWidgets = new Set(); + 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`); +}; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index a88f9d99c..8bd4d7734 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -95,6 +95,6 @@ export const defaultBookmarkApps = [ { name: "Support Homarr", href: "https://opencollective.com/homarr", - iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/open-collective.svg", + iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg", }, ];