Compare commits

...
Author SHA1 Message Date
ajnart 245a477965 refactor: simplify demo media 2026-08-24 12:07:07 +02:00
ajnart d8eec36d98 fix: address demo mock review 2026-08-24 10:27:28 +02:00
ajnart 135611bb50 fix: satisfy mock router lint 2026-08-24 10:22:18 +02:00
ajnart 71c328cd7a feat: mock all demo widgets 2026-08-24 10:13:49 +02:00
42 changed files with 939 additions and 163 deletions

No files matched your search

@@ -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 widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
```sh
pnpm cli harness port feature-board
```
@@ -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";
@@ -15,6 +20,12 @@ const noteIdInput = z.object({
const logger = createLogger({ module: "anchorNotesRouter" });
const assertMockNoteExists = (noteId: string) => {
if (noteId === mockWidgetData.anchorNote.id) return;
throw new TRPCError({ code: "NOT_FOUND", message: "Note not found" });
};
const isJsonDeltaString = (value: string) => {
try {
const parsed: unknown = JSON.parse(value);
@@ -40,34 +51,64 @@ 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") {
assertMockNoteExists(input.noteId);
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") {
assertMockNoteExists(input.noteId);
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,
@@ -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(mockWidgetData.timestamp) };
}
const handler = archiveTeamWarriorRequestHandler.handler({ ...ctx.integration, kind: "archiveTeamWarrior" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
@@ -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 () => {
+5 -2
View File
@@ -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;
}),
+13 -2
View File
@@ -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<CoolifyInstanceResult> => {
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(mockWidgetData.timestamp),
};
}
const innerHandler = coolifyRequestHandler.handler({ ...integration, kind: "coolify" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+58 -16
View File
@@ -1,4 +1,6 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
@@ -10,29 +12,53 @@ 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<TSummary> {
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: TSummary;
error?: string;
}
const queryFirewall = <TSummary>(
handler: {
handler: (
integration: any,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
},
>(
handler: THandler,
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["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<FirewallResult<TSummary>> => {
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
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<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
error: undefined as string | undefined,
summary: data,
error: undefined,
};
},
{
fallback: (integration, error) => ({
fallback: (integration, error): FirewallResult<TSummary> => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
@@ -43,8 +69,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,
),
});
+18 -4
View File
@@ -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;
}),
@@ -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;
}),
});
+4 -2
View File
@@ -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;
}),
@@ -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(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = speedtestTrackerRequestHandler.handler({ ...integration, kind: "speedtestTracker" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+41 -28
View File
@@ -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(mockWidgetData.timestamp),
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,
},
);
}),
});
+13 -2
View File
@@ -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(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = traefikRequestHandler.handler({ ...integration, kind: "traefik" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+79 -40
View File
@@ -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(mockWidgetData.timestamp),
};
}
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) {
+13 -2
View File
@@ -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(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = uptimeKumaRequestHandler.handler({ ...integration, kind: "uptimeKuma" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+27 -4
View File
@@ -1,18 +1,41 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import 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<VpnResult> => {
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
summary: mockWidgetData.vpn,
error: undefined,
};
}
const { data, timestamp } = await vpnSummaryHandler
.handler({ ...integration, kind: "gluetun" }, {})
.getDataAsync();
return {
integration: {
id: integration.id,
@@ -25,7 +48,7 @@ export const vpnRouter = createTRPCRouter({
};
},
{
fallback: (integration, error) => ({
fallback: (integration, error): VpnResult => ({
integration: {
id: integration.id,
name: integration.name,
+7 -3
View File
@@ -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";
@@ -9,12 +10,15 @@ export const wudRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"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.",
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock 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(mockWidgetData.timestamp) };
}
const handler = wudStatsRequestHandler.handler({ ...ctx.integration, kind: "wud" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
+162
View File
@@ -730,6 +730,168 @@ 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: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
allowScrolling: false,
},
},
{
kind: "video",
xOffset: 4,
yOffset: 21,
width: 4,
height: 3,
needsIntegration: false,
options: {
feedUrl: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.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 => ({
+15 -2
View File
@@ -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`);
@@ -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({
@@ -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<Record<WidgetKind, NativeFeatureCapabilityDescriptor>>;
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
+1
View File
@@ -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";
@@ -0,0 +1,355 @@
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 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 = {
timestamp: demoTimestamp,
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: "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1200&q=80",
},
{
id: "demo-photo-2",
thumbhash: null,
fileModifiedAt: demoTimestamp,
fileCreatedAt: demoTimestamp,
updatedAt: demoTimestamp,
type: "IMAGE",
publicLink: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1200&q=80",
},
],
} 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;
+1 -1
View File
@@ -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(),
@@ -14,5 +14,5 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
}),
}));
},
supportedIntegrations: ["archiveTeamWarrior"],
supportedIntegrations: ["archiveTeamWarrior", "mock"],
}).withDynamicImport(() => import("./component"));
+3 -2
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify",
}),
}));
},
supportedIntegrations: ["coolify"],
supportedIntegrations: ["coolify", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+1 -1
View File
@@ -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,
@@ -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 }) => {
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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() {
@@ -30,7 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest
},
);
},
supportedIntegrations: ["speedtestTracker"],
supportedIntegrations: ["speedtestTracker", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+1 -1
View File
@@ -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) => ({
@@ -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 }),
+1 -1
View File
@@ -24,7 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr"
}),
}));
},
supportedIntegrations: ["tracearr"],
supportedIntegrations: ["tracearr", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+1 -1
View File
@@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("traefik",
}),
}));
},
supportedIntegrations: ["traefik"],
supportedIntegrations: ["traefik", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconRouteOff,
+1 -1
View File
@@ -18,7 +18,7 @@ export const { definition, componentLoader } = createWidgetDefinition("umami", {
],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["umami"],
supportedIntegrations: ["umami", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
+1 -1
View File
@@ -34,7 +34,7 @@ export const { definition, componentLoader } = createWidgetDefinition("uptimeKum
}),
}));
},
supportedIntegrations: ["uptimeKuma"],
supportedIntegrations: ["uptimeKuma", "mock"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+1 -1
View File
@@ -12,5 +12,5 @@ export const { componentLoader, definition } = createWidgetDefinition("vpn", {
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: getIntegrationKindsByCategory("vpn"),
supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"],
}).withDynamicImport(() => import("./component"));
+1 -1
View File
@@ -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() {