diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index 16266f889..1c15710a5 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -1,4 +1,4 @@ -import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react"; +import { IconGrid3x3, IconKey, IconMessage, IconPassword, IconServer, IconUser } from "@tabler/icons-react"; import type { IntegrationSecretKind } from "@homarr/definitions"; import type { TablerIcon } from "@homarr/ui"; @@ -9,4 +9,5 @@ export const integrationSecretIcons = { password: IconPassword, realm: IconServer, tokenId: IconGrid3x3, + topic: IconMessage, } satisfies Record; diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index d97741c29..77a1bd2ef 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -11,6 +11,7 @@ import { mediaTranscodingRouter } from "./media-transcoding"; import { minecraftRouter } from "./minecraft"; import { networkControllerRouter } from "./network-controller"; import { notebookRouter } from "./notebook"; +import { notificationsRouter } from "./notifications"; import { optionsRouter } from "./options"; import { releasesRouter } from "./releases"; import { rssFeedRouter } from "./rssFeed"; @@ -37,4 +38,5 @@ export const widgetRouter = createTRPCRouter({ options: optionsRouter, releases: releasesRouter, networkController: networkControllerRouter, + notifications: notificationsRouter, }); diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts new file mode 100644 index 000000000..89ec95165 --- /dev/null +++ b/packages/api/src/router/widgets/notifications.ts @@ -0,0 +1,64 @@ +import { observable } from "@trpc/server/observable"; + +import type { Modify } from "@homarr/common/types"; +import type { Integration } from "@homarr/db/schema"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { Notification } from "@homarr/integrations"; +import { notificationsRequestHandler } from "@homarr/request-handler/notifications"; + +import type { IntegrationAction } from "../../middlewares/integration"; +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => + createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications")); + +export const notificationsRouter = createTRPCRouter({ + getNotifications: publicProcedure + .unstable_concat(createNotificationsIntegrationMiddleware("query")) + .query(async ({ ctx }) => { + return await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = notificationsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + data, + }; + }), + ); + }), + subscribeNotifications: publicProcedure + .unstable_concat(createNotificationsIntegrationMiddleware("query")) + .subscription(({ ctx }) => { + return observable<{ + integration: Modify }>; + data: Notification[]; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((data) => { + emit.next({ + integration, + data, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), +}); diff --git a/packages/common/src/hooks.ts b/packages/common/src/hooks.ts index e5040968e..ff4559b5f 100644 --- a/packages/common/src/hooks.ts +++ b/packages/common/src/hooks.ts @@ -10,11 +10,11 @@ const calculateTimeAgo = (timestamp: Date) => { return dayjs().to(timestamp); }; -export const useTimeAgo = (timestamp: Date) => { +export const useTimeAgo = (timestamp: Date, updateFrequency = 1000) => { const [timeAgo, setTimeAgo] = useState(calculateTimeAgo(timestamp)); useEffect(() => { - const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), 1000); // update every second + const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), updateFrequency); return () => clearInterval(intervalId); // clear interval on hook unmount }, [timestamp]); diff --git a/packages/cron-job-runner/src/index.ts b/packages/cron-job-runner/src/index.ts index 9605ba3c4..73d9e436b 100644 --- a/packages/cron-job-runner/src/index.ts +++ b/packages/cron-job-runner/src/index.ts @@ -25,6 +25,7 @@ export const cronJobs = { minecraftServerStatus: { preventManualExecution: false }, networkController: { preventManualExecution: false }, dockerContainers: { preventManualExecution: false }, + refreshNotifications: { preventManualExecution: false }, } satisfies Record; /** diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 64a661b05..b29e8a46e 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -11,6 +11,7 @@ import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/m import { mediaServerJob } from "./jobs/integrations/media-server"; import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding"; import { networkControllerJob } from "./jobs/integrations/network-controller"; +import { refreshNotificationsJob } from "./jobs/integrations/notifications"; import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; import { rssFeedsJob } from "./jobs/rss-feeds"; @@ -38,6 +39,7 @@ export const jobGroup = createCronJobGroup({ minecraftServerStatus: minecraftServerStatusJob, dockerContainers: dockerContainersJob, networkController: networkControllerJob, + refreshNotifications: refreshNotificationsJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/integrations/notifications.ts b/packages/cron-jobs/src/jobs/integrations/notifications.ts new file mode 100644 index 000000000..0f544995f --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/notifications.ts @@ -0,0 +1,14 @@ +import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { notificationsRequestHandler } from "@homarr/request-handler/notifications"; + +import { createCronJob } from "../../lib"; + +export const refreshNotificationsJob = createCronJob("refreshNotifications", EVERY_5_MINUTES).withCallback( + createRequestIntegrationJobHandler(notificationsRequestHandler.handler, { + widgetKinds: ["notifications"], + getInput: { + notifications: (options) => options, + }, + }), +); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index a363a6cac..c127e364f 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -7,6 +7,7 @@ export const integrationSecretKindObject = { password: { isPublic: false }, tokenId: { isPublic: true }, realm: { isPublic: true }, + topic: { isPublic: true }, } satisfies Record; export const integrationSecretKinds = objectKeys(integrationSecretKindObject); @@ -169,6 +170,12 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", category: ["networkController"], }, + ntfy: { + name: "ntfy", + secretKinds: [["topic"], ["topic", "apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg", + category: ["notifications"], + }, } as const satisfies Record; export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; @@ -223,4 +230,5 @@ export type IntegrationCategory = | "healthMonitoring" | "search" | "mediaTranscoding" - | "networkController"; + | "networkController" + | "notifications"; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index e5b92a8bd..609e8bf9a 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -25,5 +25,6 @@ export const widgetKinds = [ "healthMonitoring", "releases", "dockerContainers", + "notifications", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index f9aec19a0..df615fa1f 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -21,6 +21,7 @@ import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integrati import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; +import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory"; @@ -92,6 +93,7 @@ export const integrationCreators = { emby: EmbyIntegration, nextcloud: NextcloudIntegration, unifiController: UnifiControllerIntegration, + ntfy: NTFYIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 80752e149..ed6901cf5 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,26 +1,27 @@ // General integrations export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; +export { Aria2Integration } from "./download-client/aria2/aria2-integration"; export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; -export { Aria2Integration } from "./download-client/aria2/aria2-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration"; +export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration"; export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration"; +export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; +export { NextcloudIntegration } from "./nextcloud/nextcloud.integration"; +export { NTFYIntegration } from "./ntfy/ntfy-integration"; export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration"; export { OverseerrIntegration } from "./overseerr/overseerr-integration"; export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5"; export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6"; export { PlexIntegration } from "./plex/plex-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; -export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration"; -export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration"; -export { NextcloudIntegration } from "./nextcloud/nextcloud.integration"; // Types export type { IntegrationInput } from "./base/integration"; @@ -34,6 +35,7 @@ export type { StreamSession } from "./interfaces/media-server/session"; export type { TdarrQueue } from "./interfaces/media-transcoding/queue"; export type { TdarrPieSegment, TdarrStatistics } from "./interfaces/media-transcoding/statistics"; export type { TdarrWorker } from "./interfaces/media-transcoding/workers"; +export type { Notification } from "./interfaces/notifications/notification"; // Schemas export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; diff --git a/packages/integrations/src/interfaces/notifications/notification.ts b/packages/integrations/src/interfaces/notifications/notification.ts new file mode 100644 index 000000000..8f6944944 --- /dev/null +++ b/packages/integrations/src/interfaces/notifications/notification.ts @@ -0,0 +1,6 @@ +export interface Notification { + id: string; + time: Date; + title: string; + body: string; +} diff --git a/packages/integrations/src/interfaces/notifications/notifications-integration.ts b/packages/integrations/src/interfaces/notifications/notifications-integration.ts new file mode 100644 index 000000000..45a052d27 --- /dev/null +++ b/packages/integrations/src/interfaces/notifications/notifications-integration.ts @@ -0,0 +1,6 @@ +import { Integration } from "../../base/integration"; +import type { Notification } from "./notification"; + +export abstract class NotificationsIntegration extends Integration { + public abstract getNotificationsAsync(): Promise; +} diff --git a/packages/integrations/src/ntfy/ntfy-integration.ts b/packages/integrations/src/ntfy/ntfy-integration.ts new file mode 100644 index 000000000..ffdf9dfad --- /dev/null +++ b/packages/integrations/src/ntfy/ntfy-integration.ts @@ -0,0 +1,65 @@ +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ResponseError } from "@homarr/common/server"; + +import type { IntegrationTestingInput } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { Notification } from "../interfaces/notifications/notification"; +import { NotificationsIntegration } from "../interfaces/notifications/notifications-integration"; +import { ntfyNotificationSchema } from "./ntfy-schema"; + +export class NTFYIntegration extends NotificationsIntegration { + public async testingAsync(input: IntegrationTestingInput): Promise { + await input.fetchAsync(this.url("/v1/account"), { headers: this.getHeaders() }); + return { success: true }; + } + + private getTopicURL() { + return this.url(`/${encodeURIComponent(super.getSecretValue("topic"))}/json`, { poll: 1 }); + } + private getHeaders() { + return this.hasSecretValue("apiKey") ? { Authorization: `Bearer ${super.getSecretValue("apiKey")}` } : {}; + } + + public async getNotificationsAsync() { + const url = this.getTopicURL(); + const notifications = await Promise.all( + ( + await fetchWithTrustedCertificatesAsync(url, { headers: this.getHeaders() }) + .then((response) => { + if (!response.ok) throw new ResponseError(response); + return response.text(); + }) + .catch((error) => { + if (error instanceof Error) throw error; + else { + throw new Error("Error communicating with ntfy"); + } + }) + ) + // response is provided as individual lines of JSON + .split("\n") + .map(async (line) => { + // ignore empty lines + if (line.length === 0) return null; + + const json = JSON.parse(line) as unknown; + const parsed = await ntfyNotificationSchema.parseAsync(json); + if (parsed.event === "message") return parsed; + // ignore non-event messages + else return null; + }), + ); + + return notifications + .filter((notification) => notification !== null) + .map((notification): Notification => { + const topicURL = this.url(`/${notification.topic}`); + return { + id: notification.id, + time: new Date(notification.time * 1000), + title: notification.title ?? topicURL.hostname + topicURL.pathname, + body: notification.message, + }; + }); + } +} diff --git a/packages/integrations/src/ntfy/ntfy-schema.ts b/packages/integrations/src/ntfy/ntfy-schema.ts new file mode 100644 index 000000000..2c00557b4 --- /dev/null +++ b/packages/integrations/src/ntfy/ntfy-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +// There are more properties, see: https://docs.ntfy.sh/subscribe/api/#json-message-format +// Not all properties are required for this use case. +export const ntfyNotificationSchema = z.object({ + id: z.string(), + time: z.number(), + event: z.string(), // we only care about "message" + topic: z.string(), + title: z.optional(z.string()), + message: z.string(), +}); diff --git a/packages/request-handler/src/notifications.ts b/packages/request-handler/src/notifications.ts new file mode 100644 index 000000000..41098e660 --- /dev/null +++ b/packages/request-handler/src/notifications.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import type { Notification } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const notificationsRequestHandler = createCachedIntegrationRequestHandler< + Notification[], + IntegrationKindByCategory<"notifications">, + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getNotificationsAsync(); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "notificationsJobStatus", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 6ed95160f..5492223d4 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -936,6 +936,10 @@ "realm": { "label": "Realm", "newLabel": "New realm" + }, + "topic": { + "label": "Topic", + "newLabel": "New topic" } } }, @@ -2359,6 +2363,12 @@ "error": { "internalServerError": "Failed to fetch Network Controller Summary" } + }, + "notifications": { + "name": "Notifications", + "description": "Display notification history from an integration", + "noItems": "No notifications to display.", + "option": {} } }, "widgetPreview": { @@ -3124,6 +3134,9 @@ "networkController": { "label": "Network Controller" }, + "refreshNotifications": { + "label": "Notification Updater" + }, "dockerContainers": { "label": "Docker containers" } diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index e8d9cbe1d..fc2fbc8e4 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -28,6 +28,7 @@ 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 notifications from "./notifications"; import type { WidgetOptionDefinition } from "./options"; import * as releases from "./releases"; import * as rssFeed from "./rssFeed"; @@ -67,6 +68,7 @@ export const widgetImports = { minecraftServerStatus, dockerContainers, releases, + notifications, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx new file mode 100644 index 000000000..1fc4d6910 --- /dev/null +++ b/packages/widgets/src/notifications/component.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useMemo } from "react"; +import { Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; +import { IconClock } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useTimeAgo } from "@homarr/common"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) { + const [notificationIntegrations] = clientApi.widget.notifications.getNotifications.useSuspenseQuery( + { + ...options, + integrationIds, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + const utils = clientApi.useUtils(); + + clientApi.widget.notifications.subscribeNotifications.useSubscription( + { + ...options, + integrationIds, + }, + { + onData: (data) => { + utils.widget.notifications.getNotifications.setData({ ...options, integrationIds }, (prevData) => { + return prevData?.map((item) => { + if (item.integration.id !== data.integration.id) return item; + + return { + data: data.data, + integration: { + ...data.integration, + updatedAt: new Date(), + }, + }; + }); + }); + }, + }, + ); + + const t = useScopedI18n("widget.notifications"); + + const board = useRequiredBoard(); + + const sortedNotifications = useMemo( + () => + notificationIntegrations + .flatMap((integration) => integration.data) + .sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()), + [notificationIntegrations], + ); + + return ( + + + {sortedNotifications.length > 0 ? ( + sortedNotifications.map((notification) => ( + + + {notification.title && ( + + {notification.title} + + )} + + {notification.body} + + + + + + )) + ) : ( + + {t("noItems")} + + )} + + + ); +} + +const InfoDisplay = ({ date }: { date: Date }) => { + const timeAgo = useTimeAgo(date, 30000); // update every 30sec + + return ( + + + + {timeAgo} + + + ); +}; diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts new file mode 100644 index 000000000..577d698a0 --- /dev/null +++ b/packages/widgets/src/notifications/index.ts @@ -0,0 +1,14 @@ +import { IconMessage } from "@tabler/icons-react"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { componentLoader, definition } = createWidgetDefinition("notifications", { + icon: IconMessage, + createOptions() { + return optionsBuilder.from(() => ({})); + }, + supportedIntegrations: getIntegrationKindsByCategory("notifications"), +}).withDynamicImport(() => import("./component"));