Files
homarr/packages/definitions/src/widget-integration-map.ts

211 lines
9.7 KiB
TypeScript

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<Record<WidgetKind, NativeFeatureCapabilityDescriptor>>;
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
WidgetKind,
NativeFeatureCapabilityDescriptor,
][];
const createWidgetIntegrationSupport = () => {
const result: Partial<Record<WidgetKind, readonly IntegrationKind[]>> = {};
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<WidgetKind>(
nativeFeatureCapabilityEntries
.filter(([, capability]) => capability.connectionOptional === true)
.map(([kind]) => kind),
);
/** Widgets that support fewer integrations than the default unlimited selection. */
const createWidgetIntegrationLimits = () => {
const result: Partial<Record<WidgetKind, number>> = {};
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<IntegrationKind, readonly WidgetKind[]>;
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<string, unknown>;
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<IntegrationKind>(["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",
},
];