feat: redesign demo dashboard (#6663)
This commit is contained in:
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
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user