From d77c6f8d4e50108f50831ee365e382a535acb254 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 10 Jun 2026 18:27:24 +0200 Subject: [PATCH] feat: add Uptime Kuma, Audiobookshelf, Paperless-ngx, and Navidrome integrations (#5910) --- .../secrets/integration-secret-icons.ts | 2 + .../board/items/actions/create-item.ts | 18 +- .../integration/integration-grid-shared.ts | 3 + .../src/components/layout/navigation-link.tsx | 22 +- .../api/src/router/widgets/audio-stats.ts | 29 +++ packages/api/src/router/widgets/index.ts | 3 + .../api/src/router/widgets/paperless-ngx.ts | 12 + .../api/src/router/widgets/uptime-kuma.ts | 55 ++++ .../src/docker-integration-match.ts | 4 + packages/definitions/src/integration.ts | 38 +++ .../definitions/src/widget-integration-map.ts | 3 + packages/definitions/src/widget.ts | 9 + .../audiobookshelf-integration.ts | 133 ++++++++++ .../audiobookshelf/audiobookshelf-types.ts | 36 +++ packages/integrations/src/base/creator.ts | 8 + packages/integrations/src/index.ts | 7 + .../src/navidrome/navidrome-integration.ts | 171 ++++++++++++ .../src/navidrome/navidrome-types.ts | 71 +++++ .../test/navidrome-integration.spec.ts | 201 ++++++++++++++ .../paperless-ngx-integration.ts | 67 +++++ .../src/paperless-ngx/paperless-ngx-types.ts | 21 ++ packages/integrations/src/types.ts | 4 + .../test/uptime-kuma-integration.spec.ts | 162 ++++++++++++ .../uptime-kuma/uptime-kuma-integration.ts | 140 ++++++++++ .../src/uptime-kuma/uptime-kuma-types.ts | 62 +++++ .../request-handler/src/audiobookshelf.ts | 19 ++ packages/request-handler/src/navidrome.ts | 19 ++ packages/request-handler/src/paperless-ngx.ts | 19 ++ packages/request-handler/src/uptime-kuma.ts | 19 ++ packages/translation/src/lang/en.json | 185 ++++++++++++- .../src/audio-stats/audio-stats-content.tsx | 125 +++++++++ .../audio-stats/audio-stats-shared.spec.ts | 94 +++++++ .../src/audio-stats/component.module.css | 185 +++++++++++++ .../widgets/src/audio-stats/component.tsx | 14 + packages/widgets/src/audio-stats/index.ts | 62 +++++ packages/widgets/src/audio-stats/shared.ts | 169 ++++++++++++ packages/widgets/src/definition.ts | 1 + packages/widgets/src/index.tsx | 6 + .../widgets/src/modals/widget-edit-modal.tsx | 4 + .../src/paperless-ngx/component.module.css | 137 ++++++++++ .../widgets/src/paperless-ngx/component.tsx | 201 ++++++++++++++ packages/widgets/src/paperless-ngx/index.ts | 48 ++++ .../src/uptime-kuma/component.module.css | 121 +++++++++ .../widgets/src/uptime-kuma/component.tsx | 245 ++++++++++++++++++ packages/widgets/src/uptime-kuma/index.ts | 43 +++ .../widgets/src/widget-integration-select.tsx | 13 +- 46 files changed, 2994 insertions(+), 16 deletions(-) create mode 100644 packages/api/src/router/widgets/audio-stats.ts create mode 100644 packages/api/src/router/widgets/paperless-ngx.ts create mode 100644 packages/api/src/router/widgets/uptime-kuma.ts create mode 100644 packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts create mode 100644 packages/integrations/src/audiobookshelf/audiobookshelf-types.ts create mode 100644 packages/integrations/src/navidrome/navidrome-integration.ts create mode 100644 packages/integrations/src/navidrome/navidrome-types.ts create mode 100644 packages/integrations/src/navidrome/test/navidrome-integration.spec.ts create mode 100644 packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts create mode 100644 packages/integrations/src/paperless-ngx/paperless-ngx-types.ts create mode 100644 packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts create mode 100644 packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts create mode 100644 packages/integrations/src/uptime-kuma/uptime-kuma-types.ts create mode 100644 packages/request-handler/src/audiobookshelf.ts create mode 100644 packages/request-handler/src/navidrome.ts create mode 100644 packages/request-handler/src/paperless-ngx.ts create mode 100644 packages/request-handler/src/uptime-kuma.ts create mode 100644 packages/widgets/src/audio-stats/audio-stats-content.tsx create mode 100644 packages/widgets/src/audio-stats/audio-stats-shared.spec.ts create mode 100644 packages/widgets/src/audio-stats/component.module.css create mode 100644 packages/widgets/src/audio-stats/component.tsx create mode 100644 packages/widgets/src/audio-stats/index.ts create mode 100644 packages/widgets/src/audio-stats/shared.ts create mode 100644 packages/widgets/src/paperless-ngx/component.module.css create mode 100644 packages/widgets/src/paperless-ngx/component.tsx create mode 100644 packages/widgets/src/paperless-ngx/index.ts create mode 100644 packages/widgets/src/uptime-kuma/component.module.css create mode 100644 packages/widgets/src/uptime-kuma/component.tsx create mode 100644 packages/widgets/src/uptime-kuma/index.ts 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 652cad7aa..18b32206e 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 @@ -8,6 +8,7 @@ import { IconPasswordUser, IconPlug, IconServer, + IconTag, IconUser, } from "@tabler/icons-react"; @@ -28,4 +29,5 @@ export const integrationSecretIcons = { githubAppId: IconCode, githubInstallationId: IconPlug, privateKey: IconKey, + slug: IconTag, } satisfies Record; diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index 42b619364..d4b2cb24d 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -2,6 +2,7 @@ import { getBoardLayouts } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import type { Modify } from "@homarr/common/types"; import type { WidgetKind } from "@homarr/definitions"; +import { widgetDefaultSizes } from "@homarr/definitions"; import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; import { getFirstEmptyPosition } from "./empty-position"; @@ -19,7 +20,7 @@ export const createItemCallback = (previous: Board): Board => { const firstSection = previous.sections .filter((section): section is EmptySection => section.kind === "empty") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); if (!firstSection) return previous; @@ -28,7 +29,7 @@ export const createItemCallback = id: id ?? createId(), kind, options, - layouts: createItemLayouts(previous, firstSection), + layouts: createItemLayouts(previous, firstSection, kind), integrationIds, advancedOptions: { title: null, @@ -48,15 +49,20 @@ export const createItemCallback = }; }; -const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => { +const defaultItemSize = { width: 1, height: 1 }; + +const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize; + +const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => { const layouts = getBoardLayouts(board); + const itemSize = getWidgetItemSize(kind); return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount) + ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -64,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayo } return { - width: 1, - height: 1, + width: itemSize.width, + height: itemSize.height, ...emptyPosition, sectionId: currentSection.id, layoutId, diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 704449b34..4b43a4299 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -57,6 +57,9 @@ export const categoryTranslationKeys: Record = { mediaMonitoring: "integration.category.mediaMonitoring", speedtest: "integration.category.speedtest", analytics: "integration.category.analytics", + documents: "integration.category.documents", + mediaLibrary: "integration.category.mediaLibrary", + uptimeMonitoring: "integration.category.uptimeMonitoring", }; export interface IntegrationGridItem { diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx index 80d58fa07..032dbc3bc 100644 --- a/apps/nextjs/src/components/layout/navigation-link.tsx +++ b/apps/nextjs/src/components/layout/navigation-link.tsx @@ -1,6 +1,7 @@ "use client"; import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { NavLink } from "@mantine/core"; @@ -15,8 +16,21 @@ const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => { return {children}; }; -const NavLinkHref = (props: NavigationLinkHref) => { +const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`); + +const useClientPathname = () => { const pathname = usePathname(); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return { pathname, isClient }; +}; + +const NavLinkHref = (props: NavigationLinkHref) => { + const { pathname, isClient } = useClientPathname(); const tourId = props["data-onboarding-tour-id"]; const link = props.external ? ( @@ -26,15 +40,15 @@ const NavLinkHref = (props: NavigationLinkHref) => { label={props.label} leftSection={props.icon} href={props.href} - active={pathname === props.href || pathname.startsWith(`${props.href}/`)} + active={isClient && pathMatches(pathname, props.href)} /> ); return {link}; }; const NavLinkWithItems = (props: NavigationLinkWithItems) => { - const pathname = usePathname(); - const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`)); + const { pathname, isClient } = useClientPathname(); + const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href)); const nav = ( {props.items.map((item) => ( diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts new file mode 100644 index 000000000..d43876583 --- /dev/null +++ b/packages/api/src/router/widgets/audio-stats.ts @@ -0,0 +1,29 @@ +import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf"; +import { navidromeRequestHandler } from "@homarr/request-handler/navidrome"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const audioStatsRouter = createTRPCRouter({ + getStats: publicProcedure + .concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf")) + .query(async ({ ctx }) => { + const { kind } = ctx.integration; + + const fetchByKind = { + navidrome: async () => { + const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {}); + return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + }, + audiobookshelf: async () => { + const handler = audiobookshelfRequestHandler.handler( + { ...ctx.integration, kind: "audiobookshelf" as const }, + {}, + ); + return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + }, + } as const; + + return { kind, data: await fetchByKind[kind]() }; + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index be34ad462..9596cf44e 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -7,6 +7,7 @@ export const widgetRouter = createTRPCRouter({ anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)), coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)), + paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), app: lazy(() => import("./app").then((mod) => mod.appRouter)), @@ -31,6 +32,8 @@ export const widgetRouter = createTRPCRouter({ timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)), tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), + uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)), + audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)), umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), }); diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts new file mode 100644 index 000000000..0fb81e72a --- /dev/null +++ b/packages/api/src/router/widgets/paperless-ngx.ts @@ -0,0 +1,12 @@ +import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const paperlessNgxRouter = createTRPCRouter({ + getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => { + const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {}); + const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.data; + }), +}); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts new file mode 100644 index 000000000..eb28f9e7e --- /dev/null +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -0,0 +1,55 @@ +import { observable } from "@trpc/server/observable"; + +import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; +import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const uptimeKumaRouter = createTRPCRouter({ + getDashboard: publicProcedure + .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + + subscribeToDashboard: publicProcedure + .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) + .subscription(({ ctx }) => { + return observable<{ + integrationId: string; + dashboard: UptimeKumaDashboardData; + timestamp: Date; + }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + return innerHandler.subscribe((dashboard) => { + emit.next({ + integrationId: integration.id, + dashboard, + timestamp: new Date(), + }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), +}); diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index c85715808..9dcba12d4 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -27,6 +27,10 @@ const integrationAliases: Partial> = overseerr: ["overseerr"], dashDot: ["dashdot", "dash-dot", "dash."], speedtestTracker: ["speedtest-tracker"], + uptimeKuma: ["uptime-kuma"], + audiobookshelf: ["audiobookshelf"], + navidrome: ["navidrome"], + paperlessNgx: ["paperless-ngx", "paperless"], searchCh: ["search-ch"], gitHubContainerRegistry: ["ghcr"], linuxServerIO: ["linuxserver"], diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 05a29f29e..eed534ea0 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -17,6 +17,7 @@ export const integrationSecretKindObject = { privateKey: { isPublic: false, multiline: true }, githubAppId: { isPublic: true, multiline: false }, githubInstallationId: { isPublic: true, multiline: false }, + slug: { isPublic: true, multiline: false }, } satisfies Record; export const integrationSecretKinds = objectKeys(integrationSecretKindObject); @@ -401,6 +402,14 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/immich"), defaultPort: 2283, }, + paperlessNgx: { + name: "Paperless-ngx", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg", + category: ["documents"], + documentationUrl: null, + defaultPort: 8000, + }, tracearr: { name: "Tracearr", secretKinds: [["apiKey"]], @@ -417,6 +426,31 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), defaultPort: 80, }, + uptimeKuma: { + name: "Uptime Kuma", + secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg", + category: ["uptimeMonitoring"], + documentationUrl: null, + defaultPort: 3001, + }, + audiobookshelf: { + name: "Audiobookshelf", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg", + category: ["mediaLibrary"], + documentationUrl: null, + defaultPort: 13378, + apiKeySettingsPath: "/account", + }, + navidrome: { + name: "Navidrome", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", + category: ["mediaLibrary"], + documentationUrl: null, + defaultPort: 4533, + }, umami: { name: "Umami", secretKinds: [["username", "password"], ["apiKey"]], @@ -460,6 +494,7 @@ export const integrationDefs = { "notifications", "smartHomeServer", "ups", + "uptimeMonitoring", ], documentationUrl: null, }, @@ -548,6 +583,9 @@ export const integrationCategories = [ "speedtest", "analytics", "ups", + "documents", + "mediaLibrary", + "uptimeMonitoring", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index bef83fea6..87898a49e 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -33,8 +33,11 @@ export const widgetIntegrationSupport: Partial> = { + uptimeKuma: { width: 2, height: 3 }, + audioStats: { width: 2, height: 2 }, + paperlessNgx: { width: 2, height: 2 }, +}; diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts new file mode 100644 index 000000000..441d7fe3a --- /dev/null +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts @@ -0,0 +1,133 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { + audiobookshelfLibrariesResponseSchema, + audiobookshelfLibraryStatsSchema, + audiobookshelfListeningStatsSchema, + audiobookshelfOnlineUsersResponseSchema, +} from "./audiobookshelf-types"; +import type { + AudiobookshelfDashboardData, + AudiobookshelfLibrary, + AudiobookshelfLibraryStats, +} from "./audiobookshelf-types"; + +const mediaTypeCountField = { + book: "totalAudiobooks", + podcast: "totalPodcasts", +} as const satisfies Record< + AudiobookshelfLibrary["mediaType"], + keyof Pick +>; + +export class AudiobookshelfIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const healthResponse = await input.fetchAsync(this.url("/healthcheck")); + + if (!healthResponse.ok) { + return TestConnectionError.StatusResult(healthResponse); + } + + const authorizeResponse = await input.fetchAsync(this.url("/api/authorize"), { + method: "POST", + headers: this.getAuthHeaders(), + }); + + if (!authorizeResponse.ok) { + return TestConnectionError.StatusResult(authorizeResponse); + } + + return { success: true }; + } + + public async getDashboardDataAsync(): Promise { + const [libraries, listeningStats, activeSessions] = await Promise.all([ + this.getLibrariesAsync(), + this.getListeningStatsAsync(), + this.getActiveSessionsCountAsync(), + ]); + + const statsByLibrary = await Promise.all( + libraries.map(async (library) => ({ + mediaType: library.mediaType, + stats: await this.getLibraryStatsAsync(library.id), + })), + ); + + const counts = { + totalAudiobooks: 0, + totalPodcasts: 0, + }; + + for (const item of statsByLibrary) { + const countField = mediaTypeCountField[item.mediaType]; + counts[countField] += item.stats.totalItems; + } + + return { + libraryCount: libraries.length, + totalAudiobooks: counts.totalAudiobooks, + totalPodcasts: counts.totalPodcasts, + totalListeningTimeSeconds: listeningStats.totalTime, + activeSessions, + }; + } + + private async getLibrariesAsync(): Promise { + const response = await this.getAsync("/api/libraries"); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfLibrariesResponseSchema.parse(await response.json()).libraries; + } + + private async getLibraryStatsAsync(libraryId: string): Promise { + const response = await this.getAsync(`/api/libraries/${libraryId}/stats`); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfLibraryStatsSchema.parse(await response.json()); + } + + private async getListeningStatsAsync() { + const response = await this.getAsync("/api/me/listening-stats"); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfListeningStatsSchema.parse(await response.json()); + } + + private async getActiveSessionsCountAsync(): Promise { + const response = await this.getAsync("/api/users/online"); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfOnlineUsersResponseSchema.parse(await response.json()).openSessions.length; + } + + private async getAsync(path: `/api/${string}` | "/healthcheck") { + return await fetchWithTrustedCertificatesAsync(this.url(path), { + headers: this.getAuthHeaders(), + }); + } + + private getAuthHeaders(): Record { + return { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + Accept: "application/json", + }; + } +} diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts new file mode 100644 index 000000000..495a24c08 --- /dev/null +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts @@ -0,0 +1,36 @@ +import { z } from "zod/v4"; + +export const audiobookshelfLibrarySchema = z.object({ + id: z.string(), + name: z.string(), + mediaType: z.enum(["book", "podcast"]), +}); + +export const audiobookshelfLibrariesResponseSchema = z.object({ + libraries: z.array(audiobookshelfLibrarySchema), +}); + +export const audiobookshelfLibraryStatsSchema = z.object({ + totalItems: z.number(), + totalDuration: z.number().optional(), + totalSize: z.number().optional(), +}); + +export const audiobookshelfListeningStatsSchema = z.object({ + totalTime: z.number(), +}); + +export const audiobookshelfOnlineUsersResponseSchema = z.object({ + openSessions: z.array(z.unknown()), +}); + +export interface AudiobookshelfDashboardData { + libraryCount: number; + totalAudiobooks: number; + totalPodcasts: number; + totalListeningTimeSeconds: number; + activeSessions: number; +} + +export type AudiobookshelfLibrary = z.infer; +export type AudiobookshelfLibraryStats = z.infer; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 54d9566f7..51eb7fa64 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -3,6 +3,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { BeszelIntegration } from "../beszel/beszel-integration"; import { AnchorIntegration } from "../anchor/anchor-integration"; +import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration"; import { CodebergIntegration } from "../codeberg/codeberg-integration"; import { CoolifyIntegration } from "../coolify/coolify-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; @@ -31,12 +32,14 @@ import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integrati import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; import { MockIntegration } from "../mock/mock-integration"; +import { NavidromeIntegration } from "../navidrome/navidrome-integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; import { NPMIntegration } from "../npm/npm-integration"; import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OPNsenseIntegration } from "../opnsense/opnsense-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; +import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration"; import { PeaNutIntegration } from "../peanut/peanut-integration"; import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory"; import { PlexIntegration } from "../plex/plex-integration"; @@ -49,6 +52,7 @@ import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-trac import { TracearrIntegration } from "../tracearr/tracearr-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration"; import { UmamiIntegration } from "../umami/umami-integration"; +import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnraidIntegration } from "../unraid/unraid-integration"; import type { Integration, IntegrationInput } from "./integration"; @@ -123,8 +127,12 @@ export const integrationCreators = { glances: GlancesIntegration, searchCh: SearchChIntegration, immich: ImmichIntegration, + paperlessNgx: PaperlessNgxIntegration, speedtestTracker: SpeedtestTrackerIntegration, + audiobookshelf: AudiobookshelfIntegration, + navidrome: NavidromeIntegration, umami: UmamiIntegration, + uptimeKuma: UptimeKumaIntegration, peaNut: PeaNutIntegration, beszel: BeszelIntegration, } satisfies Record Promise]>; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 198a17671..2f1c7d2df 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -33,6 +33,9 @@ export { CoolifyIntegration } from "./coolify/coolify-integration"; export { ImmichIntegration } from "./immich/immich-integration"; export { TracearrIntegration } from "./tracearr/tracearr-integration"; export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration"; +export { AudiobookshelfIntegration } from "./audiobookshelf/audiobookshelf-integration"; +export { NavidromeIntegration } from "./navidrome/navidrome-integration"; +export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration"; export { UmamiIntegration } from "./umami/umami-integration"; export { PeaNutIntegration } from "./peanut/peanut-integration"; @@ -61,6 +64,7 @@ export type { export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration"; +export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types"; export type { AnchorNote, AnchorNotePermission, @@ -70,6 +74,9 @@ export type { } from "./anchor/anchor-types"; export type { TracearrDashboardData } from "./tracearr/tracearr-types"; export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types"; +export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types"; +export type { NavidromeDashboardData } from "./navidrome/navidrome-types"; +export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types"; export type { UmamiVisitorStats } from "./umami/umami-types"; // Schemas diff --git a/packages/integrations/src/navidrome/navidrome-integration.ts b/packages/integrations/src/navidrome/navidrome-integration.ts new file mode 100644 index 000000000..4d7609ea0 --- /dev/null +++ b/packages/integrations/src/navidrome/navidrome-integration.ts @@ -0,0 +1,171 @@ +import { ParseError, ResponseError } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types"; +import { subsonicResponseSchema } from "./navidrome-types"; + +const logger = createLogger({ module: "navidromeIntegration" }); + +const subsonicVersion = "1.16.1"; +const subsonicClient = "homarr"; +const albumListPageSize = 500; +const maxAlbumListPages = 20; +const emptyLibraryMessage = "Library not found or empty"; + +const subsonicAuthErrorCodes = new Set([40, 41]); + +const asArray = (value: TValue | TValue[] | undefined): TValue[] => + [value].flat().filter((item): item is TValue => item !== undefined); + +export class NavidromeIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const url = this.url("/rest/ping.view", this.getAuthParams()); + const response = await input.fetchAsync(url); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + const parsed = subsonicResponseSchema.safeParse(await response.json()); + + if (!parsed.success) { + return TestConnectionError.ParseResult(new ParseError("Invalid Subsonic response", { cause: parsed.error })); + } + + const subsonicResponse = parsed.data["subsonic-response"]; + + if (subsonicResponse.status === "failed") { + const errorCode = subsonicResponse.error?.code; + + if (errorCode !== undefined && subsonicAuthErrorCodes.has(errorCode)) { + return TestConnectionError.UnauthorizedResult(401); + } + + return TestConnectionError.UnknownResult(new Error(subsonicResponse.error?.message ?? "Subsonic request failed")); + } + + return { success: true }; + } + + public async getDashboardDataAsync(): Promise { + const [artistCount, libraryCounts, nowPlaying] = await Promise.all([ + this.getArtistCountAsync(), + this.getAlbumAndSongCountsAsync(), + this.getNowPlayingAsync(), + ]); + + return { + artistCount, + albumCount: libraryCounts.albumCount, + songCount: libraryCounts.songCount, + nowPlaying, + }; + } + + private async getArtistCountAsync(): Promise { + const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true }); + return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0); + } + + private async getAlbumAndSongCountsAsync(): Promise<{ albumCount: number; songCount: number }> { + let offset = 0; + let albumCount = 0; + let songCount = 0; + let page = 0; + + while (page < maxAlbumListPages) { + const response = await this.subsonicRequestAsync( + "/rest/getAlbumList2.view", + { + type: "alphabeticalByName", + size: albumListPageSize, + offset, + }, + { tolerateEmptyLibrary: true }, + ); + const albums = asArray(response.albumList2?.album); + + if (albums.length === 0) { + break; + } + + albumCount += albums.length; + + for (const album of albums) { + songCount += album.songCount ?? 0; + } + + if (albums.length < albumListPageSize) { + break; + } + + offset += albumListPageSize; + page += 1; + } + + if (page >= maxAlbumListPages) { + logger.warn("Reached Navidrome album list page limit", { + integrationId: this.integration.id, + maxPages: maxAlbumListPages, + albumCount, + }); + } + + return { albumCount, songCount }; + } + + private async getNowPlayingAsync(): Promise { + const response = await this.subsonicRequestAsync("/rest/getNowPlaying.view", {}, { tolerateEmptyLibrary: true }); + + return asArray(response.nowPlaying?.entry).map((entry) => ({ + title: entry.title ?? "", + artist: entry.artist ?? "", + album: entry.album ?? "", + username: entry.username ?? "", + playerName: entry.playerName ?? "", + })); + } + + private async subsonicRequestAsync( + path: `/rest/${string}`, + params: Record = {}, + options: { tolerateEmptyLibrary?: boolean } = {}, + ): Promise { + const url = this.url(path, { ...this.getAuthParams(), ...params }); + const response = await fetchWithTrustedCertificatesAsync(url); + + if (!response.ok) { + throw new ResponseError(response); + } + + const parsed = subsonicResponseSchema.parse(await response.json()); + const subsonicResponse = parsed["subsonic-response"]; + + if (subsonicResponse.status === "failed") { + const message = subsonicResponse.error?.message ?? "Subsonic request failed"; + + if (options.tolerateEmptyLibrary && message === emptyLibraryMessage) { + return subsonicResponse; + } + + throw new Error(message); + } + + return subsonicResponse; + } + + private getAuthParams(): Record { + return { + u: this.getSecretValue("username"), + p: this.getSecretValue("password"), + v: subsonicVersion, + c: subsonicClient, + f: "json", + }; + } +} diff --git a/packages/integrations/src/navidrome/navidrome-types.ts b/packages/integrations/src/navidrome/navidrome-types.ts new file mode 100644 index 000000000..a9e5067f2 --- /dev/null +++ b/packages/integrations/src/navidrome/navidrome-types.ts @@ -0,0 +1,71 @@ +import { z } from "zod/v4"; + +const subsonicArtistSchema = z.object({ + id: z.string(), + name: z.string(), + albumCount: z.number().optional(), +}); + +const subsonicIndexSchema = z.object({ + name: z.string(), + artist: z.union([subsonicArtistSchema, z.array(subsonicArtistSchema)]).optional(), +}); + +const subsonicAlbumSchema = z.object({ + id: z.string(), + name: z.string(), + songCount: z.number().optional(), +}); + +const subsonicNowPlayingEntrySchema = z.object({ + title: z.string().optional(), + artist: z.string().optional(), + album: z.string().optional(), + username: z.string().optional(), + playerName: z.string().optional(), +}); + +export const subsonicResponseSchema = z.object({ + "subsonic-response": z.object({ + status: z.enum(["ok", "failed"]), + version: z.string().optional(), + error: z + .object({ + code: z.number(), + message: z.string(), + }) + .optional(), + artists: z + .object({ + index: z.union([subsonicIndexSchema, z.array(subsonicIndexSchema)]).optional(), + }) + .optional(), + albumList2: z + .object({ + album: z.union([subsonicAlbumSchema, z.array(subsonicAlbumSchema)]).optional(), + }) + .optional(), + nowPlaying: z + .object({ + entry: z.union([subsonicNowPlayingEntrySchema, z.array(subsonicNowPlayingEntrySchema)]).optional(), + }) + .optional(), + }), +}); + +export type SubsonicResponseBody = z.infer["subsonic-response"]; + +export interface NavidromeNowPlayingEntry { + title: string; + artist: string; + album: string; + username: string; + playerName: string; +} + +export interface NavidromeDashboardData { + artistCount: number; + albumCount: number; + songCount: number; + nowPlaying: NavidromeNowPlayingEntry[]; +} diff --git a/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts new file mode 100644 index 000000000..921169b1a --- /dev/null +++ b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts @@ -0,0 +1,201 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { NavidromeIntegration } from "../navidrome-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://navidrome.example.com"; +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const secrets: IntegrationSecret[] = [ + { kind: "username", value: "admin" }, + { kind: "password", value: "secret" }, +]; + +const toUrlString = (url: unknown): string => String(url); + +const createIntegration = () => + new NavidromeIntegration({ + id: "test-navidrome", + name: "Test Navidrome", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: secrets, + }); + +const subsonicOk = >(data: T) => + JSON.stringify({ + "subsonic-response": { status: "ok", version: "1.16.1", ...data }, + }); + +const subsonicFailed = (message: string) => + JSON.stringify({ + "subsonic-response": { status: "failed", error: { code: 70, message } }, + }); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("NavidromeIntegration.getDashboardDataAsync", () => { + test("paginates album list and counts songs", async () => { + const makeAlbums = (count: number, songCount: number) => + Array.from({ length: count }, (_, i) => ({ + id: `album-${i}`, + name: `Album ${i}`, + songCount, + })); + + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getArtists")) { + return Promise.resolve( + new Response(subsonicOk({ artists: { index: [{ name: "A", artist: [{ id: "1", name: "Artist" }] }] } }), { + status: 200, + }), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getAlbumList2")) { + const offsetMatch = urlStr.match(/offset=(\d+)/); + const offset = Number(offsetMatch?.[1] ?? 0); + + if (offset === 0) { + return Promise.resolve( + new Response(subsonicOk({ albumList2: { album: makeAlbums(500, 10) } }), { status: 200 }), + ) as unknown as ReturnType; + } + + if (offset === 500) { + return Promise.resolve( + new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }), + ) as unknown as ReturnType; + } + + return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getDashboardDataAsync(); + + expect(result.albumCount).toBe(700); + expect(result.songCount).toBe(500 * 10 + 200 * 5); + expect(result.artistCount).toBe(1); + expect(result.nowPlaying).toEqual([]); + }); + + test("handles empty library gracefully", async () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getArtists")) { + return Promise.resolve( + new Response(subsonicFailed("Library not found or empty"), { status: 200 }), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getAlbumList2")) { + return Promise.resolve( + new Response(subsonicFailed("Library not found or empty"), { status: 200 }), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve( + new Response(subsonicFailed("Library not found or empty"), { status: 200 }), + ) as unknown as ReturnType; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getDashboardDataAsync(); + + expect(result.artistCount).toBe(0); + expect(result.albumCount).toBe(0); + expect(result.songCount).toBe(0); + expect(result.nowPlaying).toEqual([]); + }); + + test("throws on non-empty-library subsonic error", async () => { + mockFetch.mockImplementation( + () => + Promise.resolve(new Response(subsonicFailed("Permission denied"), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >, + ); + + const integration = createIntegration(); + await expect(integration.getDashboardDataAsync()).rejects.toThrow(); + }); + + test("now playing maps entries correctly", async () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve( + new Response( + subsonicOk({ + nowPlaying: { + entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" }, + }, + }), + { status: 200 }, + ), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getArtists")) { + return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + if (urlStr.includes("getAlbumList2")) { + return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getDashboardDataAsync(); + + expect(result.nowPlaying).toHaveLength(1); + expect(result.nowPlaying[0]).toEqual({ + title: "Song", + artist: "Band", + album: "LP", + username: "user1", + playerName: "Chrome", + }); + }); +}); diff --git a/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts new file mode 100644 index 000000000..a6059c3e8 --- /dev/null +++ b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts @@ -0,0 +1,67 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { PaperlessNgxStats } from "./paperless-ngx-types"; +import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types"; + +export class PaperlessNgxIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const statisticsUrl = this.url("/api/statistics/"); + const statisticsResponse = await input.fetchAsync(statisticsUrl, { + headers: this.getAuthHeaders(), + }); + + if (!statisticsResponse.ok) { + return TestConnectionError.StatusResult(statisticsResponse); + } + + return { success: true }; + } + + public async getStatsAsync(): Promise { + const [statistics, tags, correspondents, documentTypes] = await Promise.all([ + this.fetchStatisticsAsync(), + this.fetchCountAsync("/api/tags/"), + this.fetchCountAsync("/api/correspondents/"), + this.fetchCountAsync("/api/document_types/"), + ]); + + return { + documentsTotal: statistics.documents_total, + documentsInbox: statistics.documents_inbox, + correspondentsCount: correspondents, + tagsCount: tags, + documentTypesCount: documentTypes, + }; + } + + private async fetchAuthenticatedAsync(path: `/${string}`) { + const response = await fetchWithTrustedCertificatesAsync(this.url(path), { + headers: this.getAuthHeaders(), + }); + if (!response.ok) { + throw new ResponseError(response); + } + return response; + } + + private async fetchStatisticsAsync() { + const response = await this.fetchAuthenticatedAsync("/api/statistics/"); + return paperlessNgxStatisticsSchema.parse(await response.json()); + } + + private async fetchCountAsync(path: `/${string}`) { + const response = await this.fetchAuthenticatedAsync(path); + return paperlessNgxPaginatedCountSchema.parse(await response.json()).count; + } + + private getAuthHeaders(): Record { + return { + Authorization: `Token ${this.getSecretValue("apiKey")}`, + }; + } +} diff --git a/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts b/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts new file mode 100644 index 000000000..8a47ffcdb --- /dev/null +++ b/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts @@ -0,0 +1,21 @@ +import { z } from "zod/v4"; + +export const paperlessNgxStatisticsSchema = z.object({ + documents_total: z.number(), + documents_inbox: z + .number() + .nullable() + .transform((value) => value ?? 0), +}); + +export const paperlessNgxPaginatedCountSchema = z.object({ + count: z.number(), +}); + +export interface PaperlessNgxStats { + documentsTotal: number; + documentsInbox: number; + correspondentsCount: number; + tagsCount: number; + documentTypesCount: number; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index e912d026d..1ee395dfa 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -17,5 +17,9 @@ export * from "./anchor/anchor-types"; export * from "./interfaces/timetable/timetable-types"; export * from "./tracearr/tracearr-types"; export * from "./speedtest-tracker/speedtest-tracker-types"; +export * from "./uptime-kuma/uptime-kuma-types"; +export * from "./audiobookshelf/audiobookshelf-types"; +export * from "./navidrome/navidrome-types"; +export * from "./paperless-ngx/paperless-ngx-types"; export * from "./umami/umami-types"; export * from "./beszel/beszel-types"; diff --git a/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts b/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts new file mode 100644 index 000000000..f21c522b6 --- /dev/null +++ b/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts @@ -0,0 +1,162 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { UptimeKumaIntegration } from "../uptime-kuma-integration"; +import { uptimeKumaHeartbeatCategoryMap } from "../uptime-kuma-types"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://uptime.example.com"; +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const toUrlString = (url: unknown): string => String(url); + +const createIntegration = (secrets: IntegrationSecret[] = []) => + new UptimeKumaIntegration({ + id: "test-uptime-kuma", + name: "Test Uptime Kuma", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: secrets, + }); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("uptimeKumaHeartbeatCategoryMap", () => { + test("maps status 0 (down) to 'down'", () => { + expect(uptimeKumaHeartbeatCategoryMap[0]).toBe("down"); + }); + + test("maps status 1 (up) to 'up'", () => { + expect(uptimeKumaHeartbeatCategoryMap[1]).toBe("up"); + }); + + test("maps status 2 (pending) to 'paused'", () => { + expect(uptimeKumaHeartbeatCategoryMap[2]).toBe("paused"); + }); + + test("maps status 3 (maintenance) to 'paused'", () => { + expect(uptimeKumaHeartbeatCategoryMap[3]).toBe("paused"); + }); + + test("returns undefined for unknown status codes", () => { + expect(uptimeKumaHeartbeatCategoryMap[99]).toBeUndefined(); + }); +}); + +describe("UptimeKumaIntegration.getDashboardDataAsync", () => { + const statusPageResponse = { + publicGroupList: [ + { + id: 1, + name: "Services", + monitorList: [ + { id: 10, name: "Web" }, + { id: 20, name: "API" }, + { id: 30, name: "DB" }, + ], + }, + ], + }; + + const heartbeatResponse = { + heartbeatList: { + "10": [{ status: 1, time: "2025-01-01" }], + "20": [{ status: 0, time: "2025-01-01" }], + "30": [{ status: 2, time: "2025-01-01" }], + }, + uptimeList: { + "10_24": 0.995, + "20_24": 0.5, + "30_24": 0.8, + }, + }; + + const mockResponses = () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("/heartbeat/")) { + return Promise.resolve( + new Response(JSON.stringify(heartbeatResponse), { status: 200 }), + ) as unknown as ReturnType; + } + + return Promise.resolve( + new Response(JSON.stringify(statusPageResponse), { status: 200 }), + ) as unknown as ReturnType; + }); + }; + + test("aggregates monitor counts correctly", async () => { + mockResponses(); + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + expect(result.totalMonitors).toBe(3); + expect(result.upCount).toBe(1); + expect(result.downCount).toBe(1); + expect(result.pausedCount).toBe(1); + }); + + test("computes average uptime from uptimeList", async () => { + mockResponses(); + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + const expectedAverage = ((0.995 + 0.5 + 0.8) * 100) / 3; + expect(result.averageUptimePercent).toBeCloseTo(expectedAverage, 5); + }); + + test("defaults unknown heartbeat status to 'down'", async () => { + const unknownHeartbeat = { + heartbeatList: { "10": [{ status: 99, time: "2025-01-01" }] }, + uptimeList: { "10_24": 1 }, + }; + + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + if (urlStr.includes("/heartbeat/")) { + return Promise.resolve( + new Response(JSON.stringify(unknownHeartbeat), { status: 200 }), + ) as unknown as ReturnType; + } + return Promise.resolve( + new Response( + JSON.stringify({ + publicGroupList: [{ id: 1, name: "G", monitorList: [{ id: 10, name: "Svc" }] }], + }), + { status: 200 }, + ), + ) as unknown as ReturnType; + }); + + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + expect(result.monitors[0]?.status).toBe("down"); + expect(result.downCount).toBe(1); + }); + + test("handles empty monitor list gracefully", async () => { + mockFetch.mockImplementation( + () => + Promise.resolve( + new Response(JSON.stringify({ publicGroupList: [], heartbeatList: {}, uptimeList: {} }), { status: 200 }), + ) as unknown as ReturnType, + ); + + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + expect(result.totalMonitors).toBe(0); + expect(result.averageUptimePercent).toBe(0); + }); +}); diff --git a/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts new file mode 100644 index 000000000..ce494e599 --- /dev/null +++ b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts @@ -0,0 +1,140 @@ +import { ParseError, ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { + uptimeKumaHeartbeatCategoryMap, + uptimeKumaHeartbeatResponseSchema, + uptimeKumaStatusPageResponseSchema, +} from "./uptime-kuma-types"; +import type { UptimeKumaDashboardData, UptimeKumaMonitor, UptimeKumaMonitorCategory } from "./uptime-kuma-types"; + +export class UptimeKumaIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const statusPageResponse = await input.fetchAsync(this.statusPageUrl()); + + if (!statusPageResponse.ok) { + return TestConnectionError.StatusResult(statusPageResponse); + } + + const statusPageParsed = uptimeKumaStatusPageResponseSchema.safeParse(await statusPageResponse.json()); + + if (!statusPageParsed.success) { + return TestConnectionError.ParseResult( + new ParseError("Invalid Uptime Kuma status page response", { cause: statusPageParsed.error }), + ); + } + + const heartbeatResponse = await input.fetchAsync(this.heartbeatUrl()); + + if (!heartbeatResponse.ok) { + return TestConnectionError.StatusResult(heartbeatResponse); + } + + const heartbeatParsed = uptimeKumaHeartbeatResponseSchema.safeParse(await heartbeatResponse.json()); + + if (!heartbeatParsed.success) { + return TestConnectionError.ParseResult( + new ParseError("Invalid Uptime Kuma heartbeat response", { cause: heartbeatParsed.error }), + ); + } + + return { success: true }; + } + + public async getDashboardDataAsync(): Promise { + const [statusPageResponse, heartbeatResponse] = await Promise.all([ + this.getStatusPageAsync(), + this.getHeartbeatAsync(), + ]); + + const statusPage = uptimeKumaStatusPageResponseSchema.parse(await statusPageResponse.json()); + const heartbeat = uptimeKumaHeartbeatResponseSchema.parse(await heartbeatResponse.json()); + + const monitors = statusPage.publicGroupList.flatMap((group) => + group.monitorList.map((monitor) => this.mapMonitor(monitor, heartbeat)), + ); + + return this.buildDashboardData(monitors); + } + + private mapMonitor( + monitor: { id: number; name: string }, + heartbeat: { heartbeatList: Record; uptimeList: Record }, + ): UptimeKumaMonitor { + const heartbeats = heartbeat.heartbeatList[String(monitor.id)] ?? []; + const latestHeartbeat = heartbeats.at(-1); + const uptimeKey = `${monitor.id}_24`; + const uptimeRaw = heartbeat.uptimeList[uptimeKey]; + const uptimePercent24h = typeof uptimeRaw === "number" ? uptimeRaw * 100 : null; + const status: UptimeKumaMonitorCategory = + (latestHeartbeat && (uptimeKumaHeartbeatCategoryMap[latestHeartbeat.status] ?? "down")) || "paused"; + + return { + id: monitor.id, + name: monitor.name, + status, + uptimePercent24h, + }; + } + + private buildDashboardData(monitors: UptimeKumaMonitor[]): UptimeKumaDashboardData { + const counts = monitors.reduce( + (acc, monitor) => { + acc[monitor.status] += 1; + return acc; + }, + { up: 0, down: 0, paused: 0 }, + ); + + const uptimeValues = monitors + .map((monitor) => monitor.uptimePercent24h) + .filter((value): value is number => value !== null); + + const averageUptimePercent = uptimeValues.reduce((sum, value) => sum + value, 0) / Math.max(uptimeValues.length, 1); + + return { + totalMonitors: monitors.length, + upCount: counts.up, + downCount: counts.down, + pausedCount: counts.paused, + averageUptimePercent, + monitors, + }; + } + + private async fetchOkAsync(url: string | URL) { + const response = await fetchWithTrustedCertificatesAsync(url); + if (!response.ok) { + throw new ResponseError(response); + } + return response; + } + + private getStatusPageAsync() { + return this.fetchOkAsync(this.statusPageUrl()); + } + + private getHeartbeatAsync() { + return this.fetchOkAsync(this.heartbeatUrl()); + } + + private statusPageUrl() { + return this.url(`/api/status-page/${this.getSlug()}`); + } + + private heartbeatUrl() { + return this.url(`/api/status-page/heartbeat/${this.getSlug()}`); + } + + private getSlug() { + if (this.hasSecretValue("slug")) { + return this.getSecretValue("slug").toLowerCase(); + } + + return "default"; + } +} diff --git a/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts b/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts new file mode 100644 index 000000000..f029754a8 --- /dev/null +++ b/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +export const uptimeKumaHeartbeatStatus = { + down: 0, + up: 1, + pending: 2, + maintenance: 3, +} as const; + +export type UptimeKumaMonitorCategory = "up" | "down" | "paused"; + +export const uptimeKumaHeartbeatCategoryMap: Record = { + [uptimeKumaHeartbeatStatus.down]: "down", + [uptimeKumaHeartbeatStatus.up]: "up", + [uptimeKumaHeartbeatStatus.pending]: "paused", + [uptimeKumaHeartbeatStatus.maintenance]: "paused", +}; + +export const uptimeKumaStatusPageMonitorSchema = z.object({ + id: z.number().int(), + name: z.string(), +}); + +export const uptimeKumaStatusPageGroupSchema = z.object({ + id: z.number().int(), + name: z.string(), + monitorList: z.array(uptimeKumaStatusPageMonitorSchema), +}); + +export const uptimeKumaStatusPageResponseSchema = z.object({ + publicGroupList: z.array(uptimeKumaStatusPageGroupSchema), +}); + +export const uptimeKumaHeartbeatEntrySchema = z.object({ + status: z.number().int(), + time: z.string(), + ping: z.number().nullable().optional(), +}); + +export const uptimeKumaHeartbeatResponseSchema = z.object({ + heartbeatList: z.record(z.string(), z.array(uptimeKumaHeartbeatEntrySchema)), + uptimeList: z.record(z.string(), z.number()), +}); + +export interface UptimeKumaMonitor { + id: number; + name: string; + status: UptimeKumaMonitorCategory; + uptimePercent24h: number | null; +} + +export interface UptimeKumaDashboardData { + totalMonitors: number; + upCount: number; + downCount: number; + pausedCount: number; + averageUptimePercent: number; + monitors: UptimeKumaMonitor[]; +} + +export type UptimeKumaStatusPageResponse = z.infer; +export type UptimeKumaHeartbeatResponse = z.infer; diff --git a/packages/request-handler/src/audiobookshelf.ts b/packages/request-handler/src/audiobookshelf.ts new file mode 100644 index 000000000..bb4b2d83c --- /dev/null +++ b/packages/request-handler/src/audiobookshelf.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler< + AudiobookshelfDashboardData, + "audiobookshelf", + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(10, "minutes"), + queryKey: "audiobookshelfDashboard", +}); diff --git a/packages/request-handler/src/navidrome.ts b/packages/request-handler/src/navidrome.ts new file mode 100644 index 000000000..b59f852a8 --- /dev/null +++ b/packages/request-handler/src/navidrome.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { NavidromeDashboardData } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const navidromeRequestHandler = createCachedIntegrationRequestHandler< + NavidromeDashboardData, + "navidrome", + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(10, "minutes"), + queryKey: "navidromeDashboard", +}); diff --git a/packages/request-handler/src/paperless-ngx.ts b/packages/request-handler/src/paperless-ngx.ts new file mode 100644 index 000000000..a3793658f --- /dev/null +++ b/packages/request-handler/src/paperless-ngx.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { PaperlessNgxStats } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHandler< + PaperlessNgxStats, + "paperlessNgx", + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getStatsAsync(); + }, + cacheDuration: dayjs.duration(15, "minute"), + queryKey: "paperless-ngx-stats", +}); diff --git a/packages/request-handler/src/uptime-kuma.ts b/packages/request-handler/src/uptime-kuma.ts new file mode 100644 index 000000000..39149c137 --- /dev/null +++ b/packages/request-handler/src/uptime-kuma.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler< + UptimeKumaDashboardData, + "uptimeKuma", + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(1, "minute"), + queryKey: "uptimeKumaDashboard", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index e02d6945b..8df39d7f9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -811,10 +811,13 @@ "firewall": "Firewall", "timetable": "Timetable", "photoService": "Photos", + "documents": "Documents", "notes": "Notes", "mediaMonitoring": "Monitoring", "speedtest": "Speed Test", - "analytics": "Analytics" + "analytics": "Analytics", + "mediaLibrary": "Media Library", + "uptimeMonitoring": "Uptime Monitoring" }, "testConnection": { "action": { @@ -1087,6 +1090,10 @@ "privateKey": { "label": "Private key", "newLabel": "New private key" + }, + "slug": { + "label": "Status page slug", + "newLabel": "New status page slug" } } }, @@ -1094,6 +1101,30 @@ "use": "Select integrations in items", "interact": "Interact with integrations", "full": "Full integration access" + }, + "audiobookshelf": { + "name": "Audiobookshelf", + "description": "Self-hosted audiobook and podcast server" + }, + "navidrome": { + "name": "Navidrome", + "description": "Self-hosted music streaming server with Subsonic API compatibility" + }, + "uptimeKuma": { + "name": "Uptime Kuma", + "description": "Self-hosted uptime monitoring with public status pages", + "secret": { + "slug": { + "description": "The slug of your public status page. Defaults to \"default\" if not set." + }, + "apiKey": { + "description": "Optional API key for authenticated /metrics access" + } + } + }, + "paperlessNgx": { + "name": "Paperless-ngx", + "description": "Document management system for archiving and organizing scanned documents" } }, "media": { @@ -2432,6 +2463,51 @@ } } }, + "paperlessNgx": { + "name": "Paperless-ngx", + "description": "Overview of your Paperless-ngx document archive", + "documentsTotal": "Total Documents", + "documentsInbox": "Inbox", + "correspondents": "Correspondents", + "tags": "Tags", + "documentTypes": "Document Types", + "inboxRatio": "Inbox Ratio", + "inbox": "inbox", + "total": "total", + "option": { + "showInboxRatio": { + "label": "Show inbox ratio", + "description": "Display the inbox-to-total document ratio in the hero section" + }, + "showInboxRing": { + "label": "Show inbox ring", + "description": "Display a progress ring alongside the inbox ratio" + }, + "showDocumentsTotal": { + "label": "Show total documents", + "description": "Show the total number of documents in your archive" + }, + "showDocumentsInbox": { + "label": "Show inbox count", + "description": "Show the number of unprocessed documents in the inbox" + }, + "showCorrespondents": { + "label": "Show correspondents", + "description": "Show the total number of correspondents" + }, + "showTags": { + "label": "Show tags", + "description": "Show the total number of tags" + }, + "showDocumentTypes": { + "label": "Show document types", + "description": "Show the total number of document types" + } + }, + "error": { + "internalServerError": "Failed to fetch Paperless-ngx data" + } + }, "immich-serverStats": { "name": "Immich Server Stats", "description": "Information about your Immich instance", @@ -3040,6 +3116,113 @@ "internalServerError": "Failed to fetch speedtest data" } }, + "uptimeKuma": { + "name": "Uptime Kuma", + "description": "Monitor uptime statistics from your Uptime Kuma status page", + "option": { + "showAverageUptime": { + "label": "Show average uptime", + "description": "Display the combined 24-hour average uptime percentage" + }, + "showUptimeRing": { + "label": "Show uptime ring", + "description": "Display a progress ring alongside the average uptime value" + }, + "showTotalMonitors": { + "label": "Show total monitors", + "description": "Display the total number of configured monitors" + }, + "showUpCount": { + "label": "Show up count", + "description": "Display how many monitors are currently up" + }, + "showDownCount": { + "label": "Show down count", + "description": "Display how many monitors are currently down" + }, + "showPausedCount": { + "label": "Show paused count", + "description": "Display how many monitors are currently paused" + } + }, + "averageUptime": "Average uptime (24h)", + "stats": { + "totalMonitors": "Total monitors", + "upCount": "Up", + "downCount": "Down", + "pausedCount": "Paused" + }, + "error": { + "internalServerError": "Failed to fetch uptime data" + } + }, + "audioStats": { + "name": "Audio stats", + "description": "Library and playback statistics from Navidrome or Audiobookshelf", + "option": { + "showArtists": { + "label": "Show artists", + "description": "Show the total number of artists in your music library (Navidrome)" + }, + "showAlbums": { + "label": "Show albums", + "description": "Show the total number of albums in your music library (Navidrome)" + }, + "showSongs": { + "label": "Show songs", + "description": "Show the total number of songs in your music library (Navidrome)" + }, + "showNowPlaying": { + "label": "Show now playing", + "description": "Show tracks currently being played (Navidrome)" + }, + "showNowPlayingList": { + "label": "Show now playing list", + "description": "Show individual tracks instead of only the active stream count (Navidrome)" + }, + "maxNowPlayingItems": { + "label": "Max now playing items", + "description": "Maximum number of tracks to show in the now playing list (Navidrome)" + }, + "showLibraryCount": { + "label": "Show library count", + "description": "Show the number of libraries on your instance (Audiobookshelf)" + }, + "showAudiobooks": { + "label": "Show audiobooks", + "description": "Show the total number of audiobooks across all libraries (Audiobookshelf)" + }, + "showPodcasts": { + "label": "Show podcasts", + "description": "Show the total number of podcasts across all libraries (Audiobookshelf)" + }, + "showListeningTime": { + "label": "Show listening time", + "description": "Show your total listening time (Audiobookshelf)" + }, + "showActiveSessions": { + "label": "Show active sessions", + "description": "Show the number of currently active playback sessions (Audiobookshelf)" + }, + "compactMode": { + "label": "Compact mode", + "description": "Use a denser layout with smaller tiles, ideal for small widget sizes" + } + }, + "artists": "Artists", + "albums": "Albums", + "songs": "Songs", + "libraryCount": "Libraries", + "audiobooks": "Audiobooks", + "podcasts": "Podcasts", + "listeningTime": "Listening Time", + "activeSessions": "Active Sessions", + "nowPlaying": "Now Playing", + "nowPlayingCount": "{count, plural, one {# stream} other {# streams}}", + "error": { + "internalServerError": "Failed to fetch audio stats data" + } + }, "systemResources": { "name": "System resources", "description": "CPU, Memory, Disk and other hardware usage of your system", diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx new file mode 100644 index 000000000..3b17223b8 --- /dev/null +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -0,0 +1,125 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { Badge, Text } from "@mantine/core"; +import { IconHeadphones } from "@tabler/icons-react"; + +import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import classes from "./component.module.css"; +import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared"; +import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared"; + +const rootClassByCompact = { + true: classes.rootCompact, + false: "", +} as const; + +const gridClassByCompact = { + true: classes.gridCompact, + false: "", +} as const; + +const statTileClassByCompact = { + true: classes.statTileCompact, + false: "", +} as const; + +interface AudioStatsContentProps { + backend: AudioStatsBackend; + stats: NavidromeDashboardData | AudiobookshelfDashboardData; + options: AudioStatsDisplayOptions; + width: number; +} + +export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) { + const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE); + const visibleStats = getVisibleStats(backend, options, stats); + const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact; + const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false); + const iconSize = getIconSize(width, options.compactMode ?? false); + + const navidromeStatsByBackend: Record = { + navidrome: stats as NavidromeDashboardData, + audiobookshelf: null, + }; + const navidromeStats = navidromeStatsByBackend[backend]; + const showNowPlayingSection = + navidromeStats !== null && Boolean(options.showNowPlaying) && navidromeStats.nowPlaying.length > 0; + + const nowPlayingLimitByListVisibility = { + true: options.maxNowPlayingItems ?? 3, + false: 0, + } as const; + + const nowPlayingTracks = + navidromeStats?.nowPlaying.slice( + 0, + nowPlayingLimitByListVisibility[ + String(options.showNowPlayingList) as keyof typeof nowPlayingLimitByListVisibility + ], + ) ?? []; + + const hasContent = visibleStats.length > 0 || showNowPlayingSection; + + return ( +
+ {visibleStats.length > 0 && ( +
+ {visibleStats.map(({ optionKey, statKey, value, Icon }) => ( +
+ + {value} + {t(statKey as never)} +
+ ))} +
+ )} + + {showNowPlayingSection && navidromeStats && ( +
+
+ + {t("nowPlaying" as never)} + {!options.showNowPlayingList && ( + + {(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", { + count: navidromeStats.nowPlaying.length, + })} + + )} +
+ {options.showNowPlayingList && ( +
+ {nowPlayingTracks.map((track, index) => ( +
+
+ {track.title} + + {[track.artist, track.album].filter(Boolean).join(" ยท ")} + +
+ + {track.username || track.playerName} + +
+ ))} +
+ )} +
+ )} + + {!hasContent && ( +
+ + โ€” + +
+ )} +
+ ); +} diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts new file mode 100644 index 000000000..3aab75060 --- /dev/null +++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vitest"; + +import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; + +import { getGridCols, getIconSize, getVisibleStats } from "./shared"; + +describe("getGridCols", () => { + test("caps columns to visible stat count", () => { + expect(getGridCols(500, 2, false)).toBe(2); + }); + + test("returns max cols when many stats visible", () => { + expect(getGridCols(500, 10, false)).toBe(5); + }); + + test("compact mode uses smaller breakpoints", () => { + expect(getGridCols(290, 5, true)).toBe(5); + expect(getGridCols(290, 5, false)).toBe(2); + }); + + test("returns at least 1 col even with 0 visible stats", () => { + expect(getGridCols(500, 0, false)).toBe(1); + }); +}); + +describe("getIconSize", () => { + test("returns large size for wide container", () => { + expect(getIconSize(400, false)).toBe(22); + }); + + test("returns compact size for narrow container in compact mode", () => { + expect(getIconSize(100, true)).toBe(14); + }); + + test("returns fallback for very narrow non-compact container", () => { + expect(getIconSize(50, false)).toBe(16); + }); +}); + +describe("getVisibleStats", () => { + const navidromeStats: NavidromeDashboardData = { + artistCount: 100, + albumCount: 200, + songCount: 3000, + nowPlaying: [], + }; + + const audiobookshelfStats: AudiobookshelfDashboardData = { + libraryCount: 3, + totalAudiobooks: 150, + totalPodcasts: 20, + totalListeningTimeSeconds: 86400, + activeSessions: 2, + }; + + test("returns only enabled navidrome stats", () => { + const result = getVisibleStats( + "navidrome", + { showArtists: true, showAlbums: false, showSongs: true }, + navidromeStats, + ); + expect(result).toHaveLength(2); + expect(result.map((s) => s.statKey)).toEqual(["artists", "songs"]); + expect(result[0]?.value).toBe(100); + expect(result[1]?.value).toBe(3000); + }); + + test("returns only enabled audiobookshelf stats", () => { + const result = getVisibleStats( + "audiobookshelf", + { + showLibraryCount: true, + showAudiobooks: true, + showPodcasts: false, + showListeningTime: false, + showActiveSessions: true, + }, + audiobookshelfStats, + ); + expect(result).toHaveLength(3); + expect(result.map((s) => s.statKey)).toEqual(["libraryCount", "audiobooks", "activeSessions"]); + }); + + test("returns empty array when all options disabled", () => { + const result = getVisibleStats("navidrome", {}, navidromeStats); + expect(result).toEqual([]); + }); + + test("formats listening time as duration string", () => { + const result = getVisibleStats("audiobookshelf", { showListeningTime: true }, audiobookshelfStats); + expect(result).toHaveLength(1); + expect(typeof result[0]?.value).toBe("string"); + }); +}); diff --git a/packages/widgets/src/audio-stats/component.module.css b/packages/widgets/src/audio-stats/component.module.css new file mode 100644 index 000000000..f665c8028 --- /dev/null +++ b/packages/widgets/src/audio-stats/component.module.css @@ -0,0 +1,185 @@ +.root { + container-type: size; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: clamp(6px, 3cqi, 12px); + gap: clamp(4px, 2cqi, 8px); + overflow: hidden; +} + +.rootCompact { + padding: clamp(4px, 2cqi, 8px); + gap: clamp(2px, 1cqi, 4px); +} + +.grid { + display: grid; + flex: 1; + width: 100%; + min-height: 0; + gap: clamp(4px, 2cqi, 8px); + grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-content: stretch; +} + +.gridCompact { + gap: clamp(2px, 1cqi, 4px); +} + +.statTile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; + height: 100%; + gap: clamp(2px, 1.5cqi, 4px); + padding: clamp(4px, 2cqi, 8px); + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + overflow: hidden; +} + +.statTileCompact { + gap: clamp(1px, 0.8cqi, 2px); + padding: clamp(2px, 1.2cqi, 5px); + border-radius: var(--mantine-radius-sm); +} + +.statIcon { + color: var(--mantine-color-dimmed); + flex-shrink: 0; + line-height: 0; +} + +.statLabel { + font-size: clamp(8px, 2.5cqi, 11px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statTileCompact .statLabel { + font-size: clamp(7px, 2cqi, 9px); +} + +.statValue { + font-size: clamp(13px, 5cqi, 20px); + font-weight: 700; + color: var(--mantine-primary-color-filled); + line-height: 1.1; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statTileCompact .statValue { + font-size: clamp(11px, 4cqi, 16px); +} + +.nowPlaying { + display: flex; + flex-direction: column; + gap: clamp(3px, 1.2cqi, 5px); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.nowPlayingHeader { + display: flex; + align-items: center; + gap: clamp(4px, 1.5cqi, 6px); + font-size: clamp(10px, 2.8cqi, 12px); + font-weight: 600; + flex-shrink: 0; + min-width: 0; +} + +.nowPlayingTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nowPlayingCount { + flex-shrink: 0; + margin-inline-start: auto; +} + +.nowPlayingList { + display: flex; + flex-direction: column; + gap: clamp(2px, 0.8cqi, 4px); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.nowPlayingItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(4px, 1.5cqi, 6px); + padding: clamp(3px, 1.2cqi, 6px) clamp(4px, 1.5cqi, 8px); + border-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + min-width: 0; + flex-shrink: 0; +} + +.trackInfo { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.trackTitle { + font-size: clamp(9px, 2.6cqi, 12px); + font-weight: 500; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trackArtist { + font-size: clamp(8px, 2.2cqi, 10px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trackUser { + flex-shrink: 0; + max-width: clamp(40px, 22cqi, 88px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emptyState { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + color: var(--mantine-color-dimmed); + font-size: clamp(11px, 3cqi, 13px); + text-align: center; +} diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx new file mode 100644 index 000000000..ff2b56863 --- /dev/null +++ b/packages/widgets/src/audio-stats/component.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { clientApi } from "@homarr/api/client"; + +import type { WidgetComponentProps } from "../definition"; +import { AudioStatsContent } from "./audio-stats-content"; + +export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) { + const [response] = clientApi.widget.audioStats.getStats.useSuspenseQuery({ + integrationId: integrationIds[0] ?? "", + }); + + return ; +} diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts new file mode 100644 index 000000000..25af0bdaf --- /dev/null +++ b/packages/widgets/src/audio-stats/index.ts @@ -0,0 +1,62 @@ +import { IconHeadphones, IconServerOff } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +const hideUnlessNavidrome = { + shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("navidrome"), +}; + +const hideUnlessAudiobookshelf = { + shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("audiobookshelf"), +}; + +export const { definition, componentLoader } = createWidgetDefinition("audioStats", { + icon: IconHeadphones, + supportedIntegrations: ["navidrome", "audiobookshelf"], + integrationsRequired: true, + maxIntegrations: 1, + createOptions() { + return optionsBuilder.from( + (factory) => ({ + showArtists: factory.switch({ defaultValue: true, withDescription: true }), + showAlbums: factory.switch({ defaultValue: true, withDescription: true }), + showSongs: factory.switch({ defaultValue: true, withDescription: true }), + showNowPlaying: factory.switch({ defaultValue: true, withDescription: true }), + showNowPlayingList: factory.switch({ defaultValue: true, withDescription: true }), + maxNowPlayingItems: factory.number({ + defaultValue: 3, + validate: z.number().min(1).max(5), + step: 1, + withDescription: true, + }), + showLibraryCount: factory.switch({ defaultValue: true, withDescription: true }), + showAudiobooks: factory.switch({ defaultValue: true, withDescription: true }), + showPodcasts: factory.switch({ defaultValue: true, withDescription: true }), + showListeningTime: factory.switch({ defaultValue: true, withDescription: true }), + showActiveSessions: factory.switch({ defaultValue: true, withDescription: true }), + compactMode: factory.switch({ defaultValue: false, withDescription: true }), + }), + { + showArtists: hideUnlessNavidrome, + showAlbums: hideUnlessNavidrome, + showSongs: hideUnlessNavidrome, + showNowPlaying: hideUnlessNavidrome, + showNowPlayingList: hideUnlessNavidrome, + maxNowPlayingItems: hideUnlessNavidrome, + showLibraryCount: hideUnlessAudiobookshelf, + showAudiobooks: hideUnlessAudiobookshelf, + showPodcasts: hideUnlessAudiobookshelf, + showListeningTime: hideUnlessAudiobookshelf, + showActiveSessions: hideUnlessAudiobookshelf, + }, + ); + }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.audioStats.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts new file mode 100644 index 000000000..39fc9b0b6 --- /dev/null +++ b/packages/widgets/src/audio-stats/shared.ts @@ -0,0 +1,169 @@ +import type { TablerIcon } from "@tabler/icons-react"; +import { + IconAlbum, + IconBook, + IconBooks, + IconDisc, + IconHeadphones, + IconMicrophone, + IconMicrophone2, + IconPlayerPlay, +} from "@tabler/icons-react"; + +import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; +import { formatDuration } from "@homarr/common"; + +export type AudioStatsBackend = "navidrome" | "audiobookshelf"; + +export interface NavidromeDisplayOptions { + showArtists: boolean; + showAlbums: boolean; + showSongs: boolean; + showNowPlaying: boolean; + showNowPlayingList: boolean; + maxNowPlayingItems: number; + compactMode?: boolean; +} + +export interface AudiobookshelfDisplayOptions { + showLibraryCount: boolean; + showAudiobooks: boolean; + showPodcasts: boolean; + showListeningTime: boolean; + showActiveSessions: boolean; + compactMode: boolean; +} + +export type AudioStatsDisplayOptions = Partial; + +const navidromeStatVisibilityOptions = { + showArtists: "artists", + showAlbums: "albums", + showSongs: "songs", +} as const; + +const audiobookshelfStatVisibilityOptions = { + showLibraryCount: "libraryCount", + showAudiobooks: "audiobooks", + showPodcasts: "podcasts", + showListeningTime: "listeningTime", + showActiveSessions: "activeSessions", +} as const; + +const statVisibilityOptionsByBackend = { + navidrome: navidromeStatVisibilityOptions, + audiobookshelf: audiobookshelfStatVisibilityOptions, +} as const; + +const navidromeStatIcons = { + artists: IconMicrophone2, + albums: IconAlbum, + songs: IconDisc, +} as const; + +const audiobookshelfStatIcons = { + libraryCount: IconBooks, + audiobooks: IconBook, + podcasts: IconMicrophone, + listeningTime: IconHeadphones, + activeSessions: IconPlayerPlay, +} as const; + +const statIconsByBackend = { + navidrome: navidromeStatIcons, + audiobookshelf: audiobookshelfStatIcons, +} as const; + +const gridColsByCompact = { + true: [ + { minWidth: 280, cols: 5 }, + { minWidth: 180, cols: 3 }, + { minWidth: 110, cols: 2 }, + { minWidth: 0, cols: 1 }, + ], + false: [ + { minWidth: 420, cols: 5 }, + { minWidth: 300, cols: 3 }, + { minWidth: 180, cols: 2 }, + { minWidth: 0, cols: 1 }, + ], +} as const; + +const iconSizeByCompact = { + true: [ + { minWidth: 260, size: 18 }, + { minWidth: 160, size: 16 }, + { minWidth: 0, size: 14 }, + ], + false: [ + { minWidth: 320, size: 22 }, + { minWidth: 200, size: 18 }, + { minWidth: 0, size: 16 }, + ], +} as const; + +export interface VisibleStat { + optionKey: string; + statKey: string; + value: string | number; + Icon: TablerIcon; +} + +const getNavidromeStatValues = (stats: NavidromeDashboardData) => + ({ + artists: stats.artistCount, + albums: stats.albumCount, + songs: stats.songCount, + }) as const; + +const getAudiobookshelfStatValues = (stats: AudiobookshelfDashboardData) => + ({ + libraryCount: stats.libraryCount, + audiobooks: stats.totalAudiobooks, + podcasts: stats.totalPodcasts, + listeningTime: formatDuration(stats.totalListeningTimeSeconds * 1000), + activeSessions: stats.activeSessions, + }) as const; + +const statValueGetterByBackend = { + navidrome: (stats: NavidromeDashboardData | AudiobookshelfDashboardData) => + getNavidromeStatValues(stats as NavidromeDashboardData), + audiobookshelf: (stats: NavidromeDashboardData | AudiobookshelfDashboardData) => + getAudiobookshelfStatValues(stats as AudiobookshelfDashboardData), +} as const; + +export const getVisibleStats = ( + backend: AudioStatsBackend, + options: AudioStatsDisplayOptions, + stats: NavidromeDashboardData | AudiobookshelfDashboardData, +): VisibleStat[] => { + const visibilityOptions = statVisibilityOptionsByBackend[backend]; + const statIcons = statIconsByBackend[backend]; + const statValues = statValueGetterByBackend[backend](stats); + + return Object.entries(visibilityOptions) + .filter(([optionKey]) => Boolean(options[optionKey as keyof typeof visibilityOptions])) + .map(([optionKey, statKey]) => ({ + optionKey, + statKey, + value: statValues[statKey as keyof typeof statValues], + Icon: statIcons[statKey as keyof typeof statIcons], + })); +}; + +export const getGridCols = (width: number, visibleCount: number, compact: boolean): number => { + const colsTable = gridColsByCompact[String(compact) as keyof typeof gridColsByCompact]; + const match = colsTable.find(({ minWidth }) => width >= minWidth); + const maxCols = match?.cols ?? 1; + return Math.min(maxCols, Math.max(visibleCount, 1)); +}; + +const fallbackIconSize = { true: 14, false: 16 } as const; + +export const getIconSize = (width: number, compact: boolean): number => { + const sizeTable = iconSizeByCompact[String(compact) as keyof typeof iconSizeByCompact]; + const match = sizeTable.find(({ minWidth }) => width >= minWidth); + return match?.size ?? fallbackIconSize[String(compact) as keyof typeof fallbackIconSize]; +}; + +export const AUDIO_STATS_TRANSLATION_SCOPE = "widget.audioStats" as const; diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 6a6d026fb..4c65d231e 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -42,6 +42,7 @@ export interface WidgetDefinition { icon: TablerIcon; supportedIntegrations?: IntegrationKind[]; integrationsRequired?: boolean; + maxIntegrations?: number; createOptions: ( settings: Pick, ) => WidgetOptionsRecord; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 59d5f4cfc..460cea320 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -8,6 +8,7 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { SettingsContextProps } from "@homarr/settings/creator"; import * as anchorNote from "./anchor-note"; +import * as audioStats from "./audio-stats"; import * as app from "./app"; import * as bookmarks from "./bookmarks"; import * as calendar from "./calendar"; @@ -34,6 +35,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 paperlessNgx from "./paperless-ngx"; import * as notifications from "./notifications"; import type { WidgetOptionDefinition } from "./options"; import * as releases from "./releases"; @@ -41,6 +43,7 @@ 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"; @@ -95,8 +98,11 @@ export const widgetImports = { timetable, "immich-serverStats": immichServerStats, "immich-albumCarousel": immichAlbumCarousel, + paperlessNgx, tracearr, speedtestTracker, + uptimeKuma, + audioStats, umami, ups, beszelSystemTable, diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 1c2f39700..be0d3a178 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -117,6 +117,10 @@ export const WidgetEditModal = createModal>(({ actions, i + 1 + } {...form.getInputProps("integrationIds")} /> )} diff --git a/packages/widgets/src/paperless-ngx/component.module.css b/packages/widgets/src/paperless-ngx/component.module.css new file mode 100644 index 000000000..3d1b9d215 --- /dev/null +++ b/packages/widgets/src/paperless-ngx/component.module.css @@ -0,0 +1,137 @@ +.root { + container-type: size; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: clamp(6px, 3cqi, 14px); + gap: clamp(4px, 2cqi, 10px); + overflow: hidden; +} + +.hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(8px, 3cqi, 18px); + flex-shrink: 0; + padding-bottom: clamp(4px, 2cqi, 10px); + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); +} + +.heroExpanded { + flex: 1; + border-bottom: none; + justify-content: center; + padding-bottom: 0; +} + +.heroTextOnly { + justify-content: center; +} + +.heroTextOnly .heroText { + align-items: center; + text-align: center; +} + +.heroTextOnly .heroInbox { + font-size: clamp(18px, 8cqi, 32px); +} + +.heroText { + display: flex; + flex-direction: column; + gap: clamp(2px, 1cqi, 4px); + min-width: 0; + flex: 1; +} + +.heroLabel { + font-size: clamp(9px, 2.8cqi, 13px); + color: var(--mantine-color-dimmed); + line-height: 1.2; +} + +.heroCounts { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: clamp(4px, 1.5cqi, 6px); +} + +.heroInbox { + font-size: clamp(16px, 6cqi, 24px); + font-weight: 700; + color: var(--mantine-color-orange-6); + line-height: 1.1; +} + +.heroTotal { + font-size: clamp(11px, 3.5cqi, 15px); + color: var(--mantine-color-dimmed); + line-height: 1.2; +} + +.ring { + flex-shrink: 0; +} + +.grid { + display: grid; + flex: 1; + min-height: 0; + gap: clamp(4px, 2cqi, 10px); + grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-content: stretch; +} + +.statTile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; + gap: clamp(2px, 1.5cqi, 5px); + padding: clamp(4px, 2.5cqi, 10px); + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + overflow: hidden; +} + +.statIcon { + color: var(--mantine-color-dimmed); + flex-shrink: 0; +} + +.statLabel { + font-size: clamp(8px, 2.5cqi, 12px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statValue { + font-size: clamp(14px, 5.5cqi, 24px); + font-weight: 700; + color: var(--mantine-primary-color-filled); + line-height: 1.1; + text-align: center; +} + +.emptyState { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + color: var(--mantine-color-dimmed); + font-size: clamp(11px, 3cqi, 14px); + text-align: center; +} diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx new file mode 100644 index 000000000..406c50605 --- /dev/null +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { Fragment } from "react"; +import type { CSSProperties } from "react"; +import { RingProgress, Text } from "@mantine/core"; +import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.css"; + +const statVisibilityByOption = { + showDocumentsTotal: "documentsTotal", + showDocumentsInbox: "documentsInbox", + showCorrespondents: "correspondents", + showTags: "tags", + showDocumentTypes: "documentTypes", +} as const; + +const statIcons = { + documentsTotal: IconFileText, + documentsInbox: IconInbox, + correspondents: IconUsers, + tags: IconTag, + documentTypes: IconFileDescription, +} as const; + +const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]); + +const gridColsByWidth = [ + { minWidth: 380, cols: 3 }, + { minWidth: 220, cols: 2 }, + { minWidth: 0, cols: 1 }, +] as const; + +const ringSizeByWidth = [ + { minWidth: 400, size: 88 }, + { minWidth: 320, size: 72 }, + { minWidth: 220, size: 56 }, + { minWidth: 0, size: 44 }, +] as const; + +const iconSizeByWidth = [ + { minWidth: 320, size: 22 }, + { minWidth: 200, size: 18 }, + { minWidth: 0, size: 16 }, +] as const; + +const ringLabelSizeByRingSize = [ + { minSize: 72, size: "sm" }, + { minSize: 56, size: "xs" }, + { minSize: 0, size: "xs" }, +] as const; + +const heroLayoutBySecondaryStats = { + false: classes.heroExpanded, + true: "", +} as const; + +const heroVariantByRing = { + true: "", + false: classes.heroTextOnly, +} as const; + +const heroPartVisibility = { + ratio: "showInboxRatio", + ring: "showInboxRing", +} as const; + +export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) { + const t = useScopedI18n("widget.paperlessNgx"); + const [stats] = clientApi.widget.paperlessNgx.getStats.useSuspenseQuery({ + integrationId: integrationIds[0] ?? "", + }); + + const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal; + const showHero = options.showInboxRatio && canShowInboxHero; + const inboxProgress = computeInboxProgress(stats.documentsTotal, stats.documentsInbox); + + const statValues = { + documentsTotal: stats.documentsTotal, + documentsInbox: stats.documentsInbox, + correspondents: stats.correspondentsCount, + tags: stats.tagsCount, + documentTypes: stats.documentTypesCount, + } as const; + + const visibleStatKeys = Object.entries(statVisibilityByOption) + .filter(([optionKey]) => options[optionKey as keyof typeof options]) + .map(([, statKey]) => statKey) + .filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey))); + + const gridCols = getGridCols(width); + const ringSize = getRingSize(width); + const iconSize = getIconSize(width); + const ringLabelSize = getRingLabelSize(ringSize); + const hasContent = showHero || visibleStatKeys.length > 0; + + const heroLayoutClass = + heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; + const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing]; + + const visibleHeroParts = Object.entries(heroPartVisibility).filter( + ([, optionKey]) => options[optionKey as keyof typeof options], + ); + + const heroPartRenderers = { + ratio: ( +
+ {t("inboxRatio")} +
+ + {stats.documentsInbox} {t("inbox")} + + + / {stats.documentsTotal} {t("total")} + +
+
+ ), + ring: ( + + {inboxProgress}% + + } + /> + ), + } as const; + + return ( +
+ {showHero && ( +
+ {visibleHeroParts.map(([partKey]) => ( + {heroPartRenderers[partKey as keyof typeof heroPartRenderers]} + ))} +
+ )} + + {!hasContent && ( +
+ + โ€” + +
+ )} + + {visibleStatKeys.length > 0 && ( +
+ {visibleStatKeys.map((statKey) => { + const Icon = statIcons[statKey]; + return ( +
+ + {statValues[statKey]} + {t(statKey)} +
+ ); + })} +
+ )} +
+ ); +} + +function computeInboxProgress(total: number, inbox: number): number { + if (total <= 0) { + return 0; + } + + return Math.round((inbox / total) * 100); +} + +function getGridCols(width: number): number { + const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); + return match?.cols ?? 1; +} + +function getRingSize(width: number): number { + const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth); + return match?.size ?? 44; +} + +function getIconSize(width: number): number { + const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth); + return match?.size ?? 16; +} + +function getRingLabelSize(ringSize: number): "xs" | "sm" { + const match = ringLabelSizeByRingSize.find(({ minSize }) => ringSize >= minSize); + return match?.size ?? "xs"; +} diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts new file mode 100644 index 000000000..59af7ca59 --- /dev/null +++ b/packages/widgets/src/paperless-ngx/index.ts @@ -0,0 +1,48 @@ +import { IconFileText, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", { + icon: IconFileText, + supportedIntegrations: ["paperlessNgx"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + showInboxRatio: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showInboxRing: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showDocumentsTotal: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showDocumentsInbox: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showCorrespondents: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showTags: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showDocumentTypes: factory.switch({ + defaultValue: true, + withDescription: true, + }), + })); + }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.paperlessNgx.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/uptime-kuma/component.module.css b/packages/widgets/src/uptime-kuma/component.module.css new file mode 100644 index 000000000..dd8b8e19e --- /dev/null +++ b/packages/widgets/src/uptime-kuma/component.module.css @@ -0,0 +1,121 @@ +.root { + container-type: size; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: clamp(6px, 3cqi, 14px); + gap: clamp(4px, 2cqi, 10px); + overflow: hidden; +} + +.hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(8px, 3cqi, 18px); + flex-shrink: 0; + padding-bottom: clamp(4px, 2cqi, 10px); + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); +} + +.heroExpanded { + flex: 1; + border-bottom: none; + justify-content: center; + padding-bottom: 0; +} + +.heroTextOnly { + justify-content: center; +} + +.heroTextOnly .heroText { + align-items: center; + text-align: center; +} + +.heroTextOnly .heroValue { + font-size: clamp(22px, 10cqi, 36px); +} + +.heroText { + display: flex; + flex-direction: column; + gap: clamp(2px, 1cqi, 4px); + min-width: 0; + flex: 1; +} + +.heroLabel { + font-size: clamp(9px, 2.8cqi, 13px); + color: var(--mantine-color-dimmed); + line-height: 1.2; +} + +.heroValue { + font-size: clamp(18px, 8cqi, 32px); + font-weight: 700; + line-height: 1.1; +} + +.ring { + flex-shrink: 0; +} + +.statGrid { + display: grid; + flex: 1; + min-height: 0; + gap: clamp(4px, 2cqi, 10px); + grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-content: stretch; +} + +.statTile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; + gap: clamp(2px, 1.5cqi, 5px); + padding: clamp(4px, 2.5cqi, 10px); + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + overflow: hidden; +} + +.statIcon { + flex-shrink: 0; +} + +.statValue { + font-size: clamp(14px, 5.5cqi, 24px); + font-weight: 700; + line-height: 1.1; + text-align: center; +} + +.statLabel { + font-size: clamp(8px, 2.5cqi, 12px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emptyState { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + color: var(--mantine-color-dimmed); + font-size: clamp(11px, 3cqi, 14px); + text-align: center; +} diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx new file mode 100644 index 000000000..4d484138a --- /dev/null +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -0,0 +1,245 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { RingProgress, Text } from "@mantine/core"; +import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { formatNumber } from "@homarr/common"; +import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationDataError } from "../errors/no-data-integration"; +import classes from "./component.module.css"; + +const emptyDashboard: UptimeKumaDashboardData = { + totalMonitors: 0, + upCount: 0, + downCount: 0, + pausedCount: 0, + averageUptimePercent: 0, + monitors: [], +}; + +const statVisibilityByOption = { + showTotalMonitors: "totalMonitors", + showUpCount: "upCount", + showDownCount: "downCount", + showPausedCount: "pausedCount", +} as const; + +const statIcons = { + totalMonitors: IconServer, + upCount: IconArrowUp, + downCount: IconArrowDown, + pausedCount: IconClockPause, +} as const; + +const statColorByKey = { + totalMonitors: "gray", + upCount: "green", + downCount: "red", + pausedCount: "yellow", +} as const; + +const uptimeTierByThreshold = [ + [99, "excellent"], + [95, "good"], + [0, "poor"], +] as const; + +const uptimeColorByTier = { + excellent: "green", + good: "yellow", + poor: "red", +} as const; + +const gridColsByWidth = [ + { minWidth: 380, cols: 4 }, + { minWidth: 260, cols: 2 }, + { minWidth: 0, cols: 2 }, +] as const; + +const ringSizeByWidth = [ + { minWidth: 400, size: 88 }, + { minWidth: 320, size: 72 }, + { minWidth: 220, size: 56 }, + { minWidth: 0, size: 44 }, +] as const; + +const iconSizeByWidth = [ + { minWidth: 320, size: 22 }, + { minWidth: 200, size: 18 }, + { minWidth: 0, size: 16 }, +] as const; + +const heroLayoutBySecondaryStats = { + false: classes.heroExpanded, + true: "", +} as const; + +const heroVariantByRing = { + true: "", + false: classes.heroTextOnly, +} as const; + +export default function UptimeKumaWidget({ + integrationIds, + isEditMode, + options, + width, +}: WidgetComponentProps<"uptimeKuma">) { + if (integrationIds.length === 0) { + throw new NoIntegrationDataError(); + } + + return ; +} + +interface UptimeKumaContentProps { + integrationIds: string[]; + isEditMode: boolean; + options: WidgetComponentProps<"uptimeKuma">["options"]; + width: number; +} + +function UptimeKumaContent({ integrationIds, isEditMode, options, width }: UptimeKumaContentProps) { + const t = useScopedI18n("widget.uptimeKuma"); + const [dashboardData] = clientApi.widget.uptimeKuma.getDashboard.useSuspenseQuery({ integrationIds }); + + const utils = clientApi.useUtils(); + clientApi.widget.uptimeKuma.subscribeToDashboard.useSubscription( + { integrationIds }, + { + enabled: !isEditMode, + onData(newData) { + utils.widget.uptimeKuma.getDashboard.setData({ integrationIds }, (prevData) => { + if (!prevData) return prevData; + return prevData.map((instance) => { + if (instance.integrationId === newData.integrationId) { + return { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp }; + } + return instance; + }); + }); + }, + }, + ); + + const combined = dashboardData.reduce( + (acc, item) => ({ + totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors, + upCount: acc.upCount + item.dashboard.upCount, + downCount: acc.downCount + item.dashboard.downCount, + pausedCount: acc.pausedCount + item.dashboard.pausedCount, + averageUptimePercent: acc.averageUptimePercent + item.dashboard.averageUptimePercent, + monitors: [...acc.monitors, ...item.dashboard.monitors], + }), + emptyDashboard, + ); + + const instanceCount = dashboardData.length; + const averageUptime = combined.averageUptimePercent / Math.max(instanceCount, 1); + const uptimeValue = clampPercent(averageUptime); + const uptimeColor = getUptimeColor(uptimeValue); + const ringSize = getRingSize(width); + const iconSize = getIconSize(width); + const gridCols = getGridCols(width); + + const visibleStatKeys = Object.entries(statVisibilityByOption) + .filter(([optionKey]) => options[optionKey as keyof typeof options]) + .map(([, statKey]) => statKey); + + const showHero = options.showAverageUptime; + const hasContent = showHero || visibleStatKeys.length > 0; + + const heroLayoutClass = + heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; + const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing]; + + return ( +
+ {showHero && ( +
+
+ {t("averageUptime")} + {formatNumber(uptimeValue, 1)}% +
+ {options.showUptimeRing && ( + + {formatNumber(uptimeValue, 0)}% + + } + /> + )} +
+ )} + + {!hasContent && ( +
+ + โ€” + +
+ )} + + {visibleStatKeys.length > 0 && ( +
+ {visibleStatKeys.map((statKey) => { + const Icon = statIcons[statKey]; + const color = statColorByKey[statKey]; + return ( +
+ + {formatNumber(combined[statKey], 0)} + {t(`stats.${statKey}`)} +
+ ); + })} +
+ )} +
+ ); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function getUptimeTier(uptime: number): keyof typeof uptimeColorByTier { + const match = uptimeTierByThreshold.find(([min]) => uptime >= min); + const tier = match?.[1] ?? "poor"; + return tier; +} + +function getUptimeColor(uptime: number): string { + return uptimeColorByTier[getUptimeTier(uptime)]; +} + +function getGridCols(width: number): number { + const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); + return match?.cols ?? 2; +} + +function getRingSize(width: number): number { + const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth); + return match?.size ?? 44; +} + +function getIconSize(width: number): number { + const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth); + return match?.size ?? 16; +} diff --git a/packages/widgets/src/uptime-kuma/index.ts b/packages/widgets/src/uptime-kuma/index.ts new file mode 100644 index 000000000..b9eebcd8c --- /dev/null +++ b/packages/widgets/src/uptime-kuma/index.ts @@ -0,0 +1,43 @@ +import { IconHeartbeat, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("uptimeKuma", { + icon: IconHeartbeat, + createOptions() { + return optionsBuilder.from((factory) => ({ + showAverageUptime: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showUptimeRing: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showTotalMonitors: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showUpCount: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showDownCount: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showPausedCount: factory.switch({ + defaultValue: true, + withDescription: true, + }), + })); + }, + supportedIntegrations: ["uptimeKuma"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.uptimeKuma.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/widget-integration-select.tsx b/packages/widgets/src/widget-integration-select.tsx index 8e41e8bec..864b2937b 100644 --- a/packages/widgets/src/widget-integration-select.tsx +++ b/packages/widgets/src/widget-integration-select.tsx @@ -51,11 +51,14 @@ export const WidgetIntegrationSelect = ({ }); const handleValueSelect = (selectedValue: string) => { - onChange( - multiSelectValues.includes(selectedValue) - ? multiSelectValues.filter((value) => value !== selectedValue) - : [...multiSelectValues, selectedValue], - ); + if (multiSelectValues.includes(selectedValue)) { + onChange(multiSelectValues.filter((value) => value !== selectedValue)); + } else if (canSelectMultiple) { + onChange([...multiSelectValues, selectedValue]); + } else { + onChange([selectedValue]); + } + if (!canSelectMultiple) { combobox.closeDropdown(); }