From f46ccadbe4dead1a40af33212148f8198bc2086d Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 22 Aug 2026 13:08:27 +0200 Subject: [PATCH] feat: redesign demo dashboard (#6663) --- .../images/demo-dashboard-background.svg | 53 +++ packages/db/migrations/seed.ts | 305 ++++++++++++++---- packages/db/test/sqlite-migration.spec.ts | 190 +++++++++++ 3 files changed, 487 insertions(+), 61 deletions(-) create mode 100644 apps/nextjs/public/images/demo-dashboard-background.svg 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 @@ + + Abstract homelab network + A dark indigo field with subtle connected nodes and cyan signal paths. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 });