diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
index 0e36cb4a1..5aa145093 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
@@ -17,10 +17,13 @@ import {
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
+import { TRPCClientError } from "@trpc/client";
+
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
+import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -56,6 +59,16 @@ export function TRPCReactProvider(props: PropsWithChildren) {
queries: {
staleTime: 5 * 1000,
},
+ mutations: {
+ onError(error) {
+ if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN" && error.message === "Mutations are disabled in demo mode") {
+ showWarningNotification({
+ title: "Demo mode",
+ message: "This action is disabled in demo mode.",
+ });
+ }
+ },
+ },
},
}),
);
diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx
index 6cf00b6b3..30eb88ba0 100644
--- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx
+++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx
@@ -1,11 +1,14 @@
import { redirect } from "next/navigation";
-import { Card, Center, Stack, Text, Title } from "@mantine/core";
+import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
+import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
+import { env as appEnv } from "~/env";
+
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
{t("subtitle")}
+ {appEnv.DEMO_MODE && (
+ } color="blue" variant="light" w={64 * 6} maw="90vw">
+
+ Demo mode is enabled. Sign in with username demo and password demo
+
+
+ )}
{
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
+ const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
- if (!hasChangeAccess) {
+ if (!hasChangeAccess || isLoading) {
return ;
}
@@ -51,11 +52,13 @@ export const BoardContentHeaderActions = () => {
<>
{isEditMode && }
-
+
-
-
-
+ {!demoMode && (
+
+
+
+ )}
>
@@ -141,7 +144,7 @@ const AddMenu = () => {
);
};
-const EditModeMenu = () => {
+const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -164,10 +167,18 @@ const EditModeMenu = () => {
},
});
+ const discardDemoChanges = useCallback(() => {
+ void utils.board.getBoardByName.invalidate({ name: board.name });
+ close();
+ }, [utils, board.name, close]);
+
const toggle = useCallback(() => {
- if (isEditMode) return saveBoard(board);
+ if (isEditMode) {
+ if (demoMode) return discardDemoChanges();
+ return saveBoard(board);
+ }
open();
- }, [board, isEditMode, saveBoard, open]);
+ }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index e88f1965b..b8ea0a33a 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -23,8 +23,10 @@ import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
+
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
+
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
index 994d63b3c..830345203 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
@@ -96,7 +96,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
>
{children}
-
+
);
diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts
index 62b49dee8..96f03e7b7 100644
--- a/apps/nextjs/src/env.ts
+++ b/apps/nextjs/src/env.ts
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
+ DEMO_MODE: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts
index 7cc81cc57..ba26a264a 100644
--- a/packages/api/src/env.ts
+++ b/packages/api/src/env.ts
@@ -1,12 +1,14 @@
import { z } from "zod/v4";
-import { createEnv } from "@homarr/core/infrastructure/env";
+import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
+ DEMO_MODE: createBooleanSchema(false),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
+ DEMO_MODE: process.env.DEMO_MODE,
},
});
diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts
index 5a317b95a..e4f04f192 100644
--- a/packages/api/src/middlewares/docker.ts
+++ b/packages/api/src/middlewares/docker.ts
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
-import { publicProcedure } from "../trpc";
+import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
- if (env.ENABLE_DOCKER) {
+ if (env.ENABLE_DOCKER || isDemoMode) {
return await next();
}
throw new TRPCError({
diff --git a/packages/api/src/router/info.ts b/packages/api/src/router/info.ts
index 3c205963c..8befca412 100644
--- a/packages/api/src/router/info.ts
+++ b/packages/api/src/router/info.ts
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
-import { createTRPCRouter, protectedProcedure } from "../trpc";
+import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version,
};
}),
+ isDemoMode: publicProcedure.query(() => isDemoMode),
});
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index bedd60f3d..eb4da06c2 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
+import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" });
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
+export const isDemoMode = env.DEMO_MODE;
+
+const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
+ if (env.DEMO_MODE && type === "mutation") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Mutations are disabled in demo mode",
+ });
+ }
+ return next({ ctx });
+});
+
+const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
+
/**
* Public (unauthed) procedure
*
@@ -94,19 +109,14 @@ export const createTRPCRouter = t.router;
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
-export const publicProcedure = t.procedure;
+export const publicProcedure = baseProcedure;
-/**
- * Reusable middleware that enforces users are logged in before running the
- * procedure
- */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
- // infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*
* @see https://trpc.io/docs/procedures
*/
-export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
+export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index a2dec94af..7aa615fdc 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -1,3 +1,5 @@
+import SuperJSON from "superjson";
+
import { createId, objectKeys } from "@homarr/common";
import {
createDocumentationLink,
@@ -8,6 +10,7 @@ import {
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
+import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
@@ -18,12 +21,17 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
+
import {
+ apps,
boards,
groupMembers,
groupPermissions,
groups,
+ integrationItems,
integrations,
+ itemLayouts,
+ items,
layouts,
onboarding,
searchEngines,
@@ -39,7 +47,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
- if (process.env.DEMO_MODE === "true") {
+ if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) {
await seedDemoUserAsync(db);
}
};
@@ -209,6 +217,78 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
console.log(`Created ${createdCount} default integrations through seeding process`);
};
+interface DemoWidget {
+ kind: WidgetKind;
+ width: number;
+ height: number;
+ needsIntegration: boolean;
+ options?: Record;
+}
+
+const demoApps = [
+ { name: "Sonarr", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg", href: "https://sonarr.tv" },
+ { name: "Radarr", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg", href: "https://radarr.video" },
+ { name: "Plex", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg", href: "https://plex.tv" },
+ { name: "Jellyfin", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg", href: "https://jellyfin.org" },
+ { name: "Proxmox", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", href: "https://proxmox.com" },
+ { name: "Pi-hole", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg", href: "https://pi-hole.net" },
+ { name: "Grafana", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg", href: "https://grafana.com" },
+ { name: "Portainer", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg", href: "https://portainer.io" },
+ { name: "Home Assistant", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg", href: "https://home-assistant.io" },
+ { name: "Nextcloud", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg", href: "https://nextcloud.com" },
+ { name: "qBittorrent", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg", href: "https://qbittorrent.org" },
+ { name: "Overseerr", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg", href: "https://overseerr.dev" },
+] as const;
+
+const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
+ // Row 1: calendar + downloads + clock = 12
+ { kind: "calendar", width: 5, height: 3, needsIntegration: true },
+ { kind: "downloads", width: 5, height: 3, needsIntegration: true },
+ { kind: "clock", width: 2, height: 1, needsIntegration: false },
+ // Row 2: healthMonitoring + bookmarks = 12
+ { kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
+ {
+ kind: "bookmarks", width: 6, height: 3, needsIntegration: false,
+ options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
+ },
+ // Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
+ { kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
+ { kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
+ { kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
+ // Row 4: dnsHoleControls + mediaReleases + notifications = 12
+ { kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
+ { kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
+ { kind: "notifications", width: 6, height: 2, needsIntegration: true },
+ // Row 5: requestList + requestStats + indexerManager = 12
+ { kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
+ { kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
+ { kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
+ // Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
+ { kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
+ { kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
+ {
+ kind: "rssFeed", width: 6, height: 2, needsIntegration: false,
+ options: {
+ feedUrls: [
+ "https://selfh.st/rss/",
+ "https://hnrss.org/newest?q=self-hosted",
+ ],
+ maximumAmountPosts: 20,
+ textLinesClamp: 2,
+ hideDescription: false,
+ },
+ },
+ // Row 7-8: app widgets (2x1 each, 6 per row = 12)
+ ...appIds.map((appId): DemoWidget => ({
+ kind: "app", width: 2, height: 1, needsIntegration: false,
+ options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
+ })),
+ // Row 9: notebook + dockerContainers + weather = 12
+ { kind: "notebook", width: 4, height: 4, needsIntegration: false },
+ { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
+ { kind: "weather", width: 2, height: 1, needsIntegration: false },
+];
+
const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"),
@@ -225,7 +305,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId,
name: "demo",
email: "demo@example.com",
- password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
+ password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
});
const maxPosition = await getMaxGroupPositionAsync(db);
@@ -250,6 +330,27 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings",
});
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "Demo Integration",
+ url: "https://demo.homarr.dev",
+ kind: "mock",
+ appId: null,
+ });
+
+ const appIds: string[] = [];
+ for (const app of demoApps) {
+ const appId = createId();
+ appIds.push(appId);
+ await db.insert(apps).values({
+ id: appId,
+ name: app.name,
+ iconUrl: app.iconUrl,
+ href: app.href,
+ });
+ }
+
const boardId = createId();
await db.insert(boards).values({
id: boardId,
@@ -257,23 +358,64 @@ const seedDemoUserAsync = async (db: Database) => {
isPublic: false,
creatorId: userId,
});
+
+ const sectionId = createId();
await db.insert(sections).values({
- id: createId(),
+ id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
+
+ const layoutId = createId();
await db.insert(layouts).values({
- id: createId(),
+ id: layoutId,
name: "Base",
columnCount: 12,
breakpoint: 0,
boardId,
});
+
+ const demoWidgets = buildDemoWidgets(appIds);
+ let xOffset = 0;
+ let yOffset = 0;
+ let rowMaxHeight = 0;
+ for (const widget of demoWidgets) {
+ if (xOffset + widget.width > 12) {
+ xOffset = 0;
+ yOffset += rowMaxHeight;
+ rowMaxHeight = 0;
+ }
+ rowMaxHeight = Math.max(rowMaxHeight, widget.height);
+ const itemId = createId();
+ await db.insert(items).values({
+ id: itemId,
+ boardId,
+ kind: widget.kind,
+ ...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
+ });
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId,
+ layoutId,
+ xOffset,
+ yOffset,
+ width: widget.width,
+ height: widget.height,
+ });
+ if (widget.needsIntegration) {
+ await db.insert(integrationItems).values({
+ itemId,
+ integrationId,
+ });
+ }
+ xOffset += widget.width;
+ }
+
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log(
- "Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
+ "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
);
};
diff --git a/packages/integrations/src/mock/data/calendar.ts b/packages/integrations/src/mock/data/calendar.ts
index 530819dd3..038827c79 100644
--- a/packages/integrations/src/mock/data/calendar.ts
+++ b/packages/integrations/src/mock/data/calendar.ts
@@ -1,91 +1,171 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
-export class CalendarMockService implements ICalendarIntegration {
- public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise {
- const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
- return await Promise.resolve(result);
- }
+interface EventTemplate {
+ title: string;
+ subTitle: string;
+ description: string;
+ dayOffset: number;
+ hours: number;
+ image: CalendarEvent["image"];
+ indicatorColor: string;
+ metadata?: CalendarEvent["metadata"];
+ location?: string;
+ links: CalendarEvent["links"];
}
-const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
- const startDate = randomDateBetween(start, end);
- const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
- return {
+const eventTemplates: EventTemplate[] = [
+ {
+ title: "The Mandalorian",
+ subTitle: "Season 4",
+ description: "A lone bounty hunter in the outer reaches of the galaxy.",
+ dayOffset: 0,
+ hours: 20,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S4/E3", color: "red" },
+ },
+ indicatorColor: "blue",
+ links: [{ href: "https://www.imdb.com/title/tt8111088/", name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.svg" }],
+ },
+ {
+ title: "Silo",
+ subTitle: "Season 2",
+ description: "In a ruined and toxic future, thousands live in a giant underground silo.",
+ dayOffset: 3,
+ hours: 21,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S2/E8", color: "teal" },
+ },
+ indicatorColor: "teal",
+ links: [{ href: "https://www.imdb.com/title/tt14688458/", name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.svg" }],
+ },
+ {
+ title: "Dune: Part Two",
+ subTitle: "Digital Release",
+ description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
+ dayOffset: 5,
+ hours: 0,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ },
+ metadata: { type: "radarr", releaseType: "digitalRelease" },
+ indicatorColor: "cyan",
+ links: [{ href: "https://www.imdb.com/title/tt15239678/", name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.svg" }],
+ },
+ {
+ title: "Severance",
+ subTitle: "Season 2",
+ description: "Mark leads a team of office workers whose memories are surgically divided.",
+ dayOffset: 7,
+ hours: 22,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S2/E5", color: "violet" },
+ },
+ indicatorColor: "violet",
+ links: [],
+ },
+ {
+ title: "Oppenheimer",
+ subTitle: "Physical Release",
+ description: "The story of the development of the atomic bomb.",
+ dayOffset: 10,
+ hours: 0,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ },
+ metadata: { type: "radarr", releaseType: "physicalRelease" },
+ indicatorColor: "orange",
+ links: [{ href: "https://www.imdb.com/title/tt15398776/", name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.svg" }],
+ },
+ {
title: "Homarr Meetup",
subTitle: "",
description: "Yearly meetup of the Homarr community",
- startDate,
- endDate,
- image: null,
+ dayOffset: 14,
+ hours: 14,
location: "Mountains",
+ image: null,
indicatorColor: "#fa5252",
- links: [
- {
- href: "https://homarr.dev",
- name: "Homarr",
- logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
- color: "#000000",
- isDark: true,
- },
- ],
- };
+ links: [{ href: "https://homarr.dev", name: "Homarr", logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", color: "#000000", isDark: true }],
+ },
+ {
+ title: "The Bear",
+ subTitle: "Season 3",
+ description: "A young chef fights to transform a sandwich shop.",
+ dayOffset: 18,
+ hours: 3,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S3/E1", color: "yellow" },
+ },
+ indicatorColor: "yellow",
+ links: [],
+ },
+ {
+ title: "Foundation",
+ subTitle: "Season 3",
+ description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
+ dayOffset: 21,
+ hours: 20,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S3/E1", color: "indigo" },
+ },
+ indicatorColor: "indigo",
+ links: [],
+ },
+ {
+ title: "Blade Runner 2099",
+ subTitle: "Premiere",
+ description: "A new chapter in the Blade Runner universe.",
+ dayOffset: 25,
+ hours: 21,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ },
+ metadata: { type: "radarr", releaseType: "digitalRelease" },
+ indicatorColor: "grape",
+ links: [],
+ },
+];
+
+const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
+ const rangeStart = new Date(start);
+ rangeStart.setDate(rangeStart.getDate() - 3);
+
+ return eventTemplates.map((tpl) => {
+ const startDate = new Date(rangeStart);
+ startDate.setDate(startDate.getDate() + tpl.dayOffset);
+ startDate.setHours(tpl.hours, 0, 0, 0);
+
+ return {
+ title: tpl.title,
+ subTitle: tpl.subTitle,
+ description: tpl.description,
+ startDate,
+ endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
+ image: tpl.image,
+ location: tpl.location ?? null,
+ indicatorColor: tpl.indicatorColor,
+ links: tpl.links,
+ ...(tpl.metadata ? { metadata: tpl.metadata } : {}),
+ };
+ }).filter((event) => event.startDate >= start && event.startDate <= end);
};
-const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
- title: "Titanic",
- subTitle: "A classic movie",
- description: "A tragic love story set on the ill-fated RMS Titanic.",
- startDate: randomDateBetween(start, end),
- endDate: null,
- image: {
- src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
- aspectRatio: { width: 7, height: 12 },
- },
- location: null,
- metadata: {
- type: "radarr",
- releaseType: "inCinemas",
- },
- indicatorColor: "cyan",
- links: [
- {
- href: "https://www.imdb.com/title/tt0120338/",
- name: "IMDb",
- color: "#f5c518",
- isDark: false,
- logo: "/images/apps/imdb.svg",
- },
- ],
-});
-
-const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
- title: "The Mandalorian",
- subTitle: "A Star Wars Series",
- description: "A lone bounty hunter in the outer reaches of the galaxy.",
- startDate: randomDateBetween(start, end),
- endDate: null,
- image: {
- src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
- aspectRatio: { width: 7, height: 12 },
- badge: {
- content: "S1/E1",
- color: "red",
- },
- },
- location: null,
- indicatorColor: "blue",
- links: [
- {
- href: "https://www.imdb.com/title/tt8111088/",
- name: "IMDb",
- color: "#f5c518",
- isDark: false,
- logo: "/images/apps/imdb.svg",
- },
- ],
-});
-
-function randomDateBetween(start: Date, end: Date): Date {
- return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
+export class CalendarMockService implements ICalendarIntegration {
+ public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise {
+ return buildEventsForRange(start, end);
+ }
}
diff --git a/packages/integrations/src/mock/data/download.ts b/packages/integrations/src/mock/data/download.ts
index 7d3ab0252..2f87b7972 100644
--- a/packages/integrations/src/mock/data/download.ts
+++ b/packages/integrations/src/mock/data/download.ts
@@ -2,57 +2,153 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
+const mockItems: Omit[] = [
+ {
+ name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
+ type: "torrent",
+ size: 4_700_000_000,
+ progress: 0.47,
+ state: "leeching",
+ downSpeed: 12_400_000,
+ upSpeed: 890_000,
+ sent: 420_000_000,
+ received: 2_209_000_000,
+ time: 201_000,
+ category: "movies",
+ },
+ {
+ name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
+ type: "torrent",
+ size: 5_800_000_000,
+ progress: 0.82,
+ state: "leeching",
+ downSpeed: 34_500_000,
+ upSpeed: 2_100_000,
+ sent: 1_200_000_000,
+ received: 4_756_000_000,
+ time: 30_000,
+ category: "iso",
+ },
+ {
+ name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
+ type: "torrent",
+ size: 18_200_000_000,
+ progress: 0.03,
+ state: "leeching",
+ downSpeed: 1_200_000,
+ upSpeed: 50_000,
+ sent: 30_000_000,
+ received: 546_000_000,
+ time: 14_880_000,
+ category: "movies",
+ },
+ {
+ name: "Arch.Linux.2024.12.01.x86_64.iso",
+ type: "torrent",
+ size: 1_100_000_000,
+ progress: 1,
+ state: "seeding",
+ downSpeed: 0,
+ upSpeed: 4_500_000,
+ sent: 3_300_000_000,
+ received: 1_100_000_000,
+ time: -7_200_000,
+ category: "iso",
+ },
+ {
+ name: "Tears.of.Steel.2012.1080p.DTS.mkv",
+ type: "torrent",
+ size: 7_340_000_000,
+ progress: 0,
+ state: "paused",
+ downSpeed: 0,
+ upSpeed: 0,
+ time: 0,
+ category: "movies",
+ },
+ {
+ name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
+ type: "torrent",
+ size: 12_000_000_000,
+ progress: 0.65,
+ state: "stalled",
+ downSpeed: 0,
+ upSpeed: 0,
+ sent: 200_000_000,
+ received: 7_800_000_000,
+ time: 0,
+ category: "movies",
+ },
+ {
+ name: "Spring.2019.4K.HDR.mkv",
+ type: "usenet",
+ size: 3_200_000_000,
+ progress: 0.91,
+ state: "downloading",
+ downSpeed: 45_000_000,
+ time: 6_500,
+ category: "movies",
+ },
+ {
+ name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
+ type: "usenet",
+ size: 1_800_000_000,
+ progress: 1,
+ state: "completed",
+ time: -3_600_000,
+ category: "movies",
+ },
+ {
+ name: "Caminandes.3.Llamigos.2016.1080p.mkv",
+ type: "torrent",
+ size: 890_000_000,
+ progress: 1,
+ state: "seeding",
+ downSpeed: 0,
+ upSpeed: 1_800_000,
+ sent: 2_670_000_000,
+ received: 890_000_000,
+ time: -86_400_000,
+ category: "movies",
+ },
+ {
+ name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
+ type: "usenet",
+ size: 9_400_000_000,
+ progress: 0.22,
+ state: "downloading",
+ downSpeed: 28_000_000,
+ time: 244_000,
+ category: "tv",
+ },
+];
+
export class DownloadClientMockService implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise {
- return await Promise.resolve({
+ return {
status: {
- paused: Math.random() < 0.5,
+ paused: false,
rates: {
- down: Math.floor(Math.random() * 5000),
- up: Math.floor(Math.random() * 5000),
+ down: 121_100_000,
+ up: 9_340_000,
},
types: ["torrent", "usenet"],
},
- items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
- 0,
- input.limit,
- ),
- });
- }
-
- public async pauseQueueAsync(): Promise {
- return await Promise.resolve();
- }
-
- public async pauseItemAsync(_item: DownloadClientItem): Promise {
- return await Promise.resolve();
- }
-
- public async resumeQueueAsync(): Promise {
- return Promise.resolve();
- }
-
- public async resumeItemAsync(_item: DownloadClientItem): Promise {
- return await Promise.resolve();
- }
-
- public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise {
- return await Promise.resolve();
- }
-
- private static createItem(index: number): DownloadClientItem {
- const progress = Math.random() < 0.5 ? Math.random() : 1;
- return {
- id: `item-${index}`,
- index,
- name: `Item ${index}`,
- type: Math.random() > 0.5 ? "torrent" : "usenet",
- progress,
- size: Math.floor(Math.random() * 10000) + 1,
- downSpeed: Math.floor(Math.random() * 5000),
- upSpeed: Math.floor(Math.random() * 5000),
- state: progress >= 1 ? "completed" : "downloading",
- time: 0,
+ items: mockItems.slice(0, input.limit).map((item, index) => ({
+ ...item,
+ id: `mock-${index}`,
+ index,
+ })),
};
}
+
+ public async pauseQueueAsync(): Promise {}
+
+ public async pauseItemAsync(_item: DownloadClientItem): Promise {}
+
+ public async resumeQueueAsync(): Promise {}
+
+ public async resumeItemAsync(_item: DownloadClientItem): Promise {}
+
+ public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise {}
}
diff --git a/packages/integrations/src/mock/data/media-releases.ts b/packages/integrations/src/mock/data/media-releases.ts
index d820a9aa4..8f5408207 100644
--- a/packages/integrations/src/mock/data/media-releases.ts
+++ b/packages/integrations/src/mock/data/media-releases.ts
@@ -2,7 +2,7 @@ import type { IMediaReleasesIntegration, MediaRelease } from "../../interfaces/m
export class MediaReleasesMockService implements IMediaReleasesIntegration {
public async getMediaReleasesAsync(): Promise {
- return await Promise.resolve(mockMediaReleases);
+ return mockMediaReleases;
}
}
@@ -12,18 +12,17 @@ export const mockMediaReleases: MediaRelease[] = [
type: "movie",
title: "Inception",
subtitle: "A mind-bending thriller",
- description:
- "A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
+ description: "A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/inception_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
},
producer: "Warner Bros.",
price: 14.99,
rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"],
- href: "https://example.com/inception",
+ href: "https://www.themoviedb.org/movie/27205-inception",
length: 148,
},
{
@@ -34,13 +33,13 @@ export const mockMediaReleases: MediaRelease[] = [
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/breaking_bad_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
},
producer: "AMC",
rating: "9.5/10",
tags: ["Crime", "Drama"],
- href: "https://example.com/breaking_bad",
+ href: "https://www.themoviedb.org/tv/1396-breaking-bad",
},
{
id: "3",
@@ -50,79 +49,83 @@ export const mockMediaReleases: MediaRelease[] = [
description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/ram_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
},
producer: "Columbia Records",
price: 9.99,
rating: "8.5/10",
- tags: ["Electronic", "Dance", "Pop", "Funk"],
- href: "https://example.com/ram",
+ tags: ["Electronic", "Funk"],
+ href: "https://www.daftpunk.com",
},
{
id: "4",
- type: "book",
- title: "The Great Gatsby",
- subtitle: "F. Scott Fitzgerald",
- description: "A novel about the American dream and the disillusionment that comes with it.",
- releaseDate: new Date("1925-04-10"),
+ type: "movie",
+ title: "The Dark Knight",
+ subtitle: "Theatrical Release",
+ description: "Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.",
+ releaseDate: new Date("2008-07-18"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/gatsby_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
},
- producer: "Scribner",
- price: 10.99,
- rating: "4.2/5",
- tags: ["Classic", "Fiction"],
- href: "https://example.com/gatsby",
+ producer: "Warner Bros.",
+ price: 12.99,
+ rating: "9.0/10",
+ tags: ["Action", "Crime", "Drama"],
+ href: "https://www.themoviedb.org/movie/155-the-dark-knight",
+ length: 152,
},
{
id: "5",
- type: "game",
- title: "The Legend of Zelda: Breath of the Wild",
- subtitle: "Nintendo Switch",
- description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
- releaseDate: new Date("2017-03-03"),
+ type: "movie",
+ title: "Interstellar",
+ subtitle: "Digital Release",
+ description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
+ releaseDate: new Date("2014-11-07"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/zelda_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
},
- producer: "Nintendo",
- price: 59.99,
- rating: "10/10",
- tags: ["Action", "Adventure"],
- href: "https://example.com/zelda",
+ producer: "Paramount",
+ price: 14.99,
+ rating: "8.7/10",
+ tags: ["Sci-Fi", "Adventure"],
+ href: "https://www.themoviedb.org/movie/157336-interstellar",
+ length: 169,
},
{
id: "6",
- type: "article",
- title: "The Rise of AI in Healthcare",
- subtitle: "Tech Innovations",
- description: "Exploring the impact of artificial intelligence on the healthcare industry.",
- releaseDate: new Date("2023-10-01"),
+ type: "tv",
+ title: "Severance",
+ subtitle: "S2E1 - Premiere",
+ description: "Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
+ releaseDate: new Date("2025-01-17"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
},
- producer: "Tech Innovations",
- rating: "4.8/5",
- tags: ["Technology", "Healthcare"],
- href: "https://example.com/ai_healthcare",
+ producer: "Apple TV+",
+ rating: "8.7/10",
+ tags: ["Thriller", "Sci-Fi"],
+ href: "https://www.themoviedb.org/tv/95396-severance",
},
{
id: "7",
- type: "video",
- title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
- releaseDate: new Date("2024-05-18T17:00:00Z"),
+ type: "movie",
+ title: "Dune: Part Two",
+ subtitle: "Theatrical Release",
+ description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
+ releaseDate: new Date("2024-03-01"),
imageUrls: {
- poster:
- "https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
- backdrop:
- "https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
+ poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
},
- producer: "PietSmiet",
- rating: "1K",
- tags: [],
- href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
+ producer: "Warner Bros.",
+ price: 19.99,
+ rating: "8.5/10",
+ tags: ["Sci-Fi", "Adventure"],
+ href: "https://www.themoviedb.org/movie/693134-dune-part-two",
+ length: 166,
},
];
diff --git a/packages/integrations/src/mock/data/media-server.ts b/packages/integrations/src/mock/data/media-server.ts
index 511d47f59..18335dad7 100644
--- a/packages/integrations/src/mock/data/media-server.ts
+++ b/packages/integrations/src/mock/data/media-server.ts
@@ -1,36 +1,17 @@
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
-export class MediaServerMockService implements IMediaServerIntegration {
- public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise {
- return await Promise.resolve(
- Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
- (session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
- ),
- );
- }
+const sessions: StreamSession[] = [
+ { sessionId: "s-0", sessionName: "Living Room TV", user: { userId: "u-0", username: "thomas", profilePictureUrl: null }, currentlyPlaying: { type: "movie", name: "Interstellar", seasonName: undefined, episodeName: null, albumName: null, episodeCount: null, metadata: null } },
+ { sessionId: "s-1", sessionName: "Bedroom Chromecast", user: { userId: "u-1", username: "sarah", profilePictureUrl: null }, currentlyPlaying: { type: "video", name: "Breaking Bad", seasonName: "Season 5", episodeName: "Ozymandias", albumName: null, episodeCount: null, metadata: null } },
+ { sessionId: "s-2", sessionName: "Office Desktop", user: { userId: "u-2", username: "mike", profilePictureUrl: null }, currentlyPlaying: { type: "movie", name: "The Dark Knight", seasonName: undefined, episodeName: null, albumName: null, episodeCount: null, metadata: null } },
+ { sessionId: "s-3", sessionName: "iPad", user: { userId: "u-3", username: "emily", profilePictureUrl: null }, currentlyPlaying: { type: "video", name: "Severance", seasonName: "Season 2", episodeName: "The We We Are", albumName: null, episodeCount: null, metadata: null } },
+ { sessionId: "s-4", sessionName: "Mobile", user: { userId: "u-4", username: "alex", profilePictureUrl: null }, currentlyPlaying: { type: "audio", name: "Daft Punk", seasonName: undefined, episodeName: null, albumName: "Random Access Memories", episodeCount: null, metadata: null } },
+ { sessionId: "s-5", sessionName: "Guest Room", user: { userId: "u-5", username: "guest", profilePictureUrl: null }, currentlyPlaying: { type: "movie", name: "Oppenheimer", seasonName: undefined, episodeName: null, albumName: null, episodeCount: null, metadata: null } },
+];
- private static createSession(index: number): StreamSession {
- return {
- sessionId: `session-${index}`,
- sessionName: `Session ${index}`,
- user: {
- userId: `user-${index}`,
- username: `User${index}`,
- profilePictureUrl: "/images/mock/avatar.jpg",
- },
- currentlyPlaying:
- Math.random() > 0.9 // 10% chance of being null (not currently playing)
- ? {
- type: "movie",
- name: `Movie ${index}`,
- seasonName: undefined,
- episodeName: null,
- albumName: null,
- episodeCount: null,
- metadata: null,
- }
- : null,
- };
+export class MediaServerMockService implements IMediaServerIntegration {
+ public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise {
+ return sessions;
}
}
diff --git a/packages/integrations/src/mock/data/network-controller-summary.ts b/packages/integrations/src/mock/data/network-controller-summary.ts
index 7ffe80c71..bb708d5e3 100644
--- a/packages/integrations/src/mock/data/network-controller-summary.ts
+++ b/packages/integrations/src/mock/data/network-controller-summary.ts
@@ -3,28 +3,12 @@ import type { NetworkControllerSummary } from "../../types";
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise {
- return await Promise.resolve({
- lan: {
- guests: 5,
- users: 10,
- status: "enabled",
- },
- vpn: {
- users: 3,
- status: "enabled",
- },
- wanStatus: "disabled",
- wifi: {
- status: "disabled",
- guests: 0,
- users: 0,
- },
- www: {
- latency: 22,
- status: "enabled",
- ping: 32,
- uptime: 3600,
- },
- });
+ return {
+ lan: { guests: 5, users: 18, status: "enabled" },
+ vpn: { users: 3, status: "enabled" },
+ wanStatus: "enabled",
+ wifi: { status: "enabled", guests: 2, users: 12 },
+ www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
+ };
}
}
diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts
index f04959171..fd1cbe0ea 100644
--- a/packages/integrations/src/mock/mock-integration.ts
+++ b/packages/integrations/src/mock/mock-integration.ts
@@ -85,7 +85,7 @@ export class MockIntegration
// Health Monitoring Integrations
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
- getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
+ getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
// IndexerManagerIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
@@ -104,7 +104,7 @@ export class MockIntegration
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
// MediaServerIntegration
- getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
+ getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
// MediaTranscodingIntegration
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
diff --git a/packages/notifications/src/index.tsx b/packages/notifications/src/index.tsx
index 071496b20..41bb16964 100644
--- a/packages/notifications/src/index.tsx
+++ b/packages/notifications/src/index.tsx
@@ -1,6 +1,6 @@
import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications";
-import { IconCheck, IconX } from "@tabler/icons-react";
+import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
export const showSuccessNotification = (props: NotificationData) =>
notifications.show({
@@ -15,3 +15,10 @@ export const showErrorNotification = (props: NotificationData) =>
color: "red",
icon: ,
});
+
+export const showWarningNotification = (props: NotificationData) =>
+ notifications.show({
+ ...props,
+ color: "yellow",
+ icon: ,
+ });
diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts
index a31eed56c..0f2ae1102 100644
--- a/packages/request-handler/src/docker.ts
+++ b/packages/request-handler/src/docker.ts
@@ -14,10 +14,30 @@ import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-ha
const logger = createLogger({ module: "dockerRequestHandler" });
+const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
+
+const mockContainers = [
+ { id: "a1b2c3d4e5f6", name: "sonarr", host: "local", state: "running" as ContainerState, image: "lscr.io/linuxserver/sonarr:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg", cpuUsage: 2.3, memoryUsage: 256 * 1024 * 1024, ports: [{ PrivatePort: 8989, PublicPort: 8989, Type: "tcp" }] },
+ { id: "b2c3d4e5f6a7", name: "radarr", host: "local", state: "running" as ContainerState, image: "lscr.io/linuxserver/radarr:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg", cpuUsage: 1.8, memoryUsage: 220 * 1024 * 1024, ports: [{ PrivatePort: 7878, PublicPort: 7878, Type: "tcp" }] },
+ { id: "c3d4e5f6a7b8", name: "plex", host: "local", state: "running" as ContainerState, image: "lscr.io/linuxserver/plex:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg", cpuUsage: 12.5, memoryUsage: 1024 * 1024 * 1024, ports: [{ PrivatePort: 32400, PublicPort: 32400, Type: "tcp" }] },
+ { id: "d4e5f6a7b8c9", name: "qbittorrent", host: "local", state: "running" as ContainerState, image: "lscr.io/linuxserver/qbittorrent:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg", cpuUsage: 5.1, memoryUsage: 380 * 1024 * 1024, ports: [{ PrivatePort: 8080, PublicPort: 8080, Type: "tcp" }] },
+ { id: "e5f6a7b8c9d0", name: "prowlarr", host: "local", state: "running" as ContainerState, image: "lscr.io/linuxserver/prowlarr:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg", cpuUsage: 0.8, memoryUsage: 120 * 1024 * 1024, ports: [{ PrivatePort: 9696, PublicPort: 9696, Type: "tcp" }] },
+ { id: "f6a7b8c9d0e1", name: "overseerr", host: "local", state: "running" as ContainerState, image: "lscr.io/linuxserver/overseerr:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg", cpuUsage: 1.2, memoryUsage: 180 * 1024 * 1024, ports: [{ PrivatePort: 5055, PublicPort: 5055, Type: "tcp" }] },
+ { id: "a7b8c9d0e1f2", name: "homarr", host: "local", state: "running" as ContainerState, image: "ghcr.io/homarr-labs/homarr:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", cpuUsage: 3.4, memoryUsage: 290 * 1024 * 1024, ports: [{ PrivatePort: 7575, PublicPort: 7575, Type: "tcp" }] },
+ { id: "b8c9d0e1f2a3", name: "pihole", host: "local", state: "running" as ContainerState, image: "pihole/pihole:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg", cpuUsage: 0.5, memoryUsage: 95 * 1024 * 1024, ports: [{ PrivatePort: 80, PublicPort: 80, Type: "tcp" }, { PrivatePort: 53, PublicPort: 53, Type: "udp" }] },
+ { id: "c9d0e1f2a3b4", name: "nginx-proxy", host: "local", state: "running" as ContainerState, image: "jc21/nginx-proxy-manager:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg", cpuUsage: 0.3, memoryUsage: 65 * 1024 * 1024, ports: [{ PrivatePort: 443, PublicPort: 443, Type: "tcp" }, { PrivatePort: 81, PublicPort: 81, Type: "tcp" }] },
+ { id: "d0e1f2a3b4c5", name: "watchtower", host: "local", state: "running" as ContainerState, image: "containrrr/watchtower:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg", cpuUsage: 0.1, memoryUsage: 30 * 1024 * 1024, ports: [] },
+ { id: "e1f2a3b4c5d6", name: "tdarr", host: "local", state: "exited" as ContainerState, image: "ghcr.io/haveagitgat/tdarr:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg", cpuUsage: 0, memoryUsage: 0, ports: [] },
+ { id: "f2a3b4c5d6e7", name: "bazarr", host: "local", state: "running" as ContainerState, image: "lscr.io/linuxserver/bazarr:latest", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg", cpuUsage: 0.6, memoryUsage: 110 * 1024 * 1024, ports: [{ PrivatePort: 6767, PublicPort: 6767, Type: "tcp" }] },
+];
+
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
+ if (isDemoMode) {
+ return mockContainers;
+ }
return await getContainersWithStatsAsync();
},
cacheDuration: dayjs.duration(20, "seconds"),