feat(widgets): consolidate integration metadata + shared response caching (#6691)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-30 18:38:07 +02:00
1 parent 7d1d9062ed
commit 8d8dec5749
206 files changed
+3893 -1946

No files matched your search

+3 -2
View File
@@ -1,5 +1,7 @@
import { IconNotes } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -7,8 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("anchorNot
icon: IconNotes,
supportsAdvancedFocus: true,
queryKey: [["widget", "anchorNotes"]],
maxIntegrations: 1,
supportedIntegrations: ["anchor", "mock"],
...getWidgetIntegrationConfig("anchorNote"),
createOptions() {
return optionsBuilder.from((factory) => ({
noteId: factory.anchorNote(),
+5 -2
View File
@@ -7,8 +7,11 @@ import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "appWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
const appIds = items.map((item) => item.options.appId);
const prefetchAllAsync: Prefetch = async (queryClient, items) => {
const appIds: string[] = [];
for (const item of items) {
if (typeof item.options.appId === "string") appIds.push(item.options.appId);
}
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
@@ -1,12 +1,14 @@
import { IconArchive } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
supportsAdvancedFocus: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("archiveTeamWarrior"),
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
@@ -14,5 +16,4 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
}),
}));
},
supportedIntegrations: ["archiveTeamWarrior", "mock"],
}).withDynamicImport(() => import("./component"));
+3 -3
View File
@@ -1,5 +1,7 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
@@ -17,9 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
supportsAdvancedFocus: true,
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
queryMatcher: matchesWidgetRuntimeQuery,
supportedIntegrations: ["navidrome", "audiobookshelf", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("audioStats"),
createOptions() {
return optionsBuilder.from(
(factory) => ({
+3 -3
View File
@@ -1,14 +1,14 @@
import { IconBadgeCc, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: true,
icon: IconBadgeCc,
supportedIntegrations: ["bazarr", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("bazarr"),
createOptions() {
return optionsBuilder.from((factory) => ({
showMissingEpisodes: factory.switch({
+3 -2
View File
@@ -1,6 +1,8 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
@@ -17,8 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle
maxHistoryItems: scope.options.maxHistoryItems,
});
},
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
...getWidgetIntegrationConfig("beszelAlerts"),
createOptions() {
return optionsBuilder.from((factory) => ({
showHistory: factory.switch({ defaultValue: true }),
@@ -1,5 +1,7 @@
import { IconLayoutGrid, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
@@ -7,8 +9,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel", "getSystems"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
...getWidgetIntegrationConfig("beszelSystemGrid"),
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({
@@ -1,6 +1,7 @@
import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
@@ -40,8 +41,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
(dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true }))
);
},
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
...getWidgetIntegrationConfig("beszelSystemStats"),
createOptions() {
return optionsBuilder.from((factory) => ({
systemId: factory.integrationSelect({
@@ -1,6 +1,6 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { getWidgetIntegrationConfig, invariantTechnicalLabels } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import type { SelectOption } from "../_inputs/widget-select-input";
@@ -33,8 +33,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
query.path.at(-1) === "getSystems"
? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds })
: matchesWidgetRuntimeQuery(query, scope),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
...getWidgetIntegrationConfig("beszelSystemTable"),
createOptions() {
return optionsBuilder.from(
(factory) => ({
+8 -3
View File
@@ -8,8 +8,13 @@ import { getDirectBookmarkUrl } from "./bookmark-item";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value)));
const getAppIds = (options: Record<string, unknown>) => {
if (!Array.isArray(options.items)) return [];
return options.items.filter((value): value is string => typeof value === "string" && !getDirectBookmarkUrl(value));
};
const prefetchAllAsync: Prefetch = async (queryClient, items) => {
const appIds = items.flatMap((item) => getAppIds(item.options));
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
@@ -17,7 +22,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
});
for (const item of items) {
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
const itemAppIds = getAppIds(item.options);
if (itemAppIds.length === 0) {
continue;
}
+2 -3
View File
@@ -1,7 +1,7 @@
import { IconCalendar } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
@@ -34,6 +34,5 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar"
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
integrationsRequired: false,
...getWidgetIntegrationConfig("calendar"),
}).withDynamicImport(() => import("./component"));
+2 -119
View File
@@ -1,119 +1,2 @@
import {
IconActivityHeartbeat,
IconAd,
IconApi,
IconApps,
IconArchive,
IconBadgeCc,
IconBatteryCharging,
IconBell,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartAreaLine,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconFileText,
IconGraphFilled,
IconHourglass,
IconHeadphones,
IconHeartRateMonitor,
IconHeartbeat,
IconLayoutGrid,
IconMessage,
IconMovie,
IconNotes,
IconPhoto,
IconReportSearch,
IconRocket,
IconRobot,
IconRoute,
IconRss,
IconServer2,
IconShieldCheck,
IconShieldLock,
IconSpeedboat,
IconTable,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { WidgetKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
"immich-serverStats": IconGraphFilled,
"immich-albumCarousel": IconPhoto,
paperlessNgx: IconFileText,
patchmon: IconShieldCheck,
bazarr: IconBadgeCc,
tracearr: IconActivityHeartbeat,
speedtestTracker: IconSpeedboat,
uptimeKuma: IconHeartbeat,
audioStats: IconHeadphones,
umami: IconChartBar,
vpn: IconShieldLock,
archiveTeamWarrior: IconArchive,
ups: IconBatteryCharging,
beszelSystemTable: IconTable,
beszelSystemGrid: IconLayoutGrid,
beszelAlerts: IconBell,
beszelSystemStats: IconChartAreaLine,
traefik: IconRoute,
customApi: IconApi,
wud: IconBrandDocker,
};
/** @deprecated Import widgetCatalogIcons from "@homarr/ui/widget-icons" instead. */
export { widgetCatalogIcons } from "@homarr/ui/widget-icons";
+3 -1
View File
@@ -1,5 +1,7 @@
import { IconCloud, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -19,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify",
}),
}));
},
supportedIntegrations: ["coolify", "mock"],
...getWidgetIntegrationConfig("coolify"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+5 -6
View File
@@ -1,5 +1,4 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
@@ -10,7 +9,7 @@ import type { SettingsContextProps } from "@homarr/settings/creator";
import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { WidgetImports } from "./registry";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
@@ -75,7 +74,7 @@ export interface WidgetContextActionProps {
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
(componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
(componentLoader: () => Promise<{ default: React.ComponentType<WidgetComponentProps<TKind>> }>) => ({
definition: {
...definition,
kind,
@@ -84,11 +83,11 @@ const createWithDynamicImport =
componentLoader,
});
export type PrefetchLoader<TKind extends WidgetKind> = () => Promise<{ default: Prefetch<TKind> }>;
export type Prefetch<TKind extends WidgetKind> = (
export type PrefetchLoader = () => Promise<{ default: Prefetch }>;
export type Prefetch = (
queryClient: QueryClient,
items: {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
options: Record<string, unknown>;
integrationIds: string[];
}[],
) => Promise<void>;
@@ -1,6 +1,6 @@
import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
...getWidgetIntegrationConfig("dnsHoleControls"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
@@ -1,6 +1,6 @@
import { IconAd, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -30,7 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
...getWidgetIntegrationConfig("dnsHoleSummary"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+2 -2
View File
@@ -1,7 +1,7 @@
import { IconDownload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory, getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
@@ -111,5 +111,5 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
},
);
},
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
...getWidgetIntegrationConfig("downloads"),
}).withDynamicImport(() => import("./component"));
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconWall, IconWallOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -12,7 +12,7 @@ export const { definition, componentLoader } = createWidgetDefinition("firewall"
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"],
...getWidgetIntegrationConfig("firewall"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconWallOff,
@@ -1,6 +1,6 @@
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -114,9 +114,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
},
);
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter(
(kind) => kind !== "patchmon" && kind !== "wud",
),
...getWidgetIntegrationConfig("healthMonitoring"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
@@ -1,3 +1,4 @@
import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
interface HealthMonitoringIntegration {
@@ -5,8 +6,11 @@ interface HealthMonitoringIntegration {
kind: IntegrationKind;
}
const systemIntegrationKinds = new Set<IntegrationKind>(healthMonitoringSystemIntegrationKinds);
const clusterIntegrationKinds = new Set<IntegrationKind>(healthMonitoringClusterIntegrationKinds);
export const partitionHealthMonitoringIntegrations = (integrations: HealthMonitoringIntegration[]) => ({
clusterIntegrationIds: integrations.filter(({ kind }) => kind === "proxmox" || kind === "mock").map(({ id }) => id),
clusterIntegrationIds: integrations.filter(({ kind }) => clusterIntegrationKinds.has(kind)).map(({ id }) => id),
// Mock implements both contracts and remains visible in both views.
systemIntegrationIds: integrations.filter(({ kind }) => kind !== "proxmox").map(({ id }) => id),
systemIntegrationIds: integrations.filter(({ kind }) => systemIntegrationKinds.has(kind)).map(({ id }) => id),
});
@@ -2,6 +2,7 @@ import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
@@ -62,9 +63,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
: undefined,
}),
refetchInterval: null,
supportedIntegrations: ["immich", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("immich-albumCarousel"),
contextActions: ({ widgetRuntimeRef }) => {
const actions = widgetRuntimeRef.current.actions;
return [
@@ -1,5 +1,7 @@
import { IconGraphFilled } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -9,9 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se
queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["immich", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("immich-serverStats"),
createOptions() {
return optionsBuilder.from((factory) => ({
showUsers: factory.switch({
-3
View File
@@ -1,3 +0,0 @@
import type { WidgetKind } from "@homarr/definitions";
export type WidgetImportRecord = Record<WidgetKind, unknown>;
+7 -166
View File
@@ -1,72 +1,6 @@
import type { ComponentType } from "react";
import type { Loader } from "next/dynamic";
import dynamic from "next/dynamic";
import { Center, Loader as UiLoader } from "@mantine/core";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import * as anchorNote from "./anchor-note";
import * as audioStats from "./audio-stats";
import * as app from "./app";
import * as archiveTeamWarrior from "./archive-team-warrior";
import * as bookmarks from "./bookmarks";
import * as bazarr from "./bazarr";
import * as calendar from "./calendar";
import * as clock from "./clock";
import * as coolify from "./coolify";
import type { WidgetComponentProps } from "./definition";
import * as dnsHoleControls from "./dns-hole/controls";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as dockerContainers from "./docker";
import * as downloads from "./downloads";
import * as firewall from "./firewall";
import * as healthMonitoring from "./health-monitoring";
import * as iframe from "./iframe";
import * as immichAlbumCarousel from "./immich/album-carousel";
import * as immichServerStats from "./immich/server-stats";
import type { WidgetImportRecord } from "./import";
import * as indexerManager from "./indexer-manager";
import * as mediaReleases from "./media-releases";
import * as mediaRequestsList from "./media-requests/list";
import * as mediaRequestsStats from "./media-requests/stats";
import * as mediaServer from "./media-server";
import * as mediaTranscoding from "./media-transcoding";
import * as mediaMissing from "./media-missing";
import * as minecraftServerStatus from "./minecraft/server-status";
import * as networkControllerStatus from "./network-controller/network-status";
import * as networkControllerSummary from "./network-controller/summary";
import * as notebook from "./notebook";
import * as paperlessNgx from "./paperless-ngx";
import * as patchmon from "./patchmon";
import * as notifications from "./notifications";
import * as releases from "./releases";
import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as speedtestTracker from "./speedtest-tracker";
import * as uptimeKuma from "./uptime-kuma";
import * as stockPrice from "./stocks";
import * as systemDisks from "./system-disks";
import * as systemResources from "./system-resources";
import * as timetable from "./timetable";
import * as traefik from "./traefik";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
import * as vpn from "./vpn";
import * as beszelSystemTable from "./beszel-system-table";
import * as beszelSystemGrid from "./beszel-system-grid";
import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as airQuality from "./air-quality";
import * as countdown from "./countdown";
import * as timer from "./timer";
import * as customApi from "./custom-api";
import * as assistant from "./assistant";
import * as wud from "./wud";
export { AssistantWidgetRendererProvider } from "./assistant/context";
export type {
NormalizedWidgetQuery,
WidgetDefinition,
@@ -89,102 +23,9 @@ export {
} from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
weather,
airQuality,
countdown,
timer,
app,
archiveTeamWarrior,
anchorNote,
notebook,
iframe,
video,
dnsHoleSummary,
dnsHoleControls,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
stockPrice,
mediaServer,
calendar,
downloads,
"mediaRequests-requestList": mediaRequestsList,
"mediaRequests-requestStats": mediaRequestsStats,
mediaMissing,
networkControllerSummary,
networkControllerStatus,
rssFeed,
bookmarks,
bazarr,
indexerManager,
healthMonitoring,
mediaTranscoding,
minecraftServerStatus,
dockerContainers,
releases,
firewall,
notifications,
mediaReleases,
systemResources,
coolify,
systemDisks,
timetable,
"immich-serverStats": immichServerStats,
"immich-albumCarousel": immichAlbumCarousel,
paperlessNgx,
patchmon,
tracearr,
speedtestTracker,
uptimeKuma,
audioStats,
umami,
vpn,
ups,
beszelSystemTable,
beszelSystemGrid,
beszelAlerts,
beszelSystemStats,
traefik,
customApi,
assistant,
wud,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
export type WidgetImportKey = keyof WidgetImports;
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
export const loadWidgetDynamic = <TKind extends WidgetKind>(kind: TKind) => {
const existingComponent = loadedComponents.get(kind);
if (existingComponent) return existingComponent;
const newlyLoadedComponent = dynamic<WidgetComponentProps<TKind>>(
widgetImports[kind].componentLoader as Loader<WidgetComponentProps<TKind>>,
{
loading: () => (
<Center w="100%" h="100%">
<UiLoader />
</Center>
),
},
);
loadedComponents.set(kind, newlyLoadedComponent as never);
return newlyLoadedComponent;
};
export type inferSupportedIntegrations<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: string[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: string[])[number];
export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: IntegrationKind[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: never[])[number];
export type {
inferSupportedIntegrations,
inferSupportedIntegrationsStrict,
WidgetImportKey,
WidgetImports,
} from "./registry";
@@ -1,6 +1,6 @@
import { IconReportSearch, IconServerOff, IconTestPipe } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -30,7 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa
],
refetchInterval: null,
createOptions,
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
...getWidgetIntegrationConfig("indexerManager"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+13 -5
View File
@@ -1,7 +1,7 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions";
import { widgetIntegrationSupport, widgetKinds as declaredWidgetKinds } from "@homarr/definitions";
import {
createRetryableLoader,
@@ -14,10 +14,16 @@ import {
widgetKinds,
} from "./manifest";
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
import { widgetCatalogIcons } from "./catalog";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { getWidgetQueryKeys } from "./definition";
const serializePollingPolicy = (entry: { queryKey: QueryKey; intervalSeconds: number | null }) => JSON.stringify(entry);
const serializePollingPolicy = ({
queryKey,
intervalSeconds,
}: {
queryKey: QueryKey;
intervalSeconds: number | null;
}) => JSON.stringify({ queryKey, intervalSeconds });
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
@@ -58,7 +64,7 @@ describe("widget manifest promise stability", () => {
});
it("covers every declared widget kind", () => {
expect(new Set(widgetKinds)).toEqual(new Set(definedWidgetKinds));
expect(new Set(widgetKinds)).toEqual(new Set(declaredWidgetKinds));
});
it("loads matching definitions and component loaders for every widget", async () => {
@@ -70,6 +76,7 @@ describe("widget manifest promise stability", () => {
const canonicalComponent = await module.componentLoader();
const component = await loadWidgetComponent(kind);
const resources = await loadWidgetResources(kind);
expect(module.definition.kind).toBe(kind);
expect(definitions.get(kind)).toBe(module.definition);
expect(component.default).toBe(canonicalComponent.default);
expect(component.default).toBeDefined();
@@ -98,7 +105,8 @@ describe("widget manifest promise stability", () => {
const definition = definitions.get(kind);
const supportedIntegrations =
definition && "supportedIntegrations" in definition ? (definition.supportedIntegrations ?? []) : [];
expect([...(widgetIntegrationSupport[kind] ?? [])].toSorted()).toEqual([...supportedIntegrations].toSorted());
const expectedIntegrations = widgetIntegrationSupport[kind] ?? [];
expect(expectedIntegrations.toSorted()).toEqual([...supportedIntegrations].toSorted());
}
});
+79 -163
View File
@@ -2,156 +2,54 @@ import type { ComponentType } from "react";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds as declaredWidgetKinds } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps, WidgetDefinition } from "./definition";
import type { WidgetOptionDefinition } from "./options";
import type { WidgetImports } from "./registry";
import { widgetComponentLoaders, widgetModuleLoaders } from "./registry";
type WidgetModule = {
type RegisteredWidgetModule = WidgetImports[WidgetKind];
type RegisteredWidgetComponentModule = Awaited<ReturnType<RegisteredWidgetModule["componentLoader"]>>;
type WidgetDefinitionFor<TKind extends WidgetKind> = WidgetImports[TKind]["definition"] & WidgetDefinition;
type WidgetComponentModule<TKind extends WidgetKind> = {
default: ComponentType<WidgetComponentProps<TKind>>;
};
export type WidgetModule<TKind extends WidgetKind> = {
definition: WidgetDefinitionFor<TKind>;
componentLoader: () => Promise<WidgetComponentModule<TKind>>;
};
export type WidgetResources<TKind extends WidgetKind = WidgetKind> = {
definition: WidgetDefinitionFor<TKind>;
Component: ComponentType<WidgetComponentProps<TKind>>;
};
type RegisteredWidgetResources = {
definition: RegisteredWidgetModule["definition"];
Component: RegisteredWidgetComponentModule["default"];
};
type WidgetManifestValue<TKind extends WidgetKind> = {
module: WidgetModule<TKind>;
component: WidgetComponentModule<TKind>;
resources: WidgetResources<TKind>;
definition: WidgetDefinitionFor<TKind>;
};
type RegisteredWidgetManifestValue = {
module: RegisteredWidgetModule;
component: RegisteredWidgetComponentModule;
resources: RegisteredWidgetResources;
definition: WidgetDefinition;
componentLoader: () => Promise<{ default: ComponentType<WidgetComponentProps<WidgetKind>> }>;
};
type WidgetComponentModule = {
default: ComponentType<WidgetComponentProps<WidgetKind>>;
};
export type WidgetResources = {
definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
};
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
airQuality: () => import("./air-quality") as Promise<WidgetModule>,
countdown: () => import("./countdown") as Promise<WidgetModule>,
timer: () => import("./timer") as Promise<WidgetModule>,
app: () => import("./app") as Promise<WidgetModule>,
assistant: () => import("./assistant") as Promise<WidgetModule>,
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise<WidgetModule>,
anchorNote: () => import("./anchor-note") as Promise<WidgetModule>,
notebook: () => import("./notebook") as Promise<WidgetModule>,
iframe: () => import("./iframe") as Promise<WidgetModule>,
video: () => import("./video") as Promise<WidgetModule>,
dnsHoleSummary: () => import("./dns-hole/summary") as Promise<WidgetModule>,
dnsHoleControls: () => import("./dns-hole/controls") as Promise<WidgetModule>,
"smartHome-entityState": () => import("./smart-home/entity-state") as Promise<WidgetModule>,
"smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise<WidgetModule>,
stockPrice: () => import("./stocks") as Promise<WidgetModule>,
mediaServer: () => import("./media-server") as Promise<WidgetModule>,
calendar: () => import("./calendar") as Promise<WidgetModule>,
downloads: () => import("./downloads") as Promise<WidgetModule>,
"mediaRequests-requestList": () => import("./media-requests/list") as Promise<WidgetModule>,
"mediaRequests-requestStats": () => import("./media-requests/stats") as Promise<WidgetModule>,
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
indexerManager: () => import("./indexer-manager") as Promise<WidgetModule>,
healthMonitoring: () => import("./health-monitoring") as Promise<WidgetModule>,
mediaTranscoding: () => import("./media-transcoding") as Promise<WidgetModule>,
minecraftServerStatus: () => import("./minecraft/server-status") as Promise<WidgetModule>,
dockerContainers: () => import("./docker") as Promise<WidgetModule>,
releases: () => import("./releases") as Promise<WidgetModule>,
firewall: () => import("./firewall") as Promise<WidgetModule>,
notifications: () => import("./notifications") as Promise<WidgetModule>,
mediaReleases: () => import("./media-releases") as Promise<WidgetModule>,
systemResources: () => import("./system-resources") as Promise<WidgetModule>,
coolify: () => import("./coolify") as Promise<WidgetModule>,
systemDisks: () => import("./system-disks") as Promise<WidgetModule>,
timetable: () => import("./timetable") as Promise<WidgetModule>,
"immich-serverStats": () => import("./immich/server-stats") as Promise<WidgetModule>,
"immich-albumCarousel": () => import("./immich/album-carousel") as Promise<WidgetModule>,
paperlessNgx: () => import("./paperless-ngx") as Promise<WidgetModule>,
patchmon: () => import("./patchmon") as Promise<WidgetModule>,
tracearr: () => import("./tracearr") as Promise<WidgetModule>,
speedtestTracker: () => import("./speedtest-tracker") as Promise<WidgetModule>,
uptimeKuma: () => import("./uptime-kuma") as Promise<WidgetModule>,
audioStats: () => import("./audio-stats") as Promise<WidgetModule>,
umami: () => import("./umami") as Promise<WidgetModule>,
vpn: () => import("./vpn") as Promise<WidgetModule>,
ups: () => import("./ups") as Promise<WidgetModule>,
beszelSystemTable: () => import("./beszel-system-table") as Promise<WidgetModule>,
beszelSystemGrid: () => import("./beszel-system-grid") as Promise<WidgetModule>,
beszelAlerts: () => import("./beszel-alerts") as Promise<WidgetModule>,
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
traefik: () => import("./traefik") as Promise<WidgetModule>,
customApi: () => import("./custom-api") as Promise<WidgetModule>,
wud: () => import("./wud") as Promise<WidgetModule>,
};
// Keep these imports explicit so Turbopack can create one discoverable chunk per
// widget implementation. Loading this registry does not load the implementations.
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>,
countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>,
timer: () => import("./timer/component") as Promise<WidgetComponentModule>,
app: () => import("./app/component") as Promise<WidgetComponentModule>,
assistant: () => import("./assistant/component") as Promise<WidgetComponentModule>,
archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise<WidgetComponentModule>,
anchorNote: () => import("./anchor-note/component") as Promise<WidgetComponentModule>,
notebook: () => import("./notebook/component") as Promise<WidgetComponentModule>,
iframe: () => import("./iframe/component") as Promise<WidgetComponentModule>,
video: () => import("./video/component") as Promise<WidgetComponentModule>,
dnsHoleSummary: () => import("./dns-hole/summary/component") as Promise<WidgetComponentModule>,
dnsHoleControls: () => import("./dns-hole/controls/component") as Promise<WidgetComponentModule>,
"smartHome-entityState": () => import("./smart-home/entity-state/component") as Promise<WidgetComponentModule>,
"smartHome-executeAutomation": () =>
import("./smart-home/execute-automation/component") as Promise<WidgetComponentModule>,
stockPrice: () => import("./stocks/component") as Promise<WidgetComponentModule>,
mediaServer: () => import("./media-server/component") as Promise<WidgetComponentModule>,
calendar: () => import("./calendar/component") as Promise<WidgetComponentModule>,
downloads: () => import("./downloads/component") as Promise<WidgetComponentModule>,
"mediaRequests-requestList": () => import("./media-requests/list/component") as Promise<WidgetComponentModule>,
"mediaRequests-requestStats": () => import("./media-requests/stats/component") as Promise<WidgetComponentModule>,
mediaMissing: () => import("./media-missing/component") as Promise<WidgetComponentModule>,
networkControllerSummary: () => import("./network-controller/summary/component") as Promise<WidgetComponentModule>,
networkControllerStatus: () =>
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
indexerManager: () => import("./indexer-manager/component") as Promise<WidgetComponentModule>,
healthMonitoring: () => import("./health-monitoring/component") as Promise<WidgetComponentModule>,
mediaTranscoding: () => import("./media-transcoding/component") as Promise<WidgetComponentModule>,
minecraftServerStatus: () => import("./minecraft/server-status/component") as Promise<WidgetComponentModule>,
dockerContainers: () => import("./docker/component") as Promise<WidgetComponentModule>,
releases: () => import("./releases/component") as Promise<WidgetComponentModule>,
firewall: () => import("./firewall/component") as Promise<WidgetComponentModule>,
notifications: () => import("./notifications/component") as Promise<WidgetComponentModule>,
mediaReleases: () => import("./media-releases/component") as Promise<WidgetComponentModule>,
systemResources: () => import("./system-resources/component") as Promise<WidgetComponentModule>,
coolify: () => import("./coolify/component") as Promise<WidgetComponentModule>,
systemDisks: () => import("./system-disks/component") as Promise<WidgetComponentModule>,
timetable: () => import("./timetable/component") as Promise<WidgetComponentModule>,
"immich-serverStats": () => import("./immich/server-stats/component") as Promise<WidgetComponentModule>,
"immich-albumCarousel": () => import("./immich/album-carousel/component") as Promise<WidgetComponentModule>,
paperlessNgx: () => import("./paperless-ngx/component") as Promise<WidgetComponentModule>,
patchmon: () => import("./patchmon/component") as Promise<WidgetComponentModule>,
tracearr: () => import("./tracearr/component") as Promise<WidgetComponentModule>,
speedtestTracker: () => import("./speedtest-tracker/component") as Promise<WidgetComponentModule>,
uptimeKuma: () => import("./uptime-kuma/component") as Promise<WidgetComponentModule>,
audioStats: () => import("./audio-stats/component") as Promise<WidgetComponentModule>,
umami: () => import("./umami/component") as Promise<WidgetComponentModule>,
vpn: () => import("./vpn/component") as Promise<WidgetComponentModule>,
ups: () => import("./ups/component") as Promise<WidgetComponentModule>,
beszelSystemTable: () => import("./beszel-system-table/component") as Promise<WidgetComponentModule>,
beszelSystemGrid: () => import("./beszel-system-grid/component") as Promise<WidgetComponentModule>,
beszelAlerts: () => import("./beszel-alerts/component") as Promise<WidgetComponentModule>,
beszelSystemStats: () => import("./beszel-system-stats/component") as Promise<WidgetComponentModule>,
traefik: () => import("./traefik/component") as Promise<WidgetComponentModule>,
customApi: () => import("./custom-api/component") as Promise<WidgetComponentModule>,
wud: () => import("./wud/component") as Promise<WidgetComponentModule>,
};
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
export const widgetKinds = [...declaredWidgetKinds];
export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) => {
const promises = new Map<TKey, Promise<TValue>>();
@@ -172,39 +70,59 @@ export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, (
};
};
export const loadWidgetModule = createRetryableLoader(
new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise<WidgetModule>][]),
const loadRegisteredWidgetModule = createRetryableLoader(
new Map<WidgetKind, () => Promise<RegisteredWidgetModule>>(objectEntries(widgetModuleLoaders)),
);
export const loadWidgetComponent = createRetryableLoader(
new Map(Object.entries(componentLoaders) as [WidgetKind, () => Promise<WidgetComponentModule>][]),
// ponytail: TypeScript cannot correlate a generic key with the matching value
// in a Map. The registry itself is checked against WidgetImports, so this
// assertion only restores that already-verified relationship after caching.
const correlateWidgetPromise = <TKind extends WidgetKind, TType extends keyof WidgetManifestValue<TKind>>(
_kind: TKind,
_type: TType,
promise: Promise<RegisteredWidgetManifestValue[TType]>,
) => promise as Promise<WidgetManifestValue<TKind>[TType]>;
export const loadWidgetModule = <TKind extends WidgetKind>(kind: TKind) =>
correlateWidgetPromise(kind, "module", loadRegisteredWidgetModule(kind));
const loadRegisteredWidgetComponent = createRetryableLoader(
new Map<WidgetKind, () => Promise<RegisteredWidgetComponentModule>>(objectEntries(widgetComponentLoaders)),
);
export const loadWidgetResources = createRetryableLoader(
new Map(
(Object.keys(moduleLoaders) as WidgetKind[]).map((kind) => [
export const loadWidgetComponent = <TKind extends WidgetKind>(kind: TKind) =>
correlateWidgetPromise(kind, "component", loadRegisteredWidgetComponent(kind));
const loadRegisteredWidgetResources = createRetryableLoader(
new Map<WidgetKind, () => Promise<RegisteredWidgetResources>>(
widgetKinds.map((kind) => [
kind,
async () => {
const [{ definition }, { default: Component }] = await Promise.all([
loadWidgetModule(kind),
loadWidgetComponent(kind),
loadRegisteredWidgetModule(kind),
loadRegisteredWidgetComponent(kind),
]);
return { definition, Component } satisfies WidgetResources;
return { definition, Component };
},
]),
),
);
export const loadWidgetDefinition = (kind: WidgetKind) => {
const existing = definitionPromises.get(kind);
if (existing) return existing;
export const loadWidgetResources = <TKind extends WidgetKind>(kind: TKind) =>
correlateWidgetPromise(kind, "resources", loadRegisteredWidgetResources(kind));
const promise = loadWidgetModule(kind).then(({ definition }) => definition);
export const loadWidgetDefinition = <TKind extends WidgetKind>(kind: TKind) => {
const existing = definitionPromises.get(kind);
if (existing) {
return correlateWidgetPromise(kind, "definition", existing);
}
const promise = loadRegisteredWidgetModule(kind).then(({ definition }) => definition);
definitionPromises.set(kind, promise);
void promise.catch(() => {
if (definitionPromises.get(kind) === promise) definitionPromises.delete(kind);
});
return promise;
return correlateWidgetPromise(kind, "definition", promise);
};
let allWidgetDefinitionsPromise: Promise<Map<WidgetKind, WidgetDefinition>> | undefined;
@@ -228,12 +146,10 @@ export const reduceWidgetOptionsWithDefinition = (
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
currentValue: Record<string, unknown> = {},
) => {
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
const options: Record<string, WidgetOptionDefinition> = definition.createOptions(settings);
const result: Record<string, unknown> = {};
for (const [key, value] of objectEntries(options)) {
result[key] = currentValue[key] ?? value.defaultValue;
}
return result;
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconMovie } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
@@ -25,5 +25,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer"),
...getWidgetIntegrationConfig("mediaMissing"),
}).withDynamicImport(() => import("./component"));
+3 -1
View File
@@ -1,5 +1,7 @@
import { IconTicket } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -34,5 +36,5 @@ export const { definition, componentLoader } = createWidgetDefinition("mediaRele
}),
}));
},
supportedIntegrations: ["mock", "emby", "jellyfin", "plex"],
...getWidgetIntegrationConfig("mediaReleases"),
}).withDynamicImport(() => import("./component"));
@@ -1,7 +1,7 @@
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
@@ -50,5 +50,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
...getWidgetIntegrationConfig("mediaRequests-requestList"),
}).withDynamicImport(() => import("./component"));
@@ -1,6 +1,6 @@
import { IconChartBar, IconSearch } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
@@ -22,5 +22,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
...getWidgetIntegrationConfig("mediaRequests-requestStats"),
}).withDynamicImport(() => import("./component"));
@@ -425,9 +425,7 @@ export default function MediaServerWidget({
style={isAdvanced ? { flexShrink: 0 } : zoomCompensatedSize(16)}
/>
<Text size="sm" fw={isAdvanced ? 500 : undefined} style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
{t("footer.streams", { count: flatSessions.length })}
</Text>
{totalBitrateLabel && (
<>
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconVideo } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
@@ -22,5 +22,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
showLocation: factory.switch({ defaultValue: true, withDescription: true }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaService"),
...getWidgetIntegrationConfig("mediaServer"),
}).withDynamicImport(() => import("./component"));
@@ -3,25 +3,13 @@
import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query";
import {
Button,
Divider,
Group,
Pagination,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Text,
} from "@mantine/core";
import { Button, Divider, Group, Pagination, Paper, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -30,6 +18,7 @@ import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel";
import { WorkersPanel } from "./panels/workers.panel";
import { views } from "./views";
type View = (typeof views)[number];
@@ -2,19 +2,20 @@ import { IconTransform } from "@tabler/icons-react";
import { z } from "zod/v4";
import { capitalize } from "@homarr/common";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
import { views } from "./views";
export const views = ["workers", "queue", "statistics"] as const;
export { views };
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
supportsAdvancedFocus: true,
queryKey: [["widget", "mediaTranscoding", "getDataAsync"]],
queryMatcher: matchesWidgetRuntimeQuery,
maxIntegrations: 1,
...getWidgetIntegrationConfig("mediaTranscoding"),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -25,5 +26,4 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"),
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1 @@
export const views = ["workers", "queue", "statistics"] as const;
@@ -1,6 +1,6 @@
import { IconServerOff, IconTopologyFull } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -21,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("networkCo
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("networkController"),
...getWidgetIntegrationConfig("networkControllerStatus"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
@@ -1,6 +1,6 @@
import { IconServerOff, IconTopologyFull } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -13,7 +13,7 @@ export const { definition, componentLoader } = createWidgetDefinition("networkCo
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: getIntegrationKindsByCategory("networkController"),
...getWidgetIntegrationConfig("networkControllerSummary"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconMessage } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
@@ -19,5 +19,5 @@ export const { componentLoader, definition } = createWidgetDefinition("notificat
hideLogos: factory.switch({ defaultValue: false }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("notifications"),
...getWidgetIntegrationConfig("notifications"),
}).withDynamicImport(() => import("./component"));
+3 -3
View File
@@ -1,5 +1,7 @@
import { IconFileText, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -7,9 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("paperless
supportsAdvancedFocus: false,
icon: IconFileText,
refetchInterval: null,
supportedIntegrations: ["paperlessNgx", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("paperlessNgx"),
createOptions() {
return optionsBuilder.from((factory) => ({
showInboxRatio: factory.switch({
+3 -3
View File
@@ -1,6 +1,8 @@
import { IconServerOff, IconShieldCheck } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
@@ -9,9 +11,7 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
supportsAdvancedFocus: true,
icon: IconShieldCheck,
supportedIntegrations: ["patchmon", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("patchmon"),
createOptions() {
return optionsBuilder.from(
(factory) => ({
+25 -17
View File
@@ -5,32 +5,40 @@ import type { WidgetKind } from "@homarr/definitions";
import { createSettings } from "@homarr/settings/creator";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest";
import prefetchForApps from "./app/prefetch";
import prefetchForBookmarks from "./bookmarks/prefetch";
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
import type { inferOptionsFromCreator } from "./options";
import type { PrefetchLoader } from "./definition";
const prefetchCallbacks: Partial<{
[TKind in WidgetKind]: Prefetch<TKind>;
}> = {
bookmarks: prefetchForBookmarks,
app: prefetchForApps,
};
const definePrefetchLoaders = <TLoaders extends Partial<Record<WidgetKind, PrefetchLoader>>>(loaders: TLoaders) =>
loaders;
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
kind: TKind,
// Keep these imports explicit so Next.js can trace each optional prefetch
// module without loading its database-specific implementation eagerly.
const prefetchLoaders = definePrefetchLoaders({
app: () => import("./app/prefetch"),
bookmarks: () => import("./bookmarks/prefetch"),
});
type PrefetchWidgetKind = keyof typeof prefetchLoaders;
const hasPrefetchLoader = (kind: WidgetKind): kind is PrefetchWidgetKind =>
Object.prototype.hasOwnProperty.call(prefetchLoaders, kind);
export const prefetchForKindAsync = async (
kind: WidgetKind,
queryClient: QueryClient,
items: {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
options: Record<string, unknown>;
integrationIds: string[];
}[],
) => {
const callback = prefetchCallbacks[kind];
if (!callback) {
if (!hasPrefetchLoader(kind)) {
return;
}
const [serverSettings, definition] = await Promise.all([getRscServerSettingsAsync(), loadWidgetDefinition(kind)]);
const [{ default: callback }, serverSettings, definition] = await Promise.all([
prefetchLoaders[kind](),
getRscServerSettingsAsync(),
loadWidgetDefinition(kind),
]);
const itemsWithDefaultOptions = items.map((item) => ({
...item,
@@ -41,5 +49,5 @@ export const prefetchForKindAsync = async <TKind extends WidgetKind>(
),
}));
await callback(queryClient, itemsWithDefaultOptions as never[]);
await callback(queryClient, itemsWithDefaultOptions);
};
+13 -6
View File
@@ -1,19 +1,26 @@
/**
* Query policy used by the root client provider.
* Lightweight query defaults for the root client provider.
*
* Keep this module free of widget component/definition imports. Importing the
* full widget registry from a root provider makes every widget part of the
* initial client graph, even before a board has rendered one widget.
* Keep this list free of widget imports: importing widget definitions here
* would put every widget in the initial client bundle.
*/
export const widgetQueryRefetchIntervals = [
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 10 },
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 10 },
{
queryKey: [["widget", "downloads", "getJobsAndStatuses"]],
intervalSeconds: 10,
staleTimeSeconds: 10,
},
{ queryKey: [["widget", "firewall"]], intervalSeconds: 10 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
{ queryKey: [["widget", "weather", "atLocation"]], intervalSeconds: 600 },
{
queryKey: [["widget", "weather", "atLocation"]],
intervalSeconds: 600,
staleTimeSeconds: 300,
},
{ queryKey: [["widget", "airQuality", "atLocation"]], intervalSeconds: 900 },
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
+270
View File
@@ -0,0 +1,270 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type * as airQuality from "./air-quality";
import type * as anchorNote from "./anchor-note";
import type * as app from "./app";
import type * as archiveTeamWarrior from "./archive-team-warrior";
import type * as assistant from "./assistant";
import type * as audioStats from "./audio-stats";
import type * as bazarr from "./bazarr";
import type * as beszelAlerts from "./beszel-alerts";
import type * as beszelSystemGrid from "./beszel-system-grid";
import type * as beszelSystemStats from "./beszel-system-stats";
import type * as beszelSystemTable from "./beszel-system-table";
import type * as bookmarks from "./bookmarks";
import type * as calendar from "./calendar";
import type * as clock from "./clock";
import type * as coolify from "./coolify";
import type * as countdown from "./countdown";
import type * as customApi from "./custom-api";
import type * as dnsHoleControls from "./dns-hole/controls";
import type * as dnsHoleSummary from "./dns-hole/summary";
import type * as dockerContainers from "./docker";
import type * as downloads from "./downloads";
import type * as firewall from "./firewall";
import type * as healthMonitoring from "./health-monitoring";
import type * as iframe from "./iframe";
import type * as immichAlbumCarousel from "./immich/album-carousel";
import type * as immichServerStats from "./immich/server-stats";
import type * as indexerManager from "./indexer-manager";
import type * as mediaMissing from "./media-missing";
import type * as mediaReleases from "./media-releases";
import type * as mediaRequestsList from "./media-requests/list";
import type * as mediaRequestsStats from "./media-requests/stats";
import type * as mediaServer from "./media-server";
import type * as mediaTranscoding from "./media-transcoding";
import type * as minecraftServerStatus from "./minecraft/server-status";
import type * as networkControllerStatus from "./network-controller/network-status";
import type * as networkControllerSummary from "./network-controller/summary";
import type * as notebook from "./notebook";
import type * as notifications from "./notifications";
import type * as paperlessNgx from "./paperless-ngx";
import type * as patchmon from "./patchmon";
import type * as releases from "./releases";
import type * as rssFeed from "./rssFeed";
import type * as smartHomeEntityState from "./smart-home/entity-state";
import type * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import type * as speedtestTracker from "./speedtest-tracker";
import type * as stockPrice from "./stocks";
import type * as systemDisks from "./system-disks";
import type * as systemResources from "./system-resources";
import type * as timetable from "./timetable";
import type * as timer from "./timer";
import type * as tracearr from "./tracearr";
import type * as traefik from "./traefik";
import type * as umami from "./umami";
import type * as ups from "./ups";
import type * as uptimeKuma from "./uptime-kuma";
import type * as video from "./video";
import type * as vpn from "./vpn";
import type * as weather from "./weather";
import type * as wud from "./wud";
// Keep these imports explicit so Next.js and Turbopack can discover every widget
// module without loading any widget definition or component eagerly.
export const widgetModuleLoaders = {
clock: () => import("./clock"),
airQuality: () => import("./air-quality"),
countdown: () => import("./countdown"),
timer: () => import("./timer"),
app: () => import("./app"),
assistant: () => import("./assistant"),
archiveTeamWarrior: () => import("./archive-team-warrior"),
anchorNote: () => import("./anchor-note"),
notebook: () => import("./notebook"),
iframe: () => import("./iframe"),
video: () => import("./video"),
dnsHoleSummary: () => import("./dns-hole/summary"),
dnsHoleControls: () => import("./dns-hole/controls"),
"smartHome-entityState": () => import("./smart-home/entity-state"),
"smartHome-executeAutomation": () => import("./smart-home/execute-automation"),
stockPrice: () => import("./stocks"),
mediaServer: () => import("./media-server"),
calendar: () => import("./calendar"),
downloads: () => import("./downloads"),
"mediaRequests-requestList": () => import("./media-requests/list"),
"mediaRequests-requestStats": () => import("./media-requests/stats"),
mediaMissing: () => import("./media-missing"),
networkControllerSummary: () => import("./network-controller/summary"),
networkControllerStatus: () => import("./network-controller/network-status"),
rssFeed: () => import("./rssFeed"),
bookmarks: () => import("./bookmarks"),
bazarr: () => import("./bazarr"),
indexerManager: () => import("./indexer-manager"),
healthMonitoring: () => import("./health-monitoring"),
mediaTranscoding: () => import("./media-transcoding"),
minecraftServerStatus: () => import("./minecraft/server-status"),
dockerContainers: () => import("./docker"),
releases: () => import("./releases"),
firewall: () => import("./firewall"),
notifications: () => import("./notifications"),
mediaReleases: () => import("./media-releases"),
systemResources: () => import("./system-resources"),
coolify: () => import("./coolify"),
systemDisks: () => import("./system-disks"),
timetable: () => import("./timetable"),
"immich-serverStats": () => import("./immich/server-stats"),
"immich-albumCarousel": () => import("./immich/album-carousel"),
paperlessNgx: () => import("./paperless-ngx"),
patchmon: () => import("./patchmon"),
tracearr: () => import("./tracearr"),
speedtestTracker: () => import("./speedtest-tracker"),
uptimeKuma: () => import("./uptime-kuma"),
audioStats: () => import("./audio-stats"),
umami: () => import("./umami"),
vpn: () => import("./vpn"),
ups: () => import("./ups"),
beszelSystemTable: () => import("./beszel-system-table"),
beszelSystemGrid: () => import("./beszel-system-grid"),
beszelAlerts: () => import("./beszel-alerts"),
beszelSystemStats: () => import("./beszel-system-stats"),
traefik: () => import("./traefik"),
customApi: () => import("./custom-api"),
weather: () => import("./weather"),
wud: () => import("./wud"),
} satisfies { [TKind in WidgetKind]: () => Promise<WidgetImports[TKind]> };
export type WidgetImports = {
clock: typeof clock;
airQuality: typeof airQuality;
countdown: typeof countdown;
timer: typeof timer;
app: typeof app;
assistant: typeof assistant;
archiveTeamWarrior: typeof archiveTeamWarrior;
anchorNote: typeof anchorNote;
notebook: typeof notebook;
iframe: typeof iframe;
video: typeof video;
dnsHoleSummary: typeof dnsHoleSummary;
dnsHoleControls: typeof dnsHoleControls;
"smartHome-entityState": typeof smartHomeEntityState;
"smartHome-executeAutomation": typeof smartHomeExecuteAutomation;
stockPrice: typeof stockPrice;
mediaServer: typeof mediaServer;
calendar: typeof calendar;
downloads: typeof downloads;
"mediaRequests-requestList": typeof mediaRequestsList;
"mediaRequests-requestStats": typeof mediaRequestsStats;
mediaMissing: typeof mediaMissing;
networkControllerSummary: typeof networkControllerSummary;
networkControllerStatus: typeof networkControllerStatus;
rssFeed: typeof rssFeed;
bookmarks: typeof bookmarks;
bazarr: typeof bazarr;
indexerManager: typeof indexerManager;
healthMonitoring: typeof healthMonitoring;
mediaTranscoding: typeof mediaTranscoding;
minecraftServerStatus: typeof minecraftServerStatus;
dockerContainers: typeof dockerContainers;
releases: typeof releases;
firewall: typeof firewall;
notifications: typeof notifications;
mediaReleases: typeof mediaReleases;
systemResources: typeof systemResources;
coolify: typeof coolify;
systemDisks: typeof systemDisks;
timetable: typeof timetable;
"immich-serverStats": typeof immichServerStats;
"immich-albumCarousel": typeof immichAlbumCarousel;
paperlessNgx: typeof paperlessNgx;
patchmon: typeof patchmon;
tracearr: typeof tracearr;
speedtestTracker: typeof speedtestTracker;
uptimeKuma: typeof uptimeKuma;
audioStats: typeof audioStats;
umami: typeof umami;
vpn: typeof vpn;
ups: typeof ups;
beszelSystemTable: typeof beszelSystemTable;
beszelSystemGrid: typeof beszelSystemGrid;
beszelAlerts: typeof beszelAlerts;
beszelSystemStats: typeof beszelSystemStats;
traefik: typeof traefik;
customApi: typeof customApi;
weather: typeof weather;
wud: typeof wud;
};
type WidgetComponentLoaders = {
[TKind in WidgetKind]: WidgetImports[TKind]["componentLoader"];
};
// Keep component imports literal and separate from widgetModuleLoaders. The
// manifest starts both chunk requests together on a cold widget load; resolving
// this loader through the widget module would serialize those requests.
export const widgetComponentLoaders = {
clock: () => import("./clock/component"),
airQuality: () => import("./air-quality/component"),
countdown: () => import("./countdown/component"),
timer: () => import("./timer/component"),
app: () => import("./app/component"),
assistant: () => import("./assistant/component"),
archiveTeamWarrior: () => import("./archive-team-warrior/component"),
anchorNote: () => import("./anchor-note/component"),
notebook: () => import("./notebook/component"),
iframe: () => import("./iframe/component"),
video: () => import("./video/component"),
dnsHoleSummary: () => import("./dns-hole/summary/component"),
dnsHoleControls: () => import("./dns-hole/controls/component"),
"smartHome-entityState": () => import("./smart-home/entity-state/component"),
"smartHome-executeAutomation": () => import("./smart-home/execute-automation/component"),
stockPrice: () => import("./stocks/component"),
mediaServer: () => import("./media-server/component"),
calendar: () => import("./calendar/component"),
downloads: () => import("./downloads/component"),
"mediaRequests-requestList": () => import("./media-requests/list/component"),
"mediaRequests-requestStats": () => import("./media-requests/stats/component"),
mediaMissing: () => import("./media-missing/component"),
networkControllerSummary: () => import("./network-controller/summary/component"),
networkControllerStatus: () => import("./network-controller/network-status/component"),
rssFeed: () => import("./rssFeed/component"),
bookmarks: () => import("./bookmarks/component"),
bazarr: () => import("./bazarr/component"),
indexerManager: () => import("./indexer-manager/component"),
healthMonitoring: () => import("./health-monitoring/component"),
mediaTranscoding: () => import("./media-transcoding/component"),
minecraftServerStatus: () => import("./minecraft/server-status/component"),
dockerContainers: () => import("./docker/component"),
releases: () => import("./releases/component"),
firewall: () => import("./firewall/component"),
notifications: () => import("./notifications/component"),
mediaReleases: () => import("./media-releases/component"),
systemResources: () => import("./system-resources/component"),
coolify: () => import("./coolify/component"),
systemDisks: () => import("./system-disks/component"),
timetable: () => import("./timetable/component"),
"immich-serverStats": () => import("./immich/server-stats/component"),
"immich-albumCarousel": () => import("./immich/album-carousel/component"),
paperlessNgx: () => import("./paperless-ngx/component"),
patchmon: () => import("./patchmon/component"),
tracearr: () => import("./tracearr/component"),
speedtestTracker: () => import("./speedtest-tracker/component"),
uptimeKuma: () => import("./uptime-kuma/component"),
audioStats: () => import("./audio-stats/component"),
umami: () => import("./umami/component"),
vpn: () => import("./vpn/component"),
ups: () => import("./ups/component"),
beszelSystemTable: () => import("./beszel-system-table/component"),
beszelSystemGrid: () => import("./beszel-system-grid/component"),
beszelAlerts: () => import("./beszel-alerts/component"),
beszelSystemStats: () => import("./beszel-system-stats/component"),
traefik: () => import("./traefik/component"),
customApi: () => import("./custom-api/component"),
weather: () => import("./weather/component"),
wud: () => import("./wud/component"),
} satisfies WidgetComponentLoaders;
export type WidgetImportKey = keyof WidgetImports;
export type inferSupportedIntegrations<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: string[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: string[])[number];
export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: IntegrationKind[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: never[])[number];
@@ -1,6 +1,6 @@
import { IconBinaryTree, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -9,7 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome
icon: IconBinaryTree,
supportsAdvancedFocus: true,
queryKey: [["widget", "smartHome"]],
maxIntegrations: 1,
...getWidgetIntegrationConfig("smartHome-entityState"),
createOptions() {
return optionsBuilder.from((factory) => ({
entityId: factory.text({
@@ -22,7 +22,6 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome
clickable: factory.switch(),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
@@ -1,6 +1,6 @@
import { IconBinaryTree } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -8,12 +8,11 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
icon: IconBinaryTree,
supportsAdvancedFocus: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("smartHome-executeAutomation"),
createOptions() {
return optionsBuilder.from((factory) => ({
displayName: factory.text(),
automationId: factory.text(),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
}).withDynamicImport(() => import("./component"));
@@ -1,5 +1,7 @@
import { IconServerOff, IconSpeedboat } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -29,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest
},
);
},
supportedIntegrations: ["speedtestTracker", "mock"],
...getWidgetIntegrationConfig("speedtestTracker"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+3 -1
View File
@@ -1,5 +1,7 @@
import { IconServer2 } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
@@ -9,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemDis
icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 10,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"],
...getWidgetIntegrationConfig("systemDisks"),
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -1,7 +1,7 @@
import { IconAlignLeft, IconEyeOff, IconGraphFilled, IconListDetails, IconPhoto } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { getWidgetIntegrationConfig, invariantTechnicalLabels } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -24,7 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes
supportsAdvancedFocus: true,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 10,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"],
...getWidgetIntegrationConfig("systemResources"),
createOptions() {
return optionsBuilder.from((factory) => ({
hasShadow: factory.switch({ defaultValue: true }),
+3 -1
View File
@@ -1,5 +1,7 @@
import { IconActivityHeartbeat, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -24,7 +26,7 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr"
}),
}));
},
supportedIntegrations: ["tracearr", "mock"],
...getWidgetIntegrationConfig("tracearr"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+3 -1
View File
@@ -1,5 +1,7 @@
import { IconRoute, IconRouteOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -19,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("traefik",
}),
}));
},
supportedIntegrations: ["traefik", "mock"],
...getWidgetIntegrationConfig("traefik"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconRouteOff,
+3 -3
View File
@@ -1,6 +1,8 @@
import { IconChartBar, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
@@ -18,9 +20,7 @@ export const { definition, componentLoader } = createWidgetDefinition("umami", {
],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["umami", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("umami"),
createOptions() {
return optionsBuilder.from(
(factory) => ({
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconBatteryCharging, IconBatteryOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -21,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("ups", {
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("ups"),
...getWidgetIntegrationConfig("ups"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconBatteryOff,
+3 -1
View File
@@ -1,5 +1,7 @@
import { IconHeartbeat, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -34,7 +36,7 @@ export const { definition, componentLoader } = createWidgetDefinition("uptimeKum
}),
}));
},
supportedIntegrations: ["uptimeKuma", "mock"],
...getWidgetIntegrationConfig("uptimeKuma"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconShieldLock } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -12,5 +12,5 @@ export const { componentLoader, definition } = createWidgetDefinition("vpn", {
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"],
...getWidgetIntegrationConfig("vpn"),
}).withDynamicImport(() => import("./component"));
+3 -3
View File
@@ -1,5 +1,7 @@
import { IconBrandDocker } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -7,9 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("wud", {
icon: IconBrandDocker,
supportsAdvancedFocus: true,
refetchInterval: null,
supportedIntegrations: ["wud", "mock"],
integrationsRequired: true,
maxIntegrations: 1,
...getWidgetIntegrationConfig("wud"),
createOptions() {
return optionsBuilder.from((factory) => ({
showTitle: factory.switch({ defaultValue: true }),