import { objectEntries } from "@homarr/common"; import { createDocumentationLink } from "./docs"; import type { IntegrationKind } from "./integration"; import { getIntegrationKindsByCategory, integrationKinds } from "./integration"; import type { WidgetKind } from "./widget"; import { widgetDefaultSizes } from "./widget"; /** Integration selection shared by the widget UI and API trust boundary. */ export interface WidgetIntegrationConfig { supportedIntegrations: IntegrationKind[]; integrationsRequired?: false; maxIntegrations?: number; } const healthMonitoringGeneralIntegrationKinds = [ "openmediavault", "dashDot", "truenas", "unraid", "glances", "synology", ] as const satisfies readonly IntegrationKind[]; export const healthMonitoringSystemIntegrationKinds = [ ...healthMonitoringGeneralIntegrationKinds, "mock", ] as const satisfies readonly IntegrationKind[]; export const healthMonitoringClusterIntegrationKinds = [ "proxmox", "mock", ] as const satisfies readonly IntegrationKind[]; export const widgetIntegrationConfigs = { stats: { supportedIntegrations: integrationKinds.filter((kind) => kind !== "ical") }, dnsHoleSummary: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") }, dnsHoleControls: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") }, "smartHome-entityState": { supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), maxIntegrations: 1, }, "smartHome-executeAutomation": { supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), maxIntegrations: 1, }, mediaServer: { supportedIntegrations: getIntegrationKindsByCategory("mediaService") }, calendar: { supportedIntegrations: getIntegrationKindsByCategory("calendar"), integrationsRequired: false }, downloads: { supportedIntegrations: getIntegrationKindsByCategory("downloadClient") }, "mediaRequests-requestList": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") }, "mediaRequests-requestStats": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") }, mediaTranscoding: { supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"), maxIntegrations: 1, }, mediaMissing: { supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer") }, networkControllerSummary: { supportedIntegrations: getIntegrationKindsByCategory("networkController") }, networkControllerStatus: { supportedIntegrations: getIntegrationKindsByCategory("networkController") }, unifiOs: { supportedIntegrations: ["unifiOs"] }, unifiSiteManager: { supportedIntegrations: ["unifiSiteManager"] }, indexerManager: { supportedIntegrations: getIntegrationKindsByCategory("indexerManager") }, healthMonitoring: { supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds, ...healthMonitoringClusterIntegrationKinds], }, firewall: { supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"] }, notifications: { supportedIntegrations: getIntegrationKindsByCategory("notifications") }, mediaReleases: { supportedIntegrations: ["mock", "emby", "jellyfin", "plex"] }, systemResources: { supportedIntegrations: [...healthMonitoringSystemIntegrationKinds], }, systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] }, beszelAlerts: { supportedIntegrations: ["beszel", "mock"] }, beszelSystemGrid: { supportedIntegrations: ["beszel", "mock"] }, beszelSystemStats: { supportedIntegrations: ["beszel", "mock"] }, beszelSystemTable: { supportedIntegrations: ["beszel", "mock"] }, coolify: { supportedIntegrations: ["coolify", "mock"] }, "immich-serverStats": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 }, "immich-albumCarousel": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 }, paperlessNgx: { supportedIntegrations: ["paperlessNgx", "mock"], maxIntegrations: 1 }, patchmon: { supportedIntegrations: ["patchmon", "mock"], maxIntegrations: 1 }, bazarr: { supportedIntegrations: ["bazarr", "mock"], maxIntegrations: 1 }, tracearr: { supportedIntegrations: ["tracearr", "mock"] }, speedtestTracker: { supportedIntegrations: ["speedtestTracker", "mock"] }, uptimeKuma: { supportedIntegrations: ["uptimeKuma", "mock"] }, audioStats: { supportedIntegrations: ["navidrome", "audiobookshelf", "mock"], maxIntegrations: 1 }, umami: { supportedIntegrations: ["umami", "mock"], maxIntegrations: 1 }, vpn: { supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"] }, ups: { supportedIntegrations: getIntegrationKindsByCategory("ups") }, archiveTeamWarrior: { supportedIntegrations: ["archiveTeamWarrior", "mock"], maxIntegrations: 1 }, anchorNote: { supportedIntegrations: ["anchor", "mock"], maxIntegrations: 1 }, traefik: { supportedIntegrations: ["traefik", "mock"] }, wud: { supportedIntegrations: ["wud", "mock"], maxIntegrations: 1 }, llamacpp: { supportedIntegrations: ["llamacpp", "mock"], maxIntegrations: 1 }, wazuhAgents: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, wazuhAlerts: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, wazuhSummary: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, wazuhTimeline: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, wazuhTopList: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, wazuhVulnerabilities: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, wazuhFim: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, wazuhAuthFailures: { supportedIntegrations: ["wazuh", "mock"], maxIntegrations: 1 }, } satisfies Partial>; export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs; export type WidgetIntegrationKind = (typeof widgetIntegrationConfigs)[TKind]["supportedIntegrations"][number]; export const getWidgetIntegrationConfig = ( kind: TKind, ): (typeof widgetIntegrationConfigs)[TKind] => widgetIntegrationConfigs[kind]; const widgetIntegrationConfigEntries = objectEntries(widgetIntegrationConfigs); const createWidgetIntegrationSupport = () => { const result: Partial> = {}; for (const [kind, config] of widgetIntegrationConfigEntries) result[kind] = config.supportedIntegrations; return result; }; export const widgetIntegrationSupport = createWidgetIntegrationSupport(); /** Widgets that remain useful without a configured integration. */ export const widgetKindsWithOptionalIntegrations = new Set( widgetIntegrationConfigEntries .filter(([, config]) => "integrationsRequired" in config && config.integrationsRequired === false) .map(([kind]) => kind), ); const createWidgetIntegrationLimits = () => { const result: Partial> = {}; for (const [kind, config] of widgetIntegrationConfigEntries) { if ("maxIntegrations" in config) result[kind] = config.maxIntegrations; } return result; }; export const widgetIntegrationLimits = createWidgetIntegrationLimits(); export type WidgetIntegrationIssue = | { code: "integration-not-supported" } | { code: "integration-required" } | { code: "incompatible-integration"; incompatibleKinds: IntegrationKind[] } | { code: "integration-limit"; limit: number }; export const getWidgetIntegrationIssue = ( widgetKind: WidgetKind, selectedIntegrationKinds: readonly IntegrationKind[], ): WidgetIntegrationIssue | null => { const supportedIntegrations = widgetIntegrationSupport[widgetKind]; const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY; if (selectedIntegrationKinds.length > integrationLimit) { return { code: "integration-limit", limit: integrationLimit }; } if (supportedIntegrations === undefined && selectedIntegrationKinds.length > 0) { return { code: "integration-not-supported" }; } if ( supportedIntegrations !== undefined && selectedIntegrationKinds.length === 0 && !widgetKindsWithOptionalIntegrations.has(widgetKind) ) { return { code: "integration-required" }; } const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind)); if (incompatibleKinds.length === 0) return null; return { code: "incompatible-integration", incompatibleKinds }; }; export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => { if (issue.code === "integration-limit") { return `${widgetKind} supports at most ${issue.limit} integration${issue.limit === 1 ? "" : "s"}`; } if (issue.code === "integration-not-supported") return `${widgetKind} does not support integrations`; if (issue.code === "integration-required") return `${widgetKind} requires an integration`; return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`; }; const createIntegrationWidgetSupport = () => { const result = {} as Record; for (const integrationKind of integrationKinds) { result[integrationKind] = widgetIntegrationConfigEntries .filter(([, config]) => supportsIntegration(config, integrationKind)) .map(([widgetKind]) => widgetKind); } return result; }; const supportsIntegration = (config: WidgetIntegrationConfig, integrationKind: IntegrationKind) => config.supportedIntegrations.includes(integrationKind); export const integrationWidgetSupport = createIntegrationWidgetSupport(); export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { return [...integrationWidgetSupport[integrationKind]]; }; export interface DefaultWidgetConfig { kind: WidgetKind; width: number; height: number; options?: Record; skip?: boolean; } export const defaultWidgetConfigs: DefaultWidgetConfig[] = [ { kind: "clock", width: 2, height: 1 }, { kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } }, { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, ]; const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); export const getDefaultWidgetConfig = (kind: WidgetKind): DefaultWidgetConfig => defaultWidgetConfigByKind.get(kind) ?? { kind, ...(widgetDefaultSizes[kind] ?? { width: 1, height: 1 }) }; export const generalWidgets: WidgetKind[] = defaultWidgetConfigs .filter((config) => !config.skip) .map((config) => config.kind); export const featuredIntegrations: readonly IntegrationKind[] = [ "sonarr", "radarr", "prowlarr", "sabNzbd", "qBittorrent", "seerr", "jellyfin", ]; export const hiddenFromOnboarding = new Set(["mock"]); export const defaultBookmarkApps = [ { name: "Homarr Docs", href: createDocumentationLink("/docs/getting-started"), iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", }, { name: "Homarr GitHub", href: "https://github.com/homarr-labs/homarr", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/github.svg", }, { name: "Help Translate", href: createDocumentationLink("/docs/community/translations"), iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", }, { name: "Support Homarr", href: "https://opencollective.com/homarr", iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg", }, ];