import SuperJSON from "superjson"; import { createId, objectKeys } from "@homarr/common"; import { customWidgetImportSchema } from "@homarr/validation/custom-widget"; import { createDocumentationLink, credentialsAdminGroup, defaultBookmarkApps, everyoneGroup, getIntegrationDefaultUrl, getIntegrationName, integrationDefs, integrationKinds, } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; import { eq, inArray } from ".."; import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { getServerSettingByKeyAsync, insertServerSettingByKeyAsync, updateServerSettingByKeyAsync, } from "../queries/server-setting"; import { apps, boards, groupMembers, groupPermissions, groups, integrationItems, integrations, itemLayouts, items, layouts, onboarding, searchEngines, sections, users, customWidgetDefinitions, } from "../schema"; import type { Integration } from "../schema"; const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase()); const CUSTOM_WIDGET_SEEDS: Array<{ id: string; data: Record }> = [ { id: "seed-dog-facts", data: { $schema: "homarr-custom-widget-v2", name: "Random Dog Fact", description: "Displays a random fun fact about dogs", url: "https://dogapi.dog/api/v2/facts", authType: "none", method: "GET", displayType: "singleValue", displayConfig: { type: "singleValue", jsonPath: "$.data[0].attributes.body", label: "Dog Fact", unit: "", valueSize: "sm", labelPosition: "above", }, }, }, { id: "seed-currency-exchange", data: { $schema: "homarr-custom-widget-v2", name: "Currency Exchange (JPY)", description: "Converts 50 Japanese Yen to EUR and USD using European Central Bank rates", url: "https://api.frankfurter.dev/v1/latest?from=JPY&to=EUR,USD&amount=50", authType: "none", method: "GET", displayType: "keyValue", displayConfig: { type: "keyValue", mappings: [ { label: "50 JPY → EUR", jsonPath: "$.rates.EUR", unit: "€" }, { label: "50 JPY → USD", jsonPath: "$.rates.USD", unit: "$" }, ], layout: "list", columns: 2, }, }, }, { id: "seed-jellyfin", data: { $schema: "homarr-custom-widget-v2", name: "Jellyfin library", description: "Counts the number of movies, series, episodes and songs in the library", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", url: "https://jellyfin.homelab.com/Items/Counts", authType: "apiKeyHeader", headerName: "X-Emby-Token", method: "GET", requestBody: null, displayType: "countGrid", displayConfig: { type: "countGrid", items: [ { label: "Movies", jsonPath: "$.MovieCount", unit: "" }, { label: "Series", jsonPath: "$.SeriesCount", unit: "" }, { label: "Episodes", jsonPath: "$.EpisodeCount", unit: "" }, { label: "Songs", jsonPath: "$.SongCount", unit: "" }, ], columns: 4, valueSize: "lg", }, }, }, { id: "seed-pokedex", data: { $schema: "homarr-custom-widget-v2", name: "Pokédex", description: "Browseable Pokémon list", iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png", url: "https://pokeapi.co/api/v2/pokemon?limit=75", authType: "none", headerName: null, method: "GET", requestBody: null, displayType: "customJsx", displayConfig: { type: "customJsx", template: '\n \n \n \n Pokédex\n \n \n \n\n \n \n {data.results.map((pokemon, i) =>\n \n \n \n \n \n \n #{String(i + 1).padStart(3, "0")}\n \n \n {pokemon.name}\n \n \n\n \n \n \n \n \n \n \n )}\n \n \n', }, }, }, ]; export const seedDataAsync = async (db: Database) => { if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) { console.warn( "UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.", ); } await seedEveryoneGroupAsync(db); await seedOnboardingAsync(db); await seedServerSettingsAsync(db); await seedDefaultSearchEnginesAsync(db); await seedDefaultIntegrationsAsync(db); await seedDefaultAppsAsync(db); await seedDefaultBoardAsync(db); await seedDefaultCustomWidgetsAsync(db); await seedBoardWidgetsAsync(db); if (isTruthyEnv(process.env.DEMO_MODE)) { await seedDemoUserAsync(db); } }; const seedEveryoneGroupAsync = async (db: Database) => { const group = await db.query.groups.findFirst({ where: eq(groups.name, everyoneGroup), }); if (group) { console.log("Skipping seeding of group 'everyone' as it already exists"); return; } await db.insert(groups).values({ id: createId(), name: everyoneGroup, position: -1, }); console.log("Created group 'everyone' through seed"); }; const seedOnboardingAsync = async (db: Database) => { const existing = await db.query.onboarding.findFirst(); if (existing) { console.log("Skipping seeding of onboarding as it already exists"); return; } await db.insert(onboarding).values({ id: createId(), step: "start", }); console.log("Created onboarding step through seed"); }; const seedDefaultSearchEnginesAsync = async (db: Database) => { const existingSearchEngines = await db.$count(searchEngines); if (existingSearchEngines > 0) { console.log("Skipping seeding of default search engines as some already exists"); return; } const homarrId = createId(); const defaultSearchEngines = [ { id: createId(), name: "Google", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google.svg", short: "g", description: "Search the web with Google", urlTemplate: "https://www.google.com/search?q=%s", type: "generic" as const, integrationId: null, }, { id: createId(), name: "YouTube", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/youtube.svg", short: "yt", description: "Search for videos on YouTube", urlTemplate: "https://www.youtube.com/results?search_query=%s", type: "generic" as const, integrationId: null, }, { id: homarrId, name: "Homarr Docs", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", short: "docs", description: "Search the Homarr documentation", urlTemplate: createDocumentationLink("/search", undefined, { q: "%s" }), type: "generic" as const, integrationId: null, }, ]; await db.insert(searchEngines).values(defaultSearchEngines); console.log(`Created ${defaultSearchEngines.length} default search engines through seeding process`); // Set Homarr docs as the default search engine in server settings const searchSettings = await getServerSettingByKeyAsync(db, "search"); await updateServerSettingByKeyAsync(db, "search", { ...searchSettings, defaultSearchEngineId: homarrId, }); console.log("Set Homarr docs as the default search engine"); }; const seedServerSettingsAsync = async (db: Database) => { const serverSettingsData = await db.query.serverSettings.findMany(); for (const settingsKey of defaultServerSettingsKeys) { const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey); if (!currentDbEntry) { await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]); console.log(`Created serverSetting through seed key=${settingsKey}`); continue; } const currentSettings = await getServerSettingByKeyAsync(db, settingsKey); const defaultSettings = defaultServerSettings[settingsKey]; const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings)); if (missingKeys.length === 0) { console.info(`Skipping seeding for serverSetting as it already exists key=${settingsKey}`); continue; } await updateServerSettingByKeyAsync(db, settingsKey, { ...defaultSettings, ...currentSettings }); console.log(`Updated serverSetting through seed key=${settingsKey}`); } }; const seedDefaultIntegrationsAsync = async (db: Database) => { const defaultIntegrations = integrationKinds.reduce((acc, kind) => { const name = getIntegrationName(kind); const defaultUrl = getIntegrationDefaultUrl(kind); const hasNoAuthOption = integrationDefs[kind].secretKinds.some((kinds) => kinds.length === 0); if (defaultUrl !== undefined && hasNoAuthOption) { acc.push({ id: "new", name: `${name} Default`, url: defaultUrl, kind, appId: null, }); } return acc; }, []); if (defaultIntegrations.length === 0) { console.warn("No default integrations found to seed"); return; } let createdCount = 0; await Promise.all( defaultIntegrations.map(async (integration) => { const existingKind = await db.$count(integrations, eq(integrations.kind, integration.kind)); if (existingKind > 0) { console.log(`Skipping seeding of default ${integration.kind} integration as one already exists`); return; } const newIntegration = { ...integration, id: createId(), }; await db.insert(integrations).values(newIntegration); createdCount++; }), ); if (createdCount === 0) { console.log("No default integrations were created as they already exist"); return; } console.log(`Created ${createdCount} default integrations through seeding process`); }; const seedDefaultAppsAsync = async (db: Database) => { const existingApps = await db.$count(apps); if (existingApps > 0) { console.log("Skipping seeding of default apps as some already exist"); return; } for (const app of defaultBookmarkApps) { await db.insert(apps).values({ id: createId(), name: app.name, iconUrl: app.iconUrl, href: app.href, }); } console.log(`Created ${defaultBookmarkApps.length} default apps through seeding process`); }; const seedDefaultBoardAsync = async (db: Database) => { const existingBoard = await db.query.boards.findFirst(); if (existingBoard) { console.log("Skipping seeding of default board as one already exists"); return; } const boardId = createId(); await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false, }); await db.insert(sections).values({ id: createId(), kind: "empty", xOffset: 0, yOffset: 0, boardId, }); await db.insert(layouts).values({ id: createId(), name: "Base", columnCount: 10, breakpoint: 0, boardId, }); const everyoneGroupRow = await db.query.groups.findFirst({ where: eq(groups.name, everyoneGroup), }); if (everyoneGroupRow) { await db.update(groups).set({ homeBoardId: boardId }).where(eq(groups.id, everyoneGroupRow.id)); console.log("Set default board as home board for everyone group"); } console.log("Created default board 'dashboard' through seed"); }; 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: beszelSystemGrid + beszelAlerts = 12 { kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true }, { kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true }, // Row 10: beszelSystemTable + beszelSystemStats = 12 { kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true }, { kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true }, // Row 11: notebook + dockerContainers + weather = 12 { kind: "notebook", width: 4, height: 4, needsIntegration: false }, { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false }, { kind: "weather", width: 2, height: 1, needsIntegration: false }, ]; const seedDemoUserAsync = async (db: Database) => { const existingUser = await db.query.users.findFirst({ where: eq(users.name, "demo"), }); if (existingUser) { console.log("Skipping seeding of demo user as it already exists"); return; } const userId = createId(); await db.insert(users).values({ id: userId, name: "demo", email: "demo@example.com", password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u", }); const maxPosition = await getMaxGroupPositionAsync(db); const groupId = createId(); await db.insert(groups).values({ id: groupId, name: credentialsAdminGroup, ownerId: userId, position: maxPosition + 1, }); await db.insert(groupPermissions).values({ groupId, permission: "admin", }); await db.insert(groupMembers).values({ groupId, userId, }); await db.update(onboarding).set({ step: "finish", 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, name: "default", isPublic: false, creatorId: userId, }); const sectionId = createId(); await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId, }); const layoutId = createId(); await db.insert(layouts).values({ 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( "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.", ); }; const seedDefaultCustomWidgetsAsync = async (db: Database) => { const seedIds = CUSTOM_WIDGET_SEEDS.map((s) => s.id); const existing = await db.query.customWidgetDefinitions.findMany({ columns: { id: true }, where: inArray(customWidgetDefinitions.id, seedIds), }); const existingIds = new Set(existing.map((row) => row.id)); const seedValues = CUSTOM_WIDGET_SEEDS.filter((seed) => !existingIds.has(seed.id)).map((seed) => { const parsed = customWidgetImportSchema.parse(seed.data); return { id: seed.id, name: parsed.name, description: parsed.description ?? null, iconUrl: parsed.iconUrl ?? null, url: parsed.url, authType: parsed.authType, headerName: parsed.headerName ?? null, method: parsed.method, requestBody: parsed.requestBody ?? null, displayType: parsed.displayType, displayConfig: SuperJSON.stringify(parsed.displayConfig), enabled: false, creatorId: null, }; }); if (seedValues.length === 0) { console.log("Skipping seeding of default custom widgets as they already exist"); return; } await db.insert(customWidgetDefinitions).values(seedValues); console.log(`Created ${seedValues.length} default custom widgets through seeding process`); }; const seedBoardWidgetsAsync = async (db: Database) => { const existingItems = await db.$count(items); if (existingItems > 0) { console.log("Skipping seeding of board widgets as some already exist"); return; } const board = await db.query.boards.findFirst({ with: { sections: true, layouts: true }, }); if (!board) return; const section = board.sections.find((sec) => sec.kind === "empty"); const layout = board.layouts[0]; if (!section || !layout) return; const allIntegrations = await db.query.integrations.findMany(); const allApps = await db.query.apps.findMany(); const count = await placeAllWidgetsAsync( db, { boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount }, allIntegrations, allApps, ); console.log(`Placed ${count} widgets on board`); };