import { createDocumentationLink } from "./docs"; import type { IntegrationKind } from "./integration"; import { getIntegrationKindsByCategory, integrationKinds } from "./integration"; import type { WidgetKind } from "./widget"; import { widgetDefaultSizes } from "./widget"; /** * Server-safe capability metadata shared by the API and widget catalog. * Keep this intentionally small: presentation, options, and request contracts * remain owned by their feature packages. */ export interface NativeFeatureCapabilityDescriptor { integrations: readonly IntegrationKind[]; connectionOptional?: true; serverMaxIntegrations?: number; } export const nativeFeatureCapabilities = { dnsHoleSummary: { integrations: getIntegrationKindsByCategory("dnsHole") }, dnsHoleControls: { integrations: getIntegrationKindsByCategory("dnsHole") }, "smartHome-entityState": { integrations: getIntegrationKindsByCategory("smartHomeServer"), serverMaxIntegrations: 1, }, "smartHome-executeAutomation": { integrations: getIntegrationKindsByCategory("smartHomeServer"), serverMaxIntegrations: 1, }, mediaServer: { integrations: getIntegrationKindsByCategory("mediaService") }, calendar: { integrations: getIntegrationKindsByCategory("calendar"), connectionOptional: true }, downloads: { integrations: getIntegrationKindsByCategory("downloadClient") }, "mediaRequests-requestList": { integrations: getIntegrationKindsByCategory("mediaRequest") }, "mediaRequests-requestStats": { integrations: getIntegrationKindsByCategory("mediaRequest") }, mediaTranscoding: { integrations: getIntegrationKindsByCategory("mediaTranscoding"), serverMaxIntegrations: 1 }, mediaMissing: { integrations: getIntegrationKindsByCategory("mediaOrganizer") }, networkControllerSummary: { integrations: getIntegrationKindsByCategory("networkController") }, networkControllerStatus: { integrations: getIntegrationKindsByCategory("networkController") }, indexerManager: { integrations: getIntegrationKindsByCategory("indexerManager") }, healthMonitoring: { integrations: getIntegrationKindsByCategory("healthMonitoring").filter( (kind) => kind !== "patchmon" && kind !== "wud", ), }, firewall: { integrations: [...getIntegrationKindsByCategory("firewall"), "mock"] }, notifications: { integrations: getIntegrationKindsByCategory("notifications") }, mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] }, systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"] }, systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] }, beszelAlerts: { integrations: ["beszel", "mock"] }, beszelSystemGrid: { integrations: ["beszel", "mock"] }, beszelSystemStats: { integrations: ["beszel", "mock"] }, beszelSystemTable: { integrations: ["beszel", "mock"] }, coolify: { integrations: ["coolify", "mock"] }, "immich-serverStats": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 }, "immich-albumCarousel": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 }, paperlessNgx: { integrations: ["paperlessNgx", "mock"], serverMaxIntegrations: 1 }, patchmon: { integrations: ["patchmon", "mock"], serverMaxIntegrations: 1 }, bazarr: { integrations: ["bazarr", "mock"], serverMaxIntegrations: 1 }, tracearr: { integrations: ["tracearr", "mock"] }, speedtestTracker: { integrations: ["speedtestTracker", "mock"] }, uptimeKuma: { integrations: ["uptimeKuma", "mock"] }, audioStats: { integrations: ["navidrome", "audiobookshelf", "mock"], serverMaxIntegrations: 1 }, umami: { integrations: ["umami", "mock"], serverMaxIntegrations: 1 }, vpn: { integrations: [...getIntegrationKindsByCategory("vpn"), "mock"] }, ups: { integrations: getIntegrationKindsByCategory("ups") }, archiveTeamWarrior: { integrations: ["archiveTeamWarrior", "mock"], serverMaxIntegrations: 1 }, anchorNote: { integrations: ["anchor", "mock"], serverMaxIntegrations: 1 }, traefik: { integrations: ["traefik", "mock"] }, wud: { integrations: ["wud", "mock"], serverMaxIntegrations: 1 }, } as const satisfies Partial>; const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [ WidgetKind, NativeFeatureCapabilityDescriptor, ][]; const createWidgetIntegrationSupport = () => { const result: Partial> = {}; for (const [kind, capability] of nativeFeatureCapabilityEntries) result[kind] = capability.integrations; return result; }; export const widgetIntegrationSupport = createWidgetIntegrationSupport(); /** Widgets that remain useful without a configured integration. */ export const widgetKindsWithOptionalIntegrations = new Set( nativeFeatureCapabilityEntries .filter(([, capability]) => capability.connectionOptional === true) .map(([kind]) => kind), ); /** Widgets that support fewer integrations than the default unlimited selection. */ const createWidgetIntegrationLimits = () => { const result: Partial> = {}; for (const [kind, capability] of nativeFeatureCapabilityEntries) { if (capability.serverMaxIntegrations !== undefined) result[kind] = capability.serverMaxIntegrations; } 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, integrationKinds: readonly IntegrationKind[], ): WidgetIntegrationIssue | null => { const supportedIntegrations = widgetIntegrationSupport[widgetKind]; const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY; if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit }; if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" }; if ( supportedIntegrations !== undefined && integrationKinds.length === 0 && !widgetKindsWithOptionalIntegrations.has(widgetKind) ) { return { code: "integration-required" }; } const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind)); return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null; }; 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(", ")}`; }; /** Reverse capability index generated from nativeFeatureCapabilities. */ const createIntegrationWidgetSupport = () => { const result = {} as Record; for (const integrationKind of integrationKinds) { result[integrationKind] = nativeFeatureCapabilityEntries .filter(([, capability]) => capability.integrations.includes(integrationKind)) .map(([widgetKind]) => widgetKind); } return result; }; export const integrationWidgetSupport = createIntegrationWidgetSupport(); export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => [ ...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", }, ];