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
This commit is contained in:
Thomas Camlong committed 2026-05-25 12:42:55 +02:00
1 parent 9377e4b2f8
commit 1fcc149c91
2 files changed
+135 -23

No files matched your search

+134 -22
View File
@@ -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<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`);
};
@@ -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",
},
];