feat: redesign demo dashboard (#6663)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-22 13:08:27 +02:00
1 parent 6297ca4b16
commit f46ccadbe4
3 files changed
+487 -61

No files matched your search

@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
<title id="title">Abstract homelab network</title>
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
<defs>
<radialGradient id="base" cx="50%" cy="40%" r="80%">
<stop offset="0" stop-color="#172047"/>
<stop offset="0.48" stop-color="#0b1028"/>
<stop offset="1" stop-color="#060814"/>
</radialGradient>
<radialGradient id="indigoGlow">
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
</radialGradient>
<radialGradient id="cyanGlow">
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
</radialGradient>
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#748ffc" stop-opacity="0.05"/>
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur">
<feGaussianBlur stdDeviation="55"/>
</filter>
</defs>
<rect width="1920" height="1080" fill="url(#base)"/>
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
</g>
<g fill="#9aa8ff" opacity=".48">
<circle cx="248" cy="660" r="3"/>
<circle cx="718" cy="511" r="4"/>
<circle cx="1118" cy="287" r="3"/>
<circle cx="1450" cy="110" r="4"/>
</g>
<g fill="#5ee0ee" opacity=".42">
<circle cx="435" cy="850" r="3"/>
<circle cx="908" cy="604" r="4"/>
<circle cx="1350" cy="510" r="3"/>
<circle cx="1715" cy="285" r="4"/>
</g>
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
<circle cx="330" cy="90" r="260"/>
<circle cx="330" cy="90" r="345"/>
<circle cx="1680" cy="940" r="250"/>
<circle cx="1680" cy="940" r="335"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+244 -61
View File
@@ -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: `<p style="text-align: center"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg" width="28%"></p><h2 style="text-align: center">Quick runbook</h2><p style="text-align: center">Keep today close: signals first, controls beside them, distractions out.</p><ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Backups verified</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Review active alerts</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Connect your everyday services</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Tune this board to your routine</p></div></li></ul><p style="text-align: center"><em>Make it yours. Keep it useful.</em></p>`,
},
},
// 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: `<Stack p="md" gap="xs" h="100%">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0}><Text size="xs" c="indigo" fw={700}>COMMUNITY WORKSHOP</Text><Title order={3}>Made by Homarr users</Title></Stack>
<Badge color="indigo" variant="light">{data.workshop?.totalItems ?? "—"} shared</Badge>
</Group>
<Text size="xs" c="dimmed">Build Custom Widgets or dashboard CSS, then share it with the community.</Text>
{status.workshop?.loading ? <Stack gap="xs"><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /></Stack> : status.workshop?.error ? <Stack gap="xs"><Alert color="red" title="The Workshop could not be loaded">{status.workshop.error}</Alert><RefreshButton /></Stack> : (data.workshop?.items ?? []).length === 0 ? <Stack gap="xs"><Alert color="gray" title="Nothing shared yet">The first community submission could be yours.</Alert><RefreshButton /></Stack> : <Stack gap={4} style={{ flex: 1 }}>{(data.workshop?.items ?? []).slice(0, 4).map(submission => <Group key={submission.id} justify="space-between" wrap="nowrap"><Text size="sm" fw={600} lineClamp={1}>{submission.title}</Text><Group gap="xs" wrap="nowrap"><Text size="xs" c="dimmed">@{submission.authorName ?? "community"}</Text><Badge size="xs" color="gray" variant="light">{submission.upvotes ?? 0} ↑</Badge></Group></Group>)}{data.workshop?.totalItems > 4 ? <Text size="xs" c="dimmed">and {data.workshop.totalItems - 4} more in the Workshop</Text> : null}</Stack>}
<Anchor href="https://homarr.dev/docs/getting-started" target="_blank" rel="noreferrer" bg="indigo" c="white" fw={700} px="md" py="xs" radius="md" underline="never">Install Homarr now →</Anchor>
</Stack>`,
});
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));
+190
View File
@@ -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<number>(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<Record<string, { baseUrl: string }>>(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 });