diff --git a/apps/docs/docs/advanced/command-line/index.mdx b/apps/docs/docs/advanced/command-line/index.mdx
index 04024254f..c6b8ce69c 100644
--- a/apps/docs/docs/advanced/command-line/index.mdx
+++ b/apps/docs/docs/advanced/command-line/index.mdx
@@ -36,6 +36,8 @@ pnpm cli harness setup feature-board
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
+The demo board includes every available widget kind. Integration-backed widgets use the bundled mock integration, while network or media examples use deterministic demo-safe sources where possible.
+
```sh
pnpm cli harness port feature-board
```
diff --git a/apps/nextjs/src/app/api/demo-assets/overview/route.ts b/apps/nextjs/src/app/api/demo-assets/overview/route.ts
new file mode 100644
index 000000000..401e97564
--- /dev/null
+++ b/apps/nextjs/src/app/api/demo-assets/overview/route.ts
@@ -0,0 +1,56 @@
+const overview = `
+
+
+
+
+
+
+
+
+ H
+ Your home, at a glance.
+ Bring services, media, infrastructure, notes and everyday tools together on one practical dashboard.
+ 59 widgets Mock data included Built for self-hosters
+
+
+`;
+
+export const GET = () =>
+ new Response(overview, {
+ headers: {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "Content-Type": "text/html; charset=utf-8",
+ },
+ });
diff --git a/apps/nextjs/src/app/api/demo-assets/video/file.mp4/route.ts b/apps/nextjs/src/app/api/demo-assets/video/file.mp4/route.ts
new file mode 100644
index 000000000..0ced33071
--- /dev/null
+++ b/apps/nextjs/src/app/api/demo-assets/video/file.mp4/route.ts
@@ -0,0 +1 @@
+export { GET } from "../route";
diff --git a/apps/nextjs/src/app/api/demo-assets/video/route.ts b/apps/nextjs/src/app/api/demo-assets/video/route.ts
new file mode 100644
index 000000000..c4ef08adb
--- /dev/null
+++ b/apps/nextjs/src/app/api/demo-assets/video/route.ts
@@ -0,0 +1,10 @@
+const videoBase64 =
+ "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";
+
+export const GET = () =>
+ new Response(Buffer.from(videoBase64, "base64"), {
+ headers: {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "Content-Type": "video/mp4",
+ },
+ });
diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts
index d431c0de9..5266e455a 100644
--- a/packages/api/src/router/widgets/anchor-notes.ts
+++ b/packages/api/src/router/widgets/anchor-notes.ts
@@ -3,7 +3,12 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
-import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
+import {
+ anchorNotesListInputSchema,
+ anchorNoteUpdateInputSchema,
+ createIntegrationAsync,
+ mockWidgetData,
+} from "@homarr/integrations";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -40,34 +45,60 @@ const normalizeAnchorContent = (content: string | undefined) => {
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
- .concat(createOneIntegrationMiddleware("query", "anchor"))
+ .concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
- const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
+ if (ctx.integration.kind === "mock") {
+ const search = input.search?.toLowerCase();
+ const notes = mockWidgetData.anchorNotes.filter((note) => !search || note.title.toLowerCase().includes(search));
+ return notes.slice(0, input.limit ?? 50);
+ }
+
+ const handler = anchorNotesListRequestHandler.handler(
+ { ...ctx.integration, kind: "anchor" },
+ {
+ ...input,
+ limit: input.limit ?? 50,
+ },
+ );
const { data } = await handler.getDataAsync();
return data;
}),
getNote: publicProcedure
- .concat(createOneIntegrationMiddleware("query", "anchor"))
+ .concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
- const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
+ if (ctx.integration.kind === "mock") return mockWidgetData.anchorNote;
+
+ const handler = anchorNoteRequestHandler.handler(
+ { ...ctx.integration, kind: "anchor" },
+ { noteId: input.noteId },
+ );
const { data } = await handler.getDataAsync();
return data;
}),
updateNote: protectedProcedure
- .concat(createOneIntegrationMiddleware("interact", "anchor"))
+ .concat(createOneIntegrationMiddleware("interact", "anchor", "mock"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
- const integrationInstance = await createIntegrationAsync(ctx.integration);
const normalizedContent = normalizeAnchorContent(input.content);
const isContentNormalized =
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
+ if (ctx.integration.kind === "mock") {
+ return {
+ ...mockWidgetData.anchorNote,
+ ...(input.title !== undefined ? { title: input.title } : {}),
+ ...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
+ };
+ }
+
+ const integrationInstance = await createIntegrationAsync({ ...ctx.integration, kind: "anchor" });
+
try {
const updatedNote = await integrationInstance.updateNoteAsync({
noteId: input.noteId,
diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts
index d71b7546a..d8aa865bc 100644
--- a/packages/api/src/router/widgets/archive-team-warrior.ts
+++ b/packages/api/src/router/widgets/archive-team-warrior.ts
@@ -1,13 +1,18 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
+import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
- .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
+ .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock"))
.query(async ({ ctx }) => {
- const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
+ if (ctx.integration.kind === "mock") {
+ return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date() };
+ }
+
+ const handler = archiveTeamWarriorRequestHandler.handler({ ...ctx.integration, kind: "archiveTeamWarrior" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts
index 14fd7d49c..cc3e25ddd 100644
--- a/packages/api/src/router/widgets/audio-stats.ts
+++ b/packages/api/src/router/widgets/audio-stats.ts
@@ -1,14 +1,16 @@
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
+import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
- .concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
+ .concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
+ if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
const fetchByKind = {
navidrome: async () => {
diff --git a/packages/api/src/router/widgets/bazarr.ts b/packages/api/src/router/widgets/bazarr.ts
index c47ad4173..e9f8093b5 100644
--- a/packages/api/src/router/widgets/bazarr.ts
+++ b/packages/api/src/router/widgets/bazarr.ts
@@ -1,4 +1,5 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
+import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -12,9 +13,11 @@ export const bazarrRouter = createTRPCRouter({
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
- .concat(createOneIntegrationMiddleware("query", "bazarr"))
+ .concat(createOneIntegrationMiddleware("query", "bazarr", "mock"))
.query(async ({ ctx }) => {
- const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
+ if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
+
+ const innerHandler = bazarrBadgesRequestHandler.handler({ ...ctx.integration, kind: "bazarr" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts
index 133d36f14..e545a2f14 100644
--- a/packages/api/src/router/widgets/coolify.ts
+++ b/packages/api/src/router/widgets/coolify.ts
@@ -1,4 +1,5 @@
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
+import { mockWidgetData } from "@homarr/integrations";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
@@ -16,12 +17,22 @@ interface CoolifyInstanceResult {
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "coolify"))
+ .concat(createManyIntegrationMiddleware("query", "coolify", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration): Promise => {
- const innerHandler = coolifyRequestHandler.handler(integration, {});
+ if (integration.kind === "mock") {
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ instanceInfo: mockWidgetData.coolify,
+ updatedAt: new Date(),
+ };
+ }
+
+ const innerHandler = coolifyRequestHandler.handler({ ...integration, kind: "coolify" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts
index 335f75d09..f7de85157 100644
--- a/packages/api/src/router/widgets/firewall.ts
+++ b/packages/api/src/router/widgets/firewall.ts
@@ -1,4 +1,5 @@
-import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory, type IntegrationKind } from "@homarr/definitions";
+import { mockWidgetData } from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
@@ -10,29 +11,48 @@ import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
-const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
+const firewallMiddleware = createManyIntegrationMiddleware(
+ "query",
+ ...getIntegrationKindsByCategory("firewall"),
+ "mock",
+);
-const queryFirewall = <
- THandler extends {
- handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> };
+interface FirewallResult {
+ integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
+ summary: TSummary;
+ error?: string;
+}
+
+const queryFirewall = (
+ handler: {
+ handler: (
+ integration: any,
+ input: Record,
+ ) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
},
->(
- handler: THandler,
- createFallback: () => Awaited["getDataAsync"]>>["data"],
+ createFallback: () => TSummary,
+ getMockData: () => TSummary,
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(
ctx.integrations,
- async (integration) => {
- const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
+ async (integration): Promise> => {
+ if (integration.kind === "mock") {
+ return {
+ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date() },
+ summary: getMockData(),
+ error: undefined,
+ };
+ }
+ const { data, timestamp } = await handler.handler({ ...integration, kind: "opnsense" }, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
- summary: data as Awaited["getDataAsync"]>>["data"],
- error: undefined as string | undefined,
+ summary: data,
+ error: undefined,
};
},
{
- fallback: (integration, error) => ({
+ fallback: (integration, error): FirewallResult => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
@@ -43,8 +63,24 @@ const queryFirewall = <
);
export const firewallRouter = createTRPCRouter({
- getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
- getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
- getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
- getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
+ getFirewallCpuStatus: queryFirewall(
+ firewallCpuRequestHandler,
+ () => ({ total: 0 }),
+ () => mockWidgetData.firewallCpu,
+ ),
+ getFirewallInterfacesStatus: queryFirewall(
+ firewallInterfacesRequestHandler,
+ () => [],
+ () => mockWidgetData.firewallInterfaces,
+ ),
+ getFirewallVersionStatus: queryFirewall(
+ firewallVersionRequestHandler,
+ () => ({ version: "Unknown" }),
+ () => mockWidgetData.firewallVersion,
+ ),
+ getFirewallMemoryStatus: queryFirewall(
+ firewallMemoryRequestHandler,
+ () => ({ used: 0, total: 0, percent: 0 }),
+ () => mockWidgetData.firewallMemory,
+ ),
});
diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts
index 136d0e111..75966c793 100644
--- a/packages/api/src/router/widgets/immich.ts
+++ b/packages/api/src/router/widgets/immich.ts
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { mockWidgetData } from "@homarr/integrations";
import {
immichAlbumRequestHandler,
immichAlbumsRequestHandler,
@@ -12,11 +13,12 @@ import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
- createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"));
+ createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
- const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
+ if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
+ const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -29,7 +31,13 @@ export const immichRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
- const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
+ if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbum;
+ const innerHandler = immichAlbumRequestHandler.handler(
+ { ...ctx.integration, kind: "immich" },
+ {
+ albumId: input.albumId,
+ },
+ );
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -42,7 +50,13 @@ export const immichRouter = createTRPCRouter({
)
.concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => {
- const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
+ if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
+ const innerHandler = immichAlbumsRequestHandler.handler(
+ { ...ctx.integration, kind: "immich" },
+ {
+ limit: input.limit,
+ },
+ );
const data = await innerHandler.getDataAsync();
return data.data;
}),
diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts
index 2a1bf40de..0adcb2fc5 100644
--- a/packages/api/src/router/widgets/paperless-ngx.ts
+++ b/packages/api/src/router/widgets/paperless-ngx.ts
@@ -1,12 +1,16 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
+import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
- getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
- const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
- const data = await innerHandler.getDataAsync();
- return data.data;
- }),
+ getStats: publicProcedure
+ .concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock"))
+ .query(async ({ ctx }) => {
+ if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
+ const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
+ const data = await innerHandler.getDataAsync();
+ return data.data;
+ }),
});
diff --git a/packages/api/src/router/widgets/patchmon.ts b/packages/api/src/router/widgets/patchmon.ts
index aee758823..2ea98bb10 100644
--- a/packages/api/src/router/widgets/patchmon.ts
+++ b/packages/api/src/router/widgets/patchmon.ts
@@ -1,4 +1,5 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
+import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
@@ -12,9 +13,10 @@ export const patchmonRouter = createTRPCRouter({
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
},
})
- .concat(createOneIntegrationMiddleware("query", "patchmon"))
+ .concat(createOneIntegrationMiddleware("query", "patchmon", "mock"))
.query(async ({ ctx }) => {
- const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
+ if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
+ const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
const { data } = await innerHandler.getDataAsync();
return data;
}),
diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts
index 4730940a4..19c5c7179 100644
--- a/packages/api/src/router/widgets/speedtest-tracker.ts
+++ b/packages/api/src/router/widgets/speedtest-tracker.ts
@@ -1,4 +1,5 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
+import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -6,12 +7,22 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
+ .concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
- const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
+ if (integration.kind === "mock") {
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: mockWidgetData.speedtestTracker,
+ updatedAt: new Date(),
+ error: undefined,
+ };
+ }
+ const innerHandler = speedtestTrackerRequestHandler.handler({ ...integration, kind: "speedtestTracker" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts
index d96a02932..f5e03fb88 100644
--- a/packages/api/src/router/widgets/tracearr.ts
+++ b/packages/api/src/router/widgets/tracearr.ts
@@ -1,37 +1,50 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
+import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
- getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
- return await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const innerHandler = tracearrRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getDataAsync();
+ getDashboard: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "tracearr", "mock"))
+ .query(async ({ ctx }) => {
+ return await settleIntegrationQueries(
+ ctx.integrations,
+ async (integration) => {
+ if (integration.kind === "mock") {
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: mockWidgetData.tracearr,
+ updatedAt: new Date(),
+ error: undefined,
+ };
+ }
+ const innerHandler = tracearrRequestHandler.handler({ ...integration, kind: "tracearr" }, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- dashboard: data as typeof data | null,
- updatedAt: timestamp,
- error: undefined as string | undefined,
- };
- },
- {
- fallback: (integration, error) => ({
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- dashboard: null,
- updatedAt: new Date(0),
- error: toPublicIntegrationError(error),
- }),
- throwOnAllFailures: true,
- },
- );
- }),
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: data as typeof data | null,
+ updatedAt: timestamp,
+ error: undefined as string | undefined,
+ };
+ },
+ {
+ fallback: (integration, error) => ({
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: null,
+ updatedAt: new Date(0),
+ error: toPublicIntegrationError(error),
+ }),
+ throwOnAllFailures: true,
+ },
+ );
+ }),
});
diff --git a/packages/api/src/router/widgets/traefik.ts b/packages/api/src/router/widgets/traefik.ts
index ad7e8479d..72476a6b9 100644
--- a/packages/api/src/router/widgets/traefik.ts
+++ b/packages/api/src/router/widgets/traefik.ts
@@ -1,4 +1,5 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
+import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -13,12 +14,22 @@ export const traefikRouter = createTRPCRouter({
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
- .concat(createManyIntegrationMiddleware("query", "traefik"))
+ .concat(createManyIntegrationMiddleware("query", "traefik", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
- const innerHandler = traefikRequestHandler.handler(integration, {});
+ if (integration.kind === "mock") {
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: mockWidgetData.traefik,
+ updatedAt: new Date(),
+ error: undefined,
+ };
+ }
+ const innerHandler = traefikRequestHandler.handler({ ...integration, kind: "traefik" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
diff --git a/packages/api/src/router/widgets/umami.ts b/packages/api/src/router/widgets/umami.ts
index 3994f398e..9ce0bfbfa 100644
--- a/packages/api/src/router/widgets/umami.ts
+++ b/packages/api/src/router/widgets/umami.ts
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
-import { createIntegrationAsync } from "@homarr/integrations";
+import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
@@ -19,15 +19,18 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
- getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
- try {
- const instance = await createIntegrationAsync(ctx.integration);
- return await instance.getWebsitesAsync();
- } catch (error) {
- logger.warn(new Error("Failed to load websites", { cause: error }));
- return [];
- }
- }),
+ getWebsites: publicProcedure
+ .concat(createOneIntegrationMiddleware("query", "umami", "mock"))
+ .query(async ({ ctx }) => {
+ if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
+ try {
+ const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
+ return await instance.getWebsitesAsync();
+ } catch (error) {
+ logger.warn(new Error("Failed to load websites", { cause: error }));
+ return [];
+ }
+ }),
getVisitorStats: publicProcedure
.input(
@@ -37,14 +40,26 @@ export const umamiRouter = createTRPCRouter({
eventName: z.string().optional(),
}),
)
- .concat(createManyIntegrationMiddleware("query", "umami"))
+ .concat(createManyIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
- const innerHandler = umamiRequestHandler.handler(integration, {
- websiteId: input.websiteId,
- timeFrame: input.timeFrame,
- eventName: input.eventName,
- });
+ if (integration.kind === "mock") {
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ visitorStats: { ...mockWidgetData.umamiVisitorStats, timeFrame: input.timeFrame },
+ updatedAt: new Date(),
+ };
+ }
+ const innerHandler = umamiRequestHandler.handler(
+ { ...integration, kind: "umami" },
+ {
+ websiteId: input.websiteId,
+ timeFrame: input.timeFrame,
+ eventName: input.eventName,
+ },
+ );
const { data, timestamp } = await innerHandler.getDataAsync();
return {
@@ -59,10 +74,16 @@ export const umamiRouter = createTRPCRouter({
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
- .concat(createOneIntegrationMiddleware("query", "umami"))
+ .concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
+ if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
try {
- const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
+ const innerHandler = umamiEventNamesRequestHandler.handler(
+ { ...ctx.integration, kind: "umami" },
+ {
+ websiteId: input.websiteId,
+ },
+ );
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -81,14 +102,18 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
- .concat(createOneIntegrationMiddleware("query", "umami"))
+ .concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
+ if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
try {
- const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
- websiteId: input.websiteId,
- timeFrame: input.timeFrame,
- limit: input.limit,
- });
+ const innerHandler = umamiTopPagesRequestHandler.handler(
+ { ...ctx.integration, kind: "umami" },
+ {
+ websiteId: input.websiteId,
+ timeFrame: input.timeFrame,
+ limit: input.limit,
+ },
+ );
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -111,14 +136,18 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
- .concat(createOneIntegrationMiddleware("query", "umami"))
+ .concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
+ if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
try {
- const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
- websiteId: input.websiteId,
- timeFrame: input.timeFrame,
- limit: input.limit,
- });
+ const innerHandler = umamiTopReferrersRequestHandler.handler(
+ { ...ctx.integration, kind: "umami" },
+ {
+ websiteId: input.websiteId,
+ timeFrame: input.timeFrame,
+ limit: input.limit,
+ },
+ );
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -141,15 +170,21 @@ export const umamiRouter = createTRPCRouter({
eventNames: z.array(z.string()),
}),
)
- .concat(createOneIntegrationMiddleware("query", "umami"))
+ .concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
+ if (ctx.integration.kind === "mock") {
+ return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
+ }
try {
const sortedNames = [...input.eventNames].toSorted();
- const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
- websiteId: input.websiteId,
- timeFrame: input.timeFrame,
- eventNames: sortedNames,
- });
+ const innerHandler = umamiMultiEventRequestHandler.handler(
+ { ...ctx.integration, kind: "umami" },
+ {
+ websiteId: input.websiteId,
+ timeFrame: input.timeFrame,
+ eventNames: sortedNames,
+ },
+ );
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -166,12 +201,16 @@ export const umamiRouter = createTRPCRouter({
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
- .concat(createOneIntegrationMiddleware("query", "umami"))
+ .concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
+ if (ctx.integration.kind === "mock") return 7;
try {
- const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
- websiteId: input.websiteId,
- });
+ const innerHandler = umamiActiveVisitorsRequestHandler.handler(
+ { ...ctx.integration, kind: "umami" },
+ {
+ websiteId: input.websiteId,
+ },
+ );
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts
index e3082c284..a3c91f496 100644
--- a/packages/api/src/router/widgets/uptime-kuma.ts
+++ b/packages/api/src/router/widgets/uptime-kuma.ts
@@ -1,4 +1,5 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
+import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -6,12 +7,22 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
+ .concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
- const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
+ if (integration.kind === "mock") {
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: mockWidgetData.uptimeKuma,
+ updatedAt: new Date(),
+ error: undefined,
+ };
+ }
+ const innerHandler = uptimeKumaRequestHandler.handler({ ...integration, kind: "uptimeKuma" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts
index 276367440..79d896c0b 100644
--- a/packages/api/src/router/widgets/vpn.ts
+++ b/packages/api/src/router/widgets/vpn.ts
@@ -1,18 +1,40 @@
-import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory, type IntegrationKind } from "@homarr/definitions";
+import { mockWidgetData } from "@homarr/integrations";
+import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+interface VpnResult {
+ integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
+ summary: VpnSummary | null;
+ error?: string;
+}
+
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
- .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
+ .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
- async (integration) => {
- const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
+ async (integration): Promise => {
+ if (integration.kind === "mock") {
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: new Date(),
+ },
+ summary: mockWidgetData.vpn,
+ error: undefined,
+ };
+ }
+ const { data, timestamp } = await vpnSummaryHandler
+ .handler({ ...integration, kind: "gluetun" }, {})
+ .getDataAsync();
return {
integration: {
id: integration.id,
@@ -25,7 +47,7 @@ export const vpnRouter = createTRPCRouter({
};
},
{
- fallback: (integration, error) => ({
+ fallback: (integration, error): VpnResult => ({
integration: {
id: integration.id,
name: integration.name,
diff --git a/packages/api/src/router/widgets/wud.ts b/packages/api/src/router/widgets/wud.ts
index 4b510e660..2b06c41b8 100644
--- a/packages/api/src/router/widgets/wud.ts
+++ b/packages/api/src/router/widgets/wud.ts
@@ -1,4 +1,5 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
+import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -12,9 +13,10 @@ export const wudRouter = createTRPCRouter({
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
- .concat(createOneIntegrationMiddleware("query", "wud"))
+ .concat(createOneIntegrationMiddleware("query", "wud", "mock"))
.query(async ({ ctx }) => {
- const handler = wudStatsRequestHandler.handler(ctx.integration, {});
+ if (ctx.integration.kind === "mock") return { stats: mockWidgetData.wud, updatedAt: new Date() };
+ const handler = wudStatsRequestHandler.handler({ ...ctx.integration, kind: "wud" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index f47bac705..904800bc7 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -730,6 +730,165 @@ const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): D
{ kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
{ kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
+ // Extended integrations
+ { kind: "mediaTranscoding", xOffset: 0, yOffset: 14, width: 4, height: 3, needsIntegration: true },
+ { kind: "beszelSystemTable", xOffset: 4, yOffset: 14, width: 4, height: 3, needsIntegration: true },
+ { kind: "networkControllerSummary", xOffset: 8, yOffset: 14, width: 4, height: 3, needsIntegration: true },
+ {
+ kind: "networkControllerStatus",
+ xOffset: 0,
+ yOffset: 17,
+ width: 3,
+ height: 2,
+ needsIntegration: true,
+ options: { content: "wifi" },
+ },
+ { kind: "ups", xOffset: 3, yOffset: 17, width: 3, height: 2, needsIntegration: true },
+ {
+ kind: "smartHome-entityState",
+ xOffset: 6,
+ yOffset: 17,
+ width: 3,
+ height: 2,
+ needsIntegration: true,
+ options: { entityId: "sensor.homarr_demo", displayName: "Demo environment", entityUnit: "online" },
+ },
+ {
+ kind: "smartHome-executeAutomation",
+ xOffset: 9,
+ yOffset: 17,
+ width: 3,
+ height: 2,
+ needsIntegration: true,
+ options: { automationId: "automation.homarr_demo", displayName: "Run demo routine" },
+ },
+
+ // Everyday tools
+ { kind: "dnsHoleControls", xOffset: 0, yOffset: 19, width: 2, height: 2, needsIntegration: true },
+ {
+ kind: "bookmarks",
+ xOffset: 2,
+ yOffset: 19,
+ width: 4,
+ height: 2,
+ needsIntegration: false,
+ options: { title: "Launchpad", layout: "grid", items: appIds.slice(0, 6), openNewTab: true },
+ },
+ {
+ kind: "countdown",
+ xOffset: 6,
+ yOffset: 19,
+ width: 2,
+ height: 2,
+ needsIntegration: false,
+ options: {
+ events: [
+ {
+ id: "demo-new-year",
+ label: "New year 2030",
+ targetUtc: "2030-01-01T00:00:00.000Z",
+ startUtc: "2026-01-01T00:00:00.000Z",
+ timeZone: "Europe/Paris",
+ recurrence: "none",
+ },
+ ],
+ showProgress: true,
+ showSeconds: false,
+ },
+ },
+ { kind: "stockPrice", xOffset: 8, yOffset: 19, width: 2, height: 2, needsIntegration: false },
+ { kind: "releases", xOffset: 10, yOffset: 19, width: 2, height: 2, needsIntegration: false },
+ {
+ kind: "iframe",
+ xOffset: 0,
+ yOffset: 21,
+ width: 4,
+ height: 3,
+ needsIntegration: false,
+ options: { embedUrl: "/api/demo-assets/overview", allowScrolling: true },
+ },
+ {
+ kind: "video",
+ xOffset: 4,
+ yOffset: 21,
+ width: 4,
+ height: 3,
+ needsIntegration: false,
+ options: {
+ feedUrl: "/api/demo-assets/video/file.mp4",
+ hasAutoPlay: false,
+ isMuted: true,
+ hasControls: true,
+ },
+ },
+ { kind: "minecraftServerStatus", xOffset: 8, yOffset: 21, width: 2, height: 3, needsIntegration: false },
+ {
+ kind: "timetable",
+ xOffset: 10,
+ yOffset: 21,
+ width: 2,
+ height: 3,
+ needsIntegration: false,
+ options: { baseUrl: "https://search.ch", station: { value: "8507000", label: "Bern" } },
+ },
+
+ // Complete mock integration gallery
+ {
+ kind: "immich-albumCarousel",
+ xOffset: 0,
+ yOffset: 24,
+ width: 3,
+ height: 3,
+ needsIntegration: true,
+ options: { albumId: "demo-paris", rotationIntervalSeconds: 8, showPhotoInfo: true, randomizePhotos: false },
+ },
+ { kind: "coolify", xOffset: 3, yOffset: 24, width: 3, height: 3, needsIntegration: true },
+ { kind: "systemResources", xOffset: 6, yOffset: 24, width: 3, height: 3, needsIntegration: true },
+ { kind: "systemDisks", xOffset: 9, yOffset: 24, width: 3, height: 3, needsIntegration: true },
+
+ { kind: "immich-serverStats", xOffset: 0, yOffset: 27, width: 2, height: 3, needsIntegration: true },
+ { kind: "paperlessNgx", xOffset: 2, yOffset: 27, width: 2, height: 3, needsIntegration: true },
+ { kind: "patchmon", xOffset: 4, yOffset: 27, width: 2, height: 3, needsIntegration: true },
+ {
+ kind: "speedtestTracker",
+ xOffset: 6,
+ yOffset: 27,
+ width: 4,
+ height: 3,
+ needsIntegration: true,
+ options: { showLatestResult: true, showStats: true, showRecentResults: false, showPingGraph: false },
+ },
+ { kind: "audioStats", xOffset: 10, yOffset: 27, width: 2, height: 3, needsIntegration: true },
+
+ { kind: "uptimeKuma", xOffset: 0, yOffset: 30, width: 3, height: 2, needsIntegration: true },
+ { kind: "firewall", xOffset: 3, yOffset: 30, width: 3, height: 2, needsIntegration: true },
+ { kind: "vpn", xOffset: 6, yOffset: 30, width: 3, height: 2, needsIntegration: true },
+ { kind: "bazarr", xOffset: 9, yOffset: 30, width: 3, height: 2, needsIntegration: true },
+
+ { kind: "archiveTeamWarrior", xOffset: 0, yOffset: 32, width: 3, height: 2, needsIntegration: true },
+ { kind: "wud", xOffset: 3, yOffset: 32, width: 3, height: 2, needsIntegration: true },
+ {
+ kind: "anchorNote",
+ xOffset: 6,
+ yOffset: 32,
+ width: 2,
+ height: 2,
+ needsIntegration: true,
+ options: { noteId: "homarr-demo-note", showTitle: true, showUpdatedAt: true },
+ },
+ { kind: "tracearr", xOffset: 8, yOffset: 32, width: 4, height: 2, needsIntegration: true },
+
+ { kind: "traefik", xOffset: 0, yOffset: 34, width: 6, height: 2, needsIntegration: true },
+ {
+ kind: "umami",
+ xOffset: 6,
+ yOffset: 34,
+ width: 6,
+ height: 2,
+ needsIntegration: true,
+ options: { websiteId: "homarr-demo", timeFrame: "24h", viewMode: "chart", chartType: "bar" },
+ },
+
// Right app rail
...appIds.map(
(appId, index): DemoWidget => ({
diff --git a/packages/db/test/sqlite-migration.spec.ts b/packages/db/test/sqlite-migration.spec.ts
index 85b6a427b..4026d7f3a 100644
--- a/packages/db/test/sqlite-migration.spec.ts
+++ b/packages/db/test/sqlite-migration.spec.ts
@@ -7,6 +7,7 @@ import SuperJSON from "superjson";
import { expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
+import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import type { Database } from "..";
import { seedDataAsync } from "../migrations/seed";
@@ -45,7 +46,7 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
connection.close();
});
-test("SQLite migrations seed the redesigned demo dashboard", async () => {
+test("SQLite migrations seed the complete demo widget gallery", async () => {
const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true";
const connection = new BetterSqlite3(":memory:");
@@ -65,7 +66,7 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
with: {
layouts: true,
sections: { with: { collapseStates: true, layouts: true } },
- items: { with: { layouts: true } },
+ items: { with: { integrations: true, layouts: true } },
},
});
if (!board) throw new Error("Demo board was not seeded");
@@ -126,6 +127,18 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
expectItemLayout("notifications", 3, 9, 2, 2);
expectItemLayout("beszelAlerts", 5, 9, 4, 2);
+ expect(new Set(board.items.map((item) => item.kind))).toEqual(new Set(widgetKinds));
+ const mockIntegration = await database.query.integrations.findFirst({
+ where: (table, { eq }) => eq(table.kind, "mock"),
+ });
+ if (!mockIntegration) throw new Error("Demo mock integration was not seeded");
+ for (const item of board.items) {
+ if (widgetIntegrationSupport[item.kind] === undefined) continue;
+ expect(item.integrations).toEqual([
+ expect.objectContaining({ integrationId: mockIntegration.id, itemId: item.id }),
+ ]);
+ }
+
for (const kind of ["weather", "airQuality"]) {
const item = itemByKind(kind);
if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`);
diff --git a/packages/definitions/src/test/widget-integration-map.spec.ts b/packages/definitions/src/test/widget-integration-map.spec.ts
index 33407069e..9e21bbdcd 100644
--- a/packages/definitions/src/test/widget-integration-map.spec.ts
+++ b/packages/definitions/src/test/widget-integration-map.spec.ts
@@ -26,6 +26,12 @@ describe("native feature capability descriptor", () => {
}
});
+ test("supports the mock integration for every integration-backed widget", () => {
+ for (const capability of Object.values(nativeFeatureCapabilities)) {
+ expect(capability.integrations).toContain("mock");
+ }
+ });
+
test("derives optional connections and server selection limits", () => {
expect(widgetKindsWithOptionalIntegrations).toEqual(new Set(["calendar"]));
expect(widgetIntegrationLimits).toEqual({
diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts
index b83fa3706..b9fbfb929 100644
--- a/packages/definitions/src/widget-integration-map.ts
+++ b/packages/definitions/src/widget-integration-map.ts
@@ -41,32 +41,32 @@ export const nativeFeatureCapabilities = {
(kind) => kind !== "patchmon" && kind !== "wud",
),
},
- firewall: { integrations: getIntegrationKindsByCategory("firewall") },
+ firewall: { integrations: [...getIntegrationKindsByCategory("firewall"), "mock"] },
notifications: { integrations: getIntegrationKindsByCategory("notifications") },
mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] },
- systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"] },
- systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] },
+ systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"] },
+ systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] },
beszelAlerts: { integrations: ["beszel", "mock"] },
beszelSystemGrid: { integrations: ["beszel", "mock"] },
beszelSystemStats: { integrations: ["beszel", "mock"] },
beszelSystemTable: { integrations: ["beszel", "mock"] },
- coolify: { integrations: ["coolify"] },
- "immich-serverStats": { integrations: ["immich"], serverMaxIntegrations: 1 },
- "immich-albumCarousel": { integrations: ["immich"], serverMaxIntegrations: 1 },
- paperlessNgx: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 },
- patchmon: { integrations: ["patchmon"], serverMaxIntegrations: 1 },
- bazarr: { integrations: ["bazarr"], serverMaxIntegrations: 1 },
- tracearr: { integrations: ["tracearr"] },
- speedtestTracker: { integrations: ["speedtestTracker"] },
- uptimeKuma: { integrations: ["uptimeKuma"] },
- audioStats: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 },
- umami: { integrations: ["umami"], serverMaxIntegrations: 1 },
- vpn: { integrations: getIntegrationKindsByCategory("vpn") },
+ coolify: { integrations: ["coolify", "mock"] },
+ "immich-serverStats": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
+ "immich-albumCarousel": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
+ paperlessNgx: { integrations: ["paperlessNgx", "mock"], serverMaxIntegrations: 1 },
+ patchmon: { integrations: ["patchmon", "mock"], serverMaxIntegrations: 1 },
+ bazarr: { integrations: ["bazarr", "mock"], serverMaxIntegrations: 1 },
+ tracearr: { integrations: ["tracearr", "mock"] },
+ speedtestTracker: { integrations: ["speedtestTracker", "mock"] },
+ uptimeKuma: { integrations: ["uptimeKuma", "mock"] },
+ audioStats: { integrations: ["navidrome", "audiobookshelf", "mock"], serverMaxIntegrations: 1 },
+ umami: { integrations: ["umami", "mock"], serverMaxIntegrations: 1 },
+ vpn: { integrations: [...getIntegrationKindsByCategory("vpn"), "mock"] },
ups: { integrations: getIntegrationKindsByCategory("ups") },
- archiveTeamWarrior: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 },
- anchorNote: { integrations: ["anchor"], serverMaxIntegrations: 1 },
- traefik: { integrations: ["traefik"] },
- wud: { integrations: ["wud"], serverMaxIntegrations: 1 },
+ archiveTeamWarrior: { integrations: ["archiveTeamWarrior", "mock"], serverMaxIntegrations: 1 },
+ anchorNote: { integrations: ["anchor", "mock"], serverMaxIntegrations: 1 },
+ traefik: { integrations: ["traefik", "mock"] },
+ wud: { integrations: ["wud", "mock"], serverMaxIntegrations: 1 },
} as const satisfies Partial>;
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index d4f200618..05d3a7764 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -45,6 +45,7 @@ export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
+export { mockWidgetData } from "./mock/widget-data";
// Types
export type { IntegrationInput } from "./base/integration";
diff --git a/packages/integrations/src/mock/widget-data.ts b/packages/integrations/src/mock/widget-data.ts
new file mode 100644
index 000000000..ce0bd62c9
--- /dev/null
+++ b/packages/integrations/src/mock/widget-data.ts
@@ -0,0 +1,359 @@
+import type { AnchorNote, AnchorNoteSummary } from "../anchor/anchor-types";
+import type { ArchiveTeamWarriorStatus } from "../archive-team-warrior/archive-team-warrior-types";
+import type { BazarrBadges } from "../bazarr/bazarr-types";
+import type { CoolifyInstanceInfo } from "../coolify/coolify-types";
+import type { ImmichAlbum, ImmichServerStats } from "../immich/immich-integration";
+import type { NavidromeDashboardData } from "../navidrome/navidrome-types";
+import type { PaperlessNgxStats } from "../paperless-ngx/paperless-ngx-types";
+import type { PatchMonStats } from "../patchmon/patchmon-types";
+import type { SpeedtestTrackerDashboardData } from "../speedtest-tracker/speedtest-tracker-types";
+import type { TracearrDashboardData } from "../tracearr/tracearr-types";
+import type { TraefikDashboardData } from "../traefik/traefik-types";
+import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats, UmamiWebsite } from "../umami/umami-types";
+import type { UptimeKumaDashboardData } from "../uptime-kuma/uptime-kuma-types";
+import type { WudStats } from "../wud/wud-types";
+import type {
+ FirewallCpuSummary,
+ FirewallInterfacesSummary,
+ FirewallMemorySummary,
+ FirewallVersionSummary,
+} from "../interfaces/firewall-summary/firewall-summary-types";
+import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
+
+const demoTimestamp = "2026-08-24T12:00:00.000Z";
+
+const demoPhoto = (label: string, color: string) => {
+ const svg = `${label} `;
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+};
+
+const anchorNote: AnchorNote = {
+ id: "homarr-demo-note",
+ title: "Homarr demo runbook",
+ content: JSON.stringify({
+ ops: [
+ { insert: "Welcome to the complete Homarr widget gallery.\n", attributes: { bold: true } },
+ { insert: "Every integration-backed card below is powered by safe mock data.\n" },
+ ],
+ }),
+ createdAt: demoTimestamp,
+ updatedAt: demoTimestamp,
+ isPinned: true,
+ isArchived: false,
+ tagIds: ["demo"],
+ permission: "editor",
+ background: null,
+ userId: "demo",
+};
+
+export const mockWidgetData = {
+ anchorNote,
+ anchorNotes: [
+ {
+ id: anchorNote.id,
+ title: anchorNote.title,
+ updatedAt: anchorNote.updatedAt,
+ isPinned: anchorNote.isPinned,
+ tagIds: anchorNote.tagIds,
+ permission: anchorNote.permission,
+ },
+ ] satisfies AnchorNoteSummary[],
+ archiveTeamWarrior: {
+ status: "running",
+ runnerStatus: "running",
+ project: { id: "urlteam2", title: "URLTeam 2" },
+ selectedProject: "urlteam2",
+ broadcastMessage: "Archiving the open web, one URL at a time.",
+ bandwidth: {
+ received: 8_942_000_000,
+ sent: 1_284_000_000,
+ receiving: 4_800_000,
+ sending: 720_000,
+ session_id: "homarr-demo-session",
+ },
+ items: [
+ { id: "demo-1", name: "urls-20260824-01", status: "running", project: "urlteam2", startTime: 1_777_000_000 },
+ { id: "demo-2", name: "urls-20260824-00", status: "completed", project: "urlteam2" },
+ ],
+ counts: { running: 1, completed: 284, failed: 2, canceled: 0 },
+ updatedAt: demoTimestamp,
+ } satisfies ArchiveTeamWarriorStatus,
+ audioStats: { artistCount: 842, albumCount: 1_976, songCount: 24_681 } satisfies NavidromeDashboardData,
+ bazarr: {
+ episodes: 12,
+ movies: 4,
+ providers: 6,
+ status: 0,
+ sonarr_signalr: "connected",
+ radarr_signalr: "connected",
+ announcements: 1,
+ } satisfies BazarrBadges,
+ coolify: {
+ version: "4.0.0-beta.420",
+ servers: [
+ {
+ id: 1,
+ uuid: "homarr-demo-edge",
+ name: "Paris edge",
+ ip: "10.0.0.20",
+ is_reachable: true,
+ is_usable: true,
+ settings: { server_id: 1, is_build_server: true, is_reachable: true, is_usable: true },
+ },
+ ],
+ applications: [
+ {
+ id: 1,
+ uuid: "homarr-demo-app",
+ name: "Homarr",
+ status: "running:healthy",
+ fqdn: "https://homarr.dev",
+ projectName: "Home cloud",
+ environmentName: "Production",
+ },
+ {
+ id: 2,
+ uuid: "media-demo-app",
+ name: "Media stack",
+ status: "running:healthy",
+ fqdn: null,
+ projectName: "Home cloud",
+ environmentName: "Production",
+ },
+ ],
+ services: [
+ {
+ id: 3,
+ uuid: "postgres-demo-service",
+ name: "Postgres",
+ status: "running:healthy",
+ fqdn: null,
+ applications: [],
+ projectName: "Home cloud",
+ environmentName: "Production",
+ },
+ ],
+ } satisfies CoolifyInstanceInfo,
+ firewallCpu: { total: 23 } satisfies FirewallCpuSummary,
+ firewallInterfaces: [
+ {
+ data: [
+ { name: "WAN", receive: 18_400_000, transmit: 4_200_000 },
+ { name: "LAN", receive: 46_700_000, transmit: 21_300_000 },
+ ],
+ timestamp: new Date(demoTimestamp),
+ },
+ ] satisfies FirewallInterfacesSummary[],
+ firewallMemory: { used: 3_220_000_000, total: 8_590_000_000, percent: 37.5 } satisfies FirewallMemorySummary,
+ firewallVersion: { version: "24.7.11" } satisfies FirewallVersionSummary,
+ immichStats: {
+ userCount: 4,
+ photoCount: 48_392,
+ videoCount: 2_418,
+ totalLibraryUsageInBytes: 684_510_412_800,
+ } satisfies ImmichServerStats,
+ immichAlbums: [
+ { id: "demo-paris", albumName: "Paris weekend", assetCount: 128 },
+ { id: "demo-mountains", albumName: "Alpine trails", assetCount: 84 },
+ ],
+ immichAlbum: {
+ id: "demo-paris",
+ albumName: "Paris weekend",
+ assets: [
+ {
+ id: "demo-photo-1",
+ thumbhash: null,
+ fileModifiedAt: demoTimestamp,
+ fileCreatedAt: demoTimestamp,
+ updatedAt: demoTimestamp,
+ type: "IMAGE",
+ publicLink: demoPhoto("Paris weekend", "#6f4cff"),
+ },
+ {
+ id: "demo-photo-2",
+ thumbhash: null,
+ fileModifiedAt: demoTimestamp,
+ fileCreatedAt: demoTimestamp,
+ updatedAt: demoTimestamp,
+ type: "IMAGE",
+ publicLink: demoPhoto("Alpine trails", "#198f83"),
+ },
+ ],
+ } satisfies ImmichAlbum,
+ paperlessNgx: {
+ documentsTotal: 2_847,
+ documentsInbox: 14,
+ correspondentsCount: 96,
+ tagsCount: 38,
+ documentTypesCount: 12,
+ } satisfies PaperlessNgxStats,
+ patchmon: {
+ totalHosts: 18,
+ hostsNeedingUpdates: 5,
+ securityUpdates: 7,
+ upToDateHosts: 13,
+ hostsWithSecurityUpdates: 3,
+ recentUpdates24h: 11,
+ totalOutdatedPackages: 26,
+ totalRepos: 42,
+ lastUpdated: demoTimestamp,
+ osDistribution: [
+ { name: "Ubuntu 24.04", count: 9, osType: "linux", osVersion: "24.04" },
+ { name: "Debian 13", count: 6, osType: "linux", osVersion: "13" },
+ { name: "Fedora 42", count: 3, osType: "linux", osVersion: "42" },
+ ],
+ } satisfies PatchMonStats,
+ speedtestTracker: {
+ latestResult: {
+ id: 42,
+ ping: 8.4,
+ download_bits: 936_000_000,
+ upload_bits: 487_000_000,
+ healthy: true,
+ created_at: new Date(demoTimestamp),
+ },
+ stats: {
+ ping: { avg: 9.1, min: 6.8, max: 14.2 },
+ download: { avg: 901, avg_bits: 901_000_000, min: 822, max: 948 },
+ upload: { avg: 472, avg_bits: 472_000_000, min: 431, max: 495 },
+ total_results: 128,
+ },
+ recentResults: [
+ {
+ id: 42,
+ ping: 8.4,
+ download_bits: 936_000_000,
+ upload_bits: 487_000_000,
+ healthy: true,
+ created_at: new Date(demoTimestamp),
+ },
+ ],
+ } satisfies SpeedtestTrackerDashboardData,
+ tracearr: {
+ stats: { activeStreams: 2, totalUsers: 14, totalSessions: 1_248, recentViolations: 1, timestamp: demoTimestamp },
+ streams: {
+ data: [],
+ summary: {
+ total: 2,
+ transcodes: 1,
+ directStreams: 0,
+ directPlays: 1,
+ totalBitrate: "24.8 Mbps",
+ byServer: [
+ {
+ serverId: "demo",
+ serverName: "Homarr Media",
+ total: 2,
+ transcodes: 1,
+ directStreams: 0,
+ directPlays: 1,
+ totalBitrate: "24.8 Mbps",
+ },
+ ],
+ },
+ },
+ violations: { data: [], meta: { total: 1, page: 1, pageSize: 10 } },
+ recentActivity: { data: [], meta: { total: 1_248, page: 1, pageSize: 10 } },
+ } satisfies TracearrDashboardData,
+ traefik: {
+ version: "3.5.0",
+ entryPoints: ["web", "websecure", "metrics"],
+ failedEndpoints: [],
+ resources: [
+ { protocol: "http", type: "router", name: "homarr@docker", provider: "docker", status: "enabled" },
+ { protocol: "http", type: "service", name: "homarr@docker", provider: "docker", status: "enabled" },
+ { protocol: "http", type: "middleware", name: "secure-headers@file", provider: "file", status: "enabled" },
+ ],
+ http: {
+ routers: { total: 12, enabled: 12, warnings: 0, errors: 0 },
+ services: { total: 10, enabled: 10, warnings: 0, errors: 0 },
+ middlewares: { total: 8, enabled: 8, warnings: 0, errors: 0 },
+ },
+ tcp: {
+ routers: { total: 2, enabled: 2, warnings: 0, errors: 0 },
+ services: { total: 2, enabled: 2, warnings: 0, errors: 0 },
+ middlewares: { total: 0, enabled: 0, warnings: 0, errors: 0 },
+ },
+ udp: {
+ routers: { total: 1, enabled: 1, warnings: 0, errors: 0 },
+ services: { total: 1, enabled: 1, warnings: 0, errors: 0 },
+ },
+ } satisfies TraefikDashboardData,
+ umamiWebsites: [{ id: "homarr-demo", name: "Homarr demo", domain: "demo.homarr.dev" }] satisfies UmamiWebsite[],
+ umamiVisitorStats: {
+ domain: "demo.homarr.dev",
+ websiteId: "homarr-demo",
+ websiteName: "Homarr demo",
+ totalVisitors: 8_429,
+ totalPageviews: 21_384,
+ totalVisits: 11_207,
+ bounceRate: 32.4,
+ avgDuration: 284,
+ timeFrame: "24h",
+ dataPoints: [
+ { timestamp: "2026-08-24T08:00:00.000Z", visitors: 280, events: 0 },
+ { timestamp: "2026-08-24T10:00:00.000Z", visitors: 410, events: 0 },
+ { timestamp: "2026-08-24T12:00:00.000Z", visitors: 536, events: 0 },
+ ],
+ eventCount: 0,
+ } satisfies UmamiVisitorStats,
+ umamiEventNames: ["dashboard_opened", "widget_added", "board_shared"],
+ umamiTopPages: [
+ { x: "/", y: 8_240 },
+ { x: "/boards/home", y: 5_812 },
+ { x: "/manage/integrations", y: 1_942 },
+ ] satisfies UmamiMetricItem[],
+ umamiTopReferrers: [
+ { x: "github.com", y: 3_842 },
+ { x: "google.com", y: 2_106 },
+ { x: "docs.homarr.dev", y: 1_284 },
+ ] satisfies UmamiMetricItem[],
+ umamiEventSeries: [
+ {
+ eventName: "dashboard_opened",
+ dataPoints: [
+ { x: "2026-08-24T08:00:00.000Z", y: 92 },
+ { x: "2026-08-24T10:00:00.000Z", y: 134 },
+ { x: "2026-08-24T12:00:00.000Z", y: 188 },
+ ],
+ },
+ ] satisfies UmamiEventSeries[],
+ uptimeKuma: {
+ totalMonitors: 6,
+ upCount: 5,
+ downCount: 1,
+ pausedCount: 0,
+ averageUptimePercent: 99.72,
+ monitors: [
+ { id: 1, name: "Homarr", status: "up", uptimePercent24h: 100 },
+ { id: 2, name: "Media server", status: "up", uptimePercent24h: 99.98 },
+ { id: 3, name: "Home cloud", status: "up", uptimePercent24h: 99.91 },
+ { id: 4, name: "Backups", status: "up", uptimePercent24h: 99.62 },
+ { id: 5, name: "VPN", status: "up", uptimePercent24h: 99.8 },
+ { id: 6, name: "Lab service", status: "down", uptimePercent24h: 98.99 },
+ ],
+ } satisfies UptimeKumaDashboardData,
+ vpn: {
+ vpnStatus: "running",
+ dnsStatus: "running",
+ publicIp: "198.51.100.42",
+ country: "France",
+ city: "Paris",
+ vpnProvider: { protocol: "wireguard", provider: "Mullvad" },
+ } satisfies VpnSummary,
+ wud: {
+ totalContainers: 24,
+ updatesAvailable: 3,
+ updates: [
+ {
+ id: "homarr",
+ name: "Homarr",
+ currentVersion: "v1.35.0",
+ newVersion: "v1.36.0",
+ link: "https://github.com/homarr-labs/homarr",
+ },
+ { id: "postgres", name: "Postgres", currentVersion: "17.5", newVersion: "17.6", link: null },
+ { id: "redis", name: "Redis", currentVersion: "8.0.2", newVersion: "8.0.3", link: null },
+ ],
+ } satisfies WudStats,
+} as const;
diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts
index 4eac1a441..d7a155911 100644
--- a/packages/widgets/src/anchor-note/index.ts
+++ b/packages/widgets/src/anchor-note/index.ts
@@ -8,7 +8,7 @@ export const { definition, componentLoader } = createWidgetDefinition("anchorNot
supportsAdvancedFocus: true,
queryKey: [["widget", "anchorNotes"]],
maxIntegrations: 1,
- supportedIntegrations: ["anchor"],
+ supportedIntegrations: ["anchor", "mock"],
createOptions() {
return optionsBuilder.from((factory) => ({
noteId: factory.anchorNote(),
diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts
index 38a3eef36..f91f2d0ac 100644
--- a/packages/widgets/src/archive-team-warrior/index.ts
+++ b/packages/widgets/src/archive-team-warrior/index.ts
@@ -14,5 +14,5 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
}),
}));
},
- supportedIntegrations: ["archiveTeamWarrior"],
+ supportedIntegrations: ["archiveTeamWarrior", "mock"],
}).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts
index 6fb3b2014..836a2d6f6 100644
--- a/packages/widgets/src/audio-stats/index.ts
+++ b/packages/widgets/src/audio-stats/index.ts
@@ -4,7 +4,8 @@ import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition
import { optionsBuilder } from "../options";
const hideUnlessNavidrome = {
- shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("navidrome"),
+ shouldHide: (_: unknown, integrationKinds: string[]) =>
+ !integrationKinds.includes("navidrome") && !integrationKinds.includes("mock"),
};
const hideUnlessAudiobookshelf = {
@@ -16,7 +17,7 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
supportsAdvancedFocus: true,
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
queryMatcher: matchesWidgetRuntimeQuery,
- supportedIntegrations: ["navidrome", "audiobookshelf"],
+ supportedIntegrations: ["navidrome", "audiobookshelf", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts
index ff5bb0d85..ec8e68f15 100644
--- a/packages/widgets/src/bazarr/index.ts
+++ b/packages/widgets/src/bazarr/index.ts
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: true,
icon: IconBadgeCc,
- supportedIntegrations: ["bazarr"],
+ supportedIntegrations: ["bazarr", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts
index cfc96bc9a..2c1ab2d8d 100644
--- a/packages/widgets/src/coolify/index.ts
+++ b/packages/widgets/src/coolify/index.ts
@@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify",
}),
}));
},
- supportedIntegrations: ["coolify"],
+ supportedIntegrations: ["coolify", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts
index 7a013f07f..f48c37dff 100644
--- a/packages/widgets/src/firewall/index.ts
+++ b/packages/widgets/src/firewall/index.ts
@@ -12,7 +12,7 @@ export const { definition, componentLoader } = createWidgetDefinition("firewall"
createOptions() {
return optionsBuilder.from(() => ({}));
},
- supportedIntegrations: getIntegrationKindsByCategory("firewall"),
+ supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconWallOff,
diff --git a/packages/widgets/src/iframe/component.spec.ts b/packages/widgets/src/iframe/component.spec.ts
index a5f3b5835..d1faa15c5 100644
--- a/packages/widgets/src/iframe/component.spec.ts
+++ b/packages/widgets/src/iframe/component.spec.ts
@@ -18,8 +18,9 @@ describe("iframe policy", () => {
expect(getSandboxFlags(permissions)).toContain("allow-modals");
});
- it("accepts only HTTP protocols", () => {
+ it("accepts HTTP URLs and same-origin paths", () => {
expect(isSupportedProtocol("https://example.com")).toBe(true);
+ expect(isSupportedProtocol("/api/demo-assets/overview")).toBe(true);
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx
index c66343927..23a5134bd 100644
--- a/packages/widgets/src/iframe/component.tsx
+++ b/packages/widgets/src/iframe/component.tsx
@@ -7,13 +7,15 @@ import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
-import { getSafeApplicationUrl } from "../common/application-url";
import classes from "./component.module.css";
+import { getSafeIframeUrl } from "./safe-url";
+
+export { getSafeIframeUrl } from "./safe-url";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n("widget.iframe");
const { embedUrl, allowScrolling, ...permissions } = options;
- const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
+ const safeEmbedUrl = getSafeIframeUrl(embedUrl);
const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions);
@@ -45,7 +47,7 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
const supportedProtocols = ["http", "https"];
export const isSupportedProtocol = (url: string) => {
- return getSafeApplicationUrl(url) !== undefined;
+ return getSafeIframeUrl(url) !== undefined;
};
const NoUrl = () => {
diff --git a/packages/widgets/src/iframe/index.ts b/packages/widgets/src/iframe/index.ts
index 981ab180c..70ad9056d 100644
--- a/packages/widgets/src/iframe/index.ts
+++ b/packages/widgets/src/iframe/index.ts
@@ -1,8 +1,8 @@
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
-import { getSafeApplicationUrl } from "../common/application-url";
import { optionsBuilder } from "../options";
+import { getSafeIframeUrl } from "./safe-url";
const createOptions = () =>
optionsBuilder.from((factory) => ({
@@ -23,7 +23,7 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe",
supportsAdvancedFocus: false,
icon: IconBrowser,
contextActions: ({ options }) => {
- const embedUrl = getSafeApplicationUrl(options.embedUrl);
+ const embedUrl = getSafeIframeUrl(options.embedUrl);
return embedUrl
? [
{
diff --git a/packages/widgets/src/iframe/safe-url.ts b/packages/widgets/src/iframe/safe-url.ts
new file mode 100644
index 000000000..2837bc9ae
--- /dev/null
+++ b/packages/widgets/src/iframe/safe-url.ts
@@ -0,0 +1,10 @@
+import { getSafeAppHref, getSafeApplicationUrl } from "@homarr/common";
+
+export const getSafeIframeUrl = (url: unknown) => {
+ const applicationUrl = getSafeApplicationUrl(url);
+ if (applicationUrl) return applicationUrl;
+
+ const relativeUrl = getSafeAppHref(url);
+ if (relativeUrl?.startsWith("/") && !relativeUrl.startsWith("//")) return relativeUrl;
+ return undefined;
+};
diff --git a/packages/widgets/src/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts
index d95c4d029..14e1c46e2 100644
--- a/packages/widgets/src/immich/album-carousel/index.ts
+++ b/packages/widgets/src/immich/album-carousel/index.ts
@@ -62,7 +62,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
: undefined,
}),
refetchInterval: null,
- supportedIntegrations: ["immich"],
+ supportedIntegrations: ["immich", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions: ({ widgetRuntimeRef }) => {
diff --git a/packages/widgets/src/immich/server-stats/index.ts b/packages/widgets/src/immich/server-stats/index.ts
index dedd420fe..c1e45a68a 100644
--- a/packages/widgets/src/immich/server-stats/index.ts
+++ b/packages/widgets/src/immich/server-stats/index.ts
@@ -9,7 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se
queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
- supportedIntegrations: ["immich"],
+ supportedIntegrations: ["immich", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts
index 600ee4652..ac4519677 100644
--- a/packages/widgets/src/paperless-ngx/index.ts
+++ b/packages/widgets/src/paperless-ngx/index.ts
@@ -7,7 +7,7 @@ export const { definition, componentLoader } = createWidgetDefinition("paperless
supportsAdvancedFocus: false,
icon: IconFileText,
refetchInterval: null,
- supportedIntegrations: ["paperlessNgx"],
+ supportedIntegrations: ["paperlessNgx", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
diff --git a/packages/widgets/src/patchmon/index.ts b/packages/widgets/src/patchmon/index.ts
index d715e90ac..46688b3cf 100644
--- a/packages/widgets/src/patchmon/index.ts
+++ b/packages/widgets/src/patchmon/index.ts
@@ -9,7 +9,7 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
supportsAdvancedFocus: true,
icon: IconShieldCheck,
- supportedIntegrations: ["patchmon"],
+ supportedIntegrations: ["patchmon", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts
index 7de41100f..81ddd8f3f 100644
--- a/packages/widgets/src/speedtest-tracker/index.ts
+++ b/packages/widgets/src/speedtest-tracker/index.ts
@@ -30,7 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest
},
);
},
- supportedIntegrations: ["speedtestTracker"],
+ supportedIntegrations: ["speedtestTracker", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts
index 0780d1e6d..18671bf7f 100644
--- a/packages/widgets/src/system-disks/index.ts
+++ b/packages/widgets/src/system-disks/index.ts
@@ -9,7 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemDis
icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 10,
- supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
+ supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"],
createOptions() {
return optionsBuilder.from(
(factory) => ({
diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts
index 03d7a83db..6d87c1e6b 100644
--- a/packages/widgets/src/system-resources/index.ts
+++ b/packages/widgets/src/system-resources/index.ts
@@ -24,7 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes
supportsAdvancedFocus: true,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 10,
- supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
+ supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"],
createOptions() {
return optionsBuilder.from((factory) => ({
hasShadow: factory.switch({ defaultValue: true }),
diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts
index 61f88cf4f..6a17f3fec 100644
--- a/packages/widgets/src/tracearr/index.ts
+++ b/packages/widgets/src/tracearr/index.ts
@@ -24,7 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr"
}),
}));
},
- supportedIntegrations: ["tracearr"],
+ supportedIntegrations: ["tracearr", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
diff --git a/packages/widgets/src/traefik/index.ts b/packages/widgets/src/traefik/index.ts
index 95392a152..431c4a462 100644
--- a/packages/widgets/src/traefik/index.ts
+++ b/packages/widgets/src/traefik/index.ts
@@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("traefik",
}),
}));
},
- supportedIntegrations: ["traefik"],
+ supportedIntegrations: ["traefik", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconRouteOff,
diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts
index 9c96e4a8e..b4a5a7d0e 100644
--- a/packages/widgets/src/umami/index.ts
+++ b/packages/widgets/src/umami/index.ts
@@ -18,7 +18,7 @@ export const { definition, componentLoader } = createWidgetDefinition("umami", {
],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
- supportedIntegrations: ["umami"],
+ supportedIntegrations: ["umami", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
diff --git a/packages/widgets/src/uptime-kuma/index.ts b/packages/widgets/src/uptime-kuma/index.ts
index 0b0cff5ad..f5274f6c6 100644
--- a/packages/widgets/src/uptime-kuma/index.ts
+++ b/packages/widgets/src/uptime-kuma/index.ts
@@ -34,7 +34,7 @@ export const { definition, componentLoader } = createWidgetDefinition("uptimeKum
}),
}));
},
- supportedIntegrations: ["uptimeKuma"],
+ supportedIntegrations: ["uptimeKuma", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts
index f8418d86f..001f2eec0 100644
--- a/packages/widgets/src/vpn/index.ts
+++ b/packages/widgets/src/vpn/index.ts
@@ -12,5 +12,5 @@ export const { componentLoader, definition } = createWidgetDefinition("vpn", {
createOptions() {
return optionsBuilder.from(() => ({}));
},
- supportedIntegrations: getIntegrationKindsByCategory("vpn"),
+ supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"],
}).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/wud/index.ts b/packages/widgets/src/wud/index.ts
index d7816b480..622b10b6b 100644
--- a/packages/widgets/src/wud/index.ts
+++ b/packages/widgets/src/wud/index.ts
@@ -7,7 +7,7 @@ export const { definition, componentLoader } = createWidgetDefinition("wud", {
icon: IconBrandDocker,
supportsAdvancedFocus: true,
refetchInterval: null,
- supportedIntegrations: ["wud"],
+ supportedIntegrations: ["wud", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {