diff --git a/apps/nextjs/public/images/demo-dashboard-background.svg b/apps/nextjs/public/images/demo-dashboard-background.svg
new file mode 100644
index 000000000..8dd135573
--- /dev/null
+++ b/apps/nextjs/public/images/demo-dashboard-background.svg
@@ -0,0 +1,53 @@
+
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index 3453a75fd..57d3fea51 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -514,6 +514,9 @@ const seedDefaultBoardAsync = async (db: Database) => {
interface DemoWidget {
kind: WidgetKind;
+ section?: "network" | "right";
+ xOffset: number;
+ yOffset: number;
width: number;
height: number;
needsIntegration: boolean;
@@ -562,9 +565,9 @@ const demoApps = [
href: "https://portainer.io",
},
{
- name: "Home Assistant",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
- href: "https://home-assistant.io",
+ name: "Uptime Kuma",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg",
+ href: "https://uptime.kuma.pet",
},
{
name: "Nextcloud",
@@ -583,69 +586,166 @@ const demoApps = [
},
] as const;
-const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
- // Row 1: calendar + downloads + clock = 12
- { kind: "calendar", width: 5, height: 3, needsIntegration: true },
- { kind: "downloads", width: 5, height: 3, needsIntegration: true },
- { kind: "clock", width: 2, height: 1, needsIntegration: false },
- // Row 2: healthMonitoring + bookmarks = 12
- { kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
+const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [
+ // Daily focus
+ { kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true },
{
- kind: "bookmarks",
- width: 6,
+ kind: "weather",
+ xOffset: 2,
+ yOffset: 0,
+ width: 2,
+ height: 2,
+ needsIntegration: false,
+ options: {
+ location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
+ hasForecast: false,
+ showHumidity: false,
+ showCurrentWindSpeed: false,
+ showCity: true,
+ animateIcons: false,
+ },
+ },
+ {
+ kind: "clock",
+ xOffset: 4,
+ yOffset: 0,
+ width: 1,
+ height: 2,
+ needsIntegration: false,
+ options: { customTitleToggle: false, showDate: false, customTimeFormat: "HH:mm" },
+ },
+ {
+ kind: "timer",
+ xOffset: 5,
+ yOffset: 0,
+ width: 2,
+ height: 1,
+ needsIntegration: false,
+ options: {
+ mode: "pomodoro",
+ focusMinutes: 25,
+ shortBreakMinutes: 5,
+ longBreakMinutes: 15,
+ sessionsBeforeLongBreak: 4,
+ autoStartBreaks: false,
+ autoStartFocus: false,
+ },
+ },
+ {
+ kind: "airQuality",
+ xOffset: 5,
+ yOffset: 1,
+ width: 2,
+ height: 1,
+ needsIntegration: false,
+ options: {
+ location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
+ aqiStandard: "european",
+ showUv: false,
+ showPollutants: false,
+ showPollen: false,
+ },
+ },
+ { kind: "downloads", xOffset: 7, yOffset: 0, width: 5, height: 2, needsIntegration: true },
+
+ // Homarr workspace
+ {
+ kind: "notebook",
+ xOffset: 0,
+ yOffset: 2,
+ width: 5,
height: 3,
needsIntegration: false,
- options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
+ options: {
+ showToolbar: false,
+ allowReadOnlyCheck: true,
+ content: `

Quick runbook
Keep today close: signals first, controls beside them, distractions out.
Make it yours. Keep it useful.
`,
+ },
},
- // Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
- { kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
- { kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
- { kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
- // Row 4: dnsHoleControls + mediaReleases + notifications = 12
- { kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
- { kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
- { kind: "notifications", width: 6, height: 2, needsIntegration: true },
- // Row 5: requestList + requestStats + indexerManager = 12
- { kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
- { kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
- { kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
- // Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
- { kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
- { kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
+ { kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true },
+ { kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false },
+
+ // Around the centered network container
+ { kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true },
+ { kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true },
+ { kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true },
+ { kind: "mediaRequests-requestStats", xOffset: 9, yOffset: 7, width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
- width: 6,
+ xOffset: 0,
+ yOffset: 9,
+ width: 3,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
- maximumAmountPosts: 20,
+ maximumAmountPosts: 12,
textLinesClamp: 2,
- hideDescription: false,
+ hideDescription: true,
},
},
- // Row 7-8: app widgets (2x1 each, 6 per row = 12)
+ { kind: "indexerManager", xOffset: 9, yOffset: 9, width: 3, height: 2, needsIntegration: true },
+
+ // Infrastructure and activity
+ { kind: "dockerContainers", xOffset: 0, yOffset: 11, width: 4, height: 3, needsIntegration: false },
+ { kind: "mediaReleases", xOffset: 4, yOffset: 11, width: 4, height: 3, needsIntegration: true },
+
+ // Community Workshop
+ {
+ kind: "customApi",
+ xOffset: 8,
+ yOffset: 11,
+ width: 4,
+ height: 3,
+ needsIntegration: false,
+ options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 },
+ },
+
+ // Network stuff container
+ {
+ kind: "healthMonitoring",
+ section: "network",
+ xOffset: 0,
+ yOffset: 0,
+ width: 6,
+ height: 2,
+ needsIntegration: true,
+ },
+ {
+ kind: "dnsHoleSummary",
+ section: "network",
+ xOffset: 0,
+ yOffset: 2,
+ width: 3,
+ height: 2,
+ needsIntegration: true,
+ options: { layout: "grid", usePiHoleColors: false },
+ },
+ {
+ kind: "beszelSystemStats",
+ section: "network",
+ xOffset: 3,
+ yOffset: 2,
+ width: 3,
+ height: 2,
+ needsIntegration: true,
+ },
+ { kind: "notifications", section: "network", xOffset: 0, yOffset: 4, width: 2, height: 2, needsIntegration: true },
+ { kind: "beszelAlerts", section: "network", xOffset: 2, yOffset: 4, width: 4, height: 2, needsIntegration: true },
+
+ // Right app rail
...appIds.map(
- (appId): DemoWidget => ({
+ (appId, index): DemoWidget => ({
kind: "app",
- width: 2,
+ section: "right",
+ xOffset: 0,
+ yOffset: index,
+ width: 1,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
- // Row 9: beszelSystemGrid + beszelAlerts = 12
- { kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
- { kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
- // Row 10: beszelSystemTable + beszelSystemStats = 12
- { kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
- { kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
- // Row 11: notebook + dockerContainers + weather = 12
- { kind: "notebook", width: 4, height: 4, needsIntegration: false },
- { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
- { kind: "weather", width: 2, height: 1, needsIntegration: false },
- // Row 12: assistant
- { kind: "assistant", width: 3, height: 3, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => {
@@ -710,44 +810,128 @@ const seedDemoUserAsync = async (db: Database) => {
});
}
+ const customWidgetDefinitionId = createId();
+ const customWidgetDefinition = customWidgetDefinitionSchema.parse({
+ $schema: "homarr-custom-widget-v2",
+ name: "Community Workshop",
+ description: "Live Custom Widget and Custom CSS submissions shared by the Homarr community.",
+ sources: {
+ default: {
+ name: "Homarr Workshop",
+ baseUrl: "https://v2.preview.homarr.dev",
+ networkScope: "public",
+ auth: "none",
+ },
+ },
+ requests: {
+ workshop: {
+ path: "/api/collections/workshop_listings/records",
+ query: { perPage: 50 },
+ cacheSeconds: 300,
+ },
+ },
+ options: {},
+ template: `
+
+ COMMUNITY WORKSHOPMade by Homarr users
+ {data.workshop?.totalItems ?? "—"} shared
+
+ Build Custom Widgets or dashboard CSS, then share it with the community.
+ {status.workshop?.loading ? : status.workshop?.error ? {status.workshop.error} : (data.workshop?.items ?? []).length === 0 ? The first community submission could be yours. : {(data.workshop?.items ?? []).slice(0, 4).map(submission => {submission.title}@{submission.authorName ?? "community"}{submission.upvotes ?? 0} ↑)}{data.workshop?.totalItems > 4 ? and {data.workshop.totalItems - 4} more in the Workshop : null}}
+ Install Homarr now →
+`,
+ });
+ await db.insert(customWidgetDefinitions).values({
+ id: customWidgetDefinitionId,
+ name: customWidgetDefinition.name,
+ description: customWidgetDefinition.description ?? null,
+ iconUrl: null,
+ sources: SuperJSON.stringify(customWidgetDefinition.sources),
+ requests: SuperJSON.stringify(customWidgetDefinition.requests),
+ options: SuperJSON.stringify(customWidgetDefinition.options),
+ template: customWidgetDefinition.template,
+ enabled: true,
+ creatorId: userId,
+ });
+
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "default",
isPublic: false,
creatorId: userId,
+ pageTitle: "Homarr demo",
+ backgroundImageUrl: "/images/demo-dashboard-background.svg",
+ primaryColor: "#748FFC",
+ secondaryColor: "#3BC9DB",
+ opacity: 90,
+ itemRadius: "xl",
});
- const sectionId = createId();
+ const mainSectionId = createId();
await db.insert(sections).values({
- id: sectionId,
+ id: mainSectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
+ const rightSectionId = createId();
+ await db.insert(sections).values({
+ id: rightSectionId,
+ kind: "empty",
+ xOffset: 1,
+ yOffset: 0,
+ boardId,
+ });
+
+ const networkSectionId = createId();
+ await db.insert(sections).values({
+ id: networkSectionId,
+ kind: "container",
+ boardId,
+ options: SuperJSON.stringify({
+ title: "Network stuff",
+ customCssClasses: [],
+ borderColor: "",
+ showLabel: true,
+ collapsible: false,
+ showOpenAll: false,
+ scrollable: false,
+ }),
+ });
+
const layoutId = createId();
await db.insert(layouts).values({
id: layoutId,
name: "Base",
- columnCount: 12,
+ columnCount: 13,
+ rightGutterColumnCount: 1,
breakpoint: 768,
role: "base",
boardId,
});
- const demoWidgets = buildDemoWidgets(appIds);
- let xOffset = 0;
- let yOffset = 0;
- let rowMaxHeight = 0;
+ await db.insert(sectionLayouts).values({
+ sectionId: networkSectionId,
+ layoutId,
+ parentSectionId: mainSectionId,
+ xOffset: 3,
+ yOffset: 5,
+ width: 6,
+ height: 6,
+ });
+
+ const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
for (const widget of demoWidgets) {
- if (xOffset + widget.width > 12) {
- xOffset = 0;
- yOffset += rowMaxHeight;
- rowMaxHeight = 0;
+ let sectionId = mainSectionId;
+ if (widget.section === "right") {
+ sectionId = rightSectionId;
+ } else if (widget.section === "network") {
+ sectionId = networkSectionId;
}
- rowMaxHeight = Math.max(rowMaxHeight, widget.height);
+
const itemId = createId();
await db.insert(items).values({
id: itemId,
@@ -759,8 +943,8 @@ const seedDemoUserAsync = async (db: Database) => {
itemId,
sectionId,
layoutId,
- xOffset,
- yOffset,
+ xOffset: widget.xOffset,
+ yOffset: widget.yOffset,
width: widget.width,
height: widget.height,
});
@@ -770,7 +954,6 @@ const seedDemoUserAsync = async (db: Database) => {
integrationId,
});
}
- xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
diff --git a/packages/db/test/sqlite-migration.spec.ts b/packages/db/test/sqlite-migration.spec.ts
index ff21bbd9b..779f887f2 100644
--- a/packages/db/test/sqlite-migration.spec.ts
+++ b/packages/db/test/sqlite-migration.spec.ts
@@ -45,6 +45,196 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
connection.close();
});
+test("SQLite migrations seed the redesigned demo dashboard", async () => {
+ const previousDemoMode = process.env.DEMO_MODE;
+ process.env.DEMO_MODE = "true";
+ const connection = new BetterSqlite3(":memory:");
+ const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING });
+
+ try {
+ migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") });
+ await seedDataAsync(database as unknown as Database);
+
+ const demoUser = await database.query.users.findFirst({
+ where: (table, { eq }) => eq(table.name, "demo"),
+ });
+ if (!demoUser?.homeBoardId) throw new Error("Demo user or home board was not seeded");
+
+ const board = await database.query.boards.findFirst({
+ where: (table, { eq }) => eq(table.id, demoUser.homeBoardId ?? ""),
+ with: {
+ layouts: true,
+ sections: { with: { collapseStates: true, layouts: true } },
+ items: { with: { layouts: true } },
+ },
+ });
+ if (!board) throw new Error("Demo board was not seeded");
+
+ expect(board).toMatchObject({
+ pageTitle: "Homarr demo",
+ backgroundImageUrl: "/images/demo-dashboard-background.svg",
+ });
+ expect(board.layouts).toHaveLength(2);
+ expect(board.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ columnCount: 13, rightGutterColumnCount: 1, role: "base" }),
+ expect.objectContaining({ columnCount: 3, rightGutterColumnCount: 0, role: "mobile" }),
+ ]),
+ );
+ const baseLayout = board.layouts.find((layout) => layout.role === "base");
+ if (!baseLayout) throw new Error("Demo base layout was not seeded");
+
+ const mainSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 0);
+ const rightSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 1);
+ const networkSection = board.sections.find((section) => section.kind === "container");
+ if (!mainSection || !rightSection || !networkSection) throw new Error("Demo board sections were not seeded");
+
+ const networkLayout = networkSection.layouts.find((layout) => layout.layoutId === baseLayout.id);
+ expect(networkLayout).toMatchObject({
+ parentSectionId: mainSection.id,
+ xOffset: 3,
+ yOffset: 5,
+ width: 6,
+ height: 6,
+ });
+ if (!networkSection.options) throw new Error("Demo network section options were not seeded");
+ expect(SuperJSON.parse<{ title: string; collapsible: boolean }>(networkSection.options)).toMatchObject({
+ title: "Network stuff",
+ collapsible: false,
+ });
+ expect(networkSection.collapseStates).toEqual([]);
+
+ const itemByKind = (kind: string) => board.items.find((item) => item.kind === kind);
+ const expectItemLayout = (
+ kind: string,
+ xOffset: number,
+ yOffset: number,
+ width: number,
+ height: number,
+ sectionId = mainSection.id,
+ ) => {
+ const layout = itemByKind(kind)?.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
+ expect(layout).toMatchObject({ sectionId, xOffset, yOffset, width, height });
+ };
+ expectItemLayout("calendar", 0, 0, 2, 2);
+ expectItemLayout("weather", 2, 0, 2, 2);
+ expectItemLayout("clock", 4, 0, 1, 2);
+ expectItemLayout("timer", 5, 0, 2, 1);
+ expectItemLayout("airQuality", 5, 1, 2, 1);
+ expectItemLayout("downloads", 7, 0, 5, 2);
+ expectItemLayout("notebook", 0, 2, 5, 3);
+ expectItemLayout("beszelSystemGrid", 5, 2, 4, 3);
+ expectItemLayout("assistant", 9, 2, 3, 3);
+ expectItemLayout("mediaRequests-requestList", 0, 5, 3, 2);
+ expectItemLayout("mediaMissing", 9, 5, 3, 2);
+ expectItemLayout("mediaServer", 0, 7, 3, 2);
+ expectItemLayout("mediaRequests-requestStats", 9, 7, 3, 2);
+ expectItemLayout("rssFeed", 0, 9, 3, 2);
+ expectItemLayout("indexerManager", 9, 9, 3, 2);
+ expectItemLayout("dockerContainers", 0, 11, 4, 3);
+ expectItemLayout("mediaReleases", 4, 11, 4, 3);
+ expectItemLayout("customApi", 8, 11, 4, 3);
+
+ expectItemLayout("healthMonitoring", 0, 0, 6, 2, networkSection.id);
+ expectItemLayout("dnsHoleSummary", 0, 2, 3, 2, networkSection.id);
+ expectItemLayout("beszelSystemStats", 3, 2, 3, 2, networkSection.id);
+ expectItemLayout("notifications", 0, 4, 2, 2, networkSection.id);
+ expectItemLayout("beszelAlerts", 2, 4, 4, 2, networkSection.id);
+
+ for (const kind of ["weather", "airQuality"]) {
+ const item = itemByKind(kind);
+ if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`);
+ expect(SuperJSON.parse<{ location: { name: string } }>(item.options).location.name).toBe("Paris");
+ }
+
+ const expectFilledGrid = (
+ placements: { xOffset: number; yOffset: number; width: number; height: number }[],
+ columnCount: number,
+ ) => {
+ const rowCount = Math.max(...placements.map((placement) => placement.yOffset + placement.height));
+ const cells = Array.from({ length: rowCount }, () => Array(columnCount).fill(0));
+ for (const placement of placements) {
+ for (let y = placement.yOffset; y < placement.yOffset + placement.height; y += 1) {
+ for (let x = placement.xOffset; x < placement.xOffset + placement.width; x += 1) {
+ const row = cells[y];
+ if (!row) throw new Error("Demo placement exceeds the grid height");
+ row[x] = (row[x] ?? 0) + 1;
+ }
+ }
+ }
+ expect(cells.every((row) => row.length === columnCount && row.every((cell) => cell === 1))).toBe(true);
+ };
+
+ const mainItemLayouts = board.items.flatMap((item) =>
+ item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === mainSection.id),
+ );
+ if (!networkLayout) throw new Error("Demo network section layout was not seeded");
+ expectFilledGrid([...mainItemLayouts, networkLayout], 12);
+
+ const rightRailItems = board.items.filter((item) =>
+ item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
+ );
+ expect(rightRailItems).toHaveLength(12);
+ const rightRailLayouts = rightRailItems.flatMap((item) =>
+ item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
+ );
+ expect(
+ rightRailItems.every((item) => {
+ const layout = item.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
+ return item.kind === "app" && layout?.width === 1 && layout.height === 1;
+ }),
+ ).toBe(true);
+ expect(
+ rightRailLayouts
+ .toSorted((first, second) => first.yOffset - second.yOffset)
+ .map(({ sectionId, xOffset, yOffset, width, height }) => ({
+ sectionId,
+ xOffset,
+ yOffset,
+ width,
+ height,
+ })),
+ ).toEqual(
+ Array.from({ length: 12 }, (_, yOffset) => ({
+ sectionId: rightSection.id,
+ xOffset: 0,
+ yOffset,
+ width: 1,
+ height: 1,
+ })),
+ );
+ expectFilledGrid(rightRailLayouts, 1);
+
+ const networkItems = board.items.filter((item) =>
+ item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === networkSection.id),
+ );
+ expect(networkItems.map((item) => item.kind).toSorted()).toEqual(
+ ["beszelAlerts", "beszelSystemStats", "dnsHoleSummary", "healthMonitoring", "notifications"].toSorted(),
+ );
+ const networkItemLayouts = networkItems.flatMap((item) =>
+ item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === networkSection.id),
+ );
+ expectFilledGrid(networkItemLayouts, 6);
+
+ const workshopDefinition = await database.query.customWidgetDefinitions.findFirst({
+ where: (table, { eq }) => eq(table.creatorId, demoUser.id),
+ });
+ if (!workshopDefinition) throw new Error("Demo Workshop definition was not seeded");
+ expect(workshopDefinition.name).toBe("Community Workshop");
+ expect(SuperJSON.parse>(workshopDefinition.sources).default?.baseUrl).toBe(
+ "https://v2.preview.homarr.dev",
+ );
+
+ const workshopItem = itemByKind("customApi");
+ if (!workshopItem) throw new Error("Demo Workshop item was not seeded");
+ expect(SuperJSON.parse<{ definitionId: string }>(workshopItem.options).definitionId).toBe(workshopDefinition.id);
+ } finally {
+ connection.close();
+ if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
+ else process.env.DEMO_MODE = previousDemoMode;
+ }
+});
+
test("Custom Widget v2 migration preserves v1 data and board references", () => {
const connection = new BetterSqlite3(":memory:");
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });