Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4782e24b79 | ||
|
|
3eb3908d4c | ||
|
|
d6f14de6d1 | ||
|
|
0ad7b79187 | ||
|
|
35bc50c12a |
No files matched your search
@@ -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,20 @@ 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.",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
</Text>
|
||||
</Stack>
|
||||
{appEnv.DEMO_MODE && (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
|
||||
<Text size="sm" fw={500}>
|
||||
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
|
||||
@@ -42,8 +42,9 @@ export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
if (!hasChangeAccess || isLoading) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
|
||||
@@ -51,11 +52,13 @@ export const BoardContentHeaderActions = () => {
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
|
||||
<EditModeMenu />
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
{!demoMode && (
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
@@ -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);
|
||||
|
||||
@@ -25,6 +25,7 @@ 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";
|
||||
|
||||
@@ -96,7 +96,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||
>
|
||||
{children}
|
||||
<MenuDropdown>
|
||||
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION || env.DEMO_MODE} />
|
||||
</MenuDropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -19,6 +19,9 @@
|
||||
|
||||
<!-- Links -->
|
||||
<p align="center">
|
||||
<a href="https://demo.homarr.dev">
|
||||
<strong>Live Demo 🎮</strong>
|
||||
</a> •
|
||||
<a href="https://homarr.dev/docs/category/installation-1/">
|
||||
<strong>Install 💻</strong>
|
||||
</a> •
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,134 @@ 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<string, unknown>;
|
||||
}
|
||||
|
||||
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 +361,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 +386,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 +414,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.",
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,130 @@
|
||||
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<CalendarEvent[]> {
|
||||
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: [
|
||||
{
|
||||
@@ -29,63 +135,79 @@ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
|
||||
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<CalendarEvent[]> {
|
||||
return await Promise.resolve(buildEventsForRange(start, end));
|
||||
}
|
||||
}
|
||||
@@ -2,57 +2,163 @@ 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<DownloadClientItem, "id" | "index">[] = [
|
||||
{
|
||||
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<DownloadClientJobsAndStatus> {
|
||||
return await Promise.resolve({
|
||||
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"],
|
||||
types: ["torrent", "usenet"] as const,
|
||||
},
|
||||
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
|
||||
0,
|
||||
input.limit,
|
||||
),
|
||||
items: mockItems.slice(0, input.limit).map((item, index) => ({
|
||||
...item,
|
||||
id: `mock-${index}`,
|
||||
index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
public async pauseQueueAsync(): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async resumeQueueAsync(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
|
||||
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,
|
||||
};
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
|
||||
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.",
|
||||
"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 +34,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 +50,86 @@ 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,
|
||||
},
|
||||
];
|
||||
@@ -1,36 +1,95 @@
|
||||
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<StreamSession[]> {
|
||||
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<StreamSession[]> {
|
||||
return await Promise.resolve(sessions);
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,11 @@ import type { NetworkControllerSummary } from "../../types";
|
||||
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
|
||||
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
|
||||
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,
|
||||
},
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: <IconX size={20} />,
|
||||
});
|
||||
|
||||
export const showWarningNotification = (props: NotificationData) =>
|
||||
notifications.show({
|
||||
...props,
|
||||
color: "yellow",
|
||||
icon: <IconInfoCircle size={20} />,
|
||||
});
|
||||
@@ -7,17 +7,174 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db, like, or } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import type { ContainerState, Port } from "@homarr/docker";
|
||||
import { dockerLabels, DockerSingleton } from "@homarr/docker";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "dockerRequestHandler" });
|
||||
|
||||
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
|
||||
|
||||
const port = (privatePort: number, publicPort: number, type: string): Port => ({
|
||||
IP: "0.0.0.0",
|
||||
PrivatePort: privatePort,
|
||||
PublicPort: publicPort,
|
||||
Type: type,
|
||||
});
|
||||
|
||||
const mockContainers: {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
state: ContainerState;
|
||||
image: string;
|
||||
iconUrl: string;
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
ports: Port[];
|
||||
}[] = [
|
||||
{
|
||||
id: "a1b2c3d4e5f6",
|
||||
name: "sonarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(8989, 8989, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "b2c3d4e5f6a7",
|
||||
name: "radarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(7878, 7878, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "c3d4e5f6a7b8",
|
||||
name: "plex",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(32400, 32400, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "d4e5f6a7b8c9",
|
||||
name: "qbittorrent",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(8080, 8080, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "e5f6a7b8c9d0",
|
||||
name: "prowlarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(9696, 9696, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "f6a7b8c9d0e1",
|
||||
name: "overseerr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(5055, 5055, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "a7b8c9d0e1f2",
|
||||
name: "homarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(7575, 7575, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "b8c9d0e1f2a3",
|
||||
name: "pihole",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(80, 80, "tcp"), port(53, 53, "udp")],
|
||||
},
|
||||
{
|
||||
id: "c9d0e1f2a3b4",
|
||||
name: "nginx-proxy",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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: [port(443, 443, "tcp"), port(81, 81, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "d0e1f2a3b4c5",
|
||||
name: "watchtower",
|
||||
host: "local",
|
||||
state: "running",
|
||||
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",
|
||||
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",
|
||||
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: [port(6767, 6767, "tcp")],
|
||||
},
|
||||
];
|
||||
|
||||
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "dockerContainersResult",
|
||||
widgetKind: "dockerContainers",
|
||||
async requestAsync() {
|
||||
if (isDemoMode) {
|
||||
return mockContainers;
|
||||
}
|
||||
return await getContainersWithStatsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(20, "seconds"),
|
||||
|
||||
Reference in new issue
Block a user