feat: add Uptime Kuma, Audiobookshelf, Paperless-ngx, and Navidrome integrations (#5910)

This commit is contained in:
Thomas authored and GitHub committed 2026-06-10 18:27:24 +02:00
1 parent a4db9cf0dc
commit d77c6f8d4e
46 files changed
+2994 -16

No files matched your search

@@ -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<IntegrationSecretKind, TablerIcon>;
@@ -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,
@@ -57,6 +57,9 @@ export const categoryTranslationKeys: Record<string, string> = {
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 {
@@ -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 <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
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 ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
@@ -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 <TourTarget id={tourId}>{link}</TourTarget>;
};
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 = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => (
@@ -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]() };
}),
});
+3
View File
@@ -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)),
});
@@ -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;
}),
});
@@ -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());
};
});
}),
});
@@ -27,6 +27,10 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
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"],
+38
View File
@@ -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<string, { isPublic: boolean; multiline: boolean }>;
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];
@@ -33,8 +33,11 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
timetable: ["searchCh"],
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
paperlessNgx: ["paperlessNgx"],
tracearr: ["tracearr"],
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
anchorNote: ["anchor"],
};
+9
View File
@@ -35,8 +35,11 @@ export const widgetKinds = [
"timetable",
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"tracearr",
"speedtestTracker",
"uptimeKuma",
"audioStats",
"umami",
"ups",
"beszelSystemTable",
@@ -45,3 +48,9 @@ export const widgetKinds = [
"beszelSystemStats",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
};
@@ -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<AudiobookshelfDashboardData, "totalAudiobooks" | "totalPodcasts">
>;
export class AudiobookshelfIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
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<AudiobookshelfDashboardData> {
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<AudiobookshelfLibrary[]> {
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<AudiobookshelfLibraryStats> {
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<number> {
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<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
Accept: "application/json",
};
}
}
@@ -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<typeof audiobookshelfLibrarySchema>;
export type AudiobookshelfLibraryStats = z.infer<typeof audiobookshelfLibraryStatsSchema>;
@@ -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<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
+7
View File
@@ -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
@@ -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 = <TValue>(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<TestingResult> {
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<NavidromeDashboardData> {
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<number> {
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<NavidromeNowPlayingEntry[]> {
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<string, string | number> = {},
options: { tolerateEmptyLibrary?: boolean } = {},
): Promise<SubsonicResponseBody> {
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<string, string> {
return {
u: this.getSecretValue("username"),
p: this.getSecretValue("password"),
v: subsonicVersion,
c: subsonicClient,
f: "json",
};
}
}
@@ -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<typeof subsonicResponseSchema>["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[];
}
@@ -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 = <T extends Record<string, unknown>>(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<typeof fetchWithTrustedCertificatesAsync>;
}
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<typeof fetchWithTrustedCertificatesAsync>;
}
if (offset === 500) {
return Promise.resolve(
new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
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<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { 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.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<typeof fetchWithTrustedCertificatesAsync>;
}
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",
});
});
});
@@ -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<TestingResult> {
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<PaperlessNgxStats> {
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<string, string> {
return {
Authorization: `Token ${this.getSecretValue("apiKey")}`,
};
}
}
@@ -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;
}
+4
View File
@@ -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";
@@ -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<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(
new Response(JSON.stringify(statusPageResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
});
};
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<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(
new Response(
JSON.stringify({
publicGroupList: [{ id: 1, name: "G", monitorList: [{ id: 10, name: "Svc" }] }],
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
});
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<typeof fetchWithTrustedCertificatesAsync>,
);
const integration = createIntegration([{ kind: "slug", value: "default" }]);
const result = await integration.getDashboardDataAsync();
expect(result.totalMonitors).toBe(0);
expect(result.averageUptimePercent).toBe(0);
});
});
@@ -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<TestingResult> {
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<UptimeKumaDashboardData> {
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<string, { status: number }[]>; uptimeList: Record<string, number> },
): 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";
}
}
@@ -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<number, UptimeKumaMonitorCategory> = {
[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<typeof uptimeKumaStatusPageResponseSchema>;
export type UptimeKumaHeartbeatResponse = z.infer<typeof uptimeKumaHeartbeatResponseSchema>;
@@ -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<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "audiobookshelfDashboard",
});
+19
View File
@@ -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<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "navidromeDashboard",
});
@@ -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<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
cacheDuration: dayjs.duration(15, "minute"),
queryKey: "paperless-ngx-stats",
});
@@ -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<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "uptimeKumaDashboard",
});
+184 -1
View File
@@ -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",
@@ -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<AudioStatsBackend, NavidromeDashboardData | null> = {
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 (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
{visibleStats.length > 0 && (
<div
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
style={{ "--stat-cols": gridCols } as CSSProperties}
>
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={classes.statValue}>{value}</span>
<span className={classes.statLabel}>{t(statKey as never)}</span>
</div>
))}
</div>
)}
{showNowPlayingSection && navidromeStats && (
<div className={classes.nowPlaying}>
<div className={classes.nowPlayingHeader}>
<IconHeadphones size={iconSize} stroke={1.5} />
<span className={classes.nowPlayingTitle}>{t("nowPlaying" as never)}</span>
{!options.showNowPlayingList && (
<Badge className={classes.nowPlayingCount} variant="light" size="sm">
{(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", {
count: navidromeStats.nowPlaying.length,
})}
</Badge>
)}
</div>
{options.showNowPlayingList && (
<div className={classes.nowPlayingList}>
{nowPlayingTracks.map((track, index) => (
<div key={`${track.username}-${track.title}-${index}`} className={classes.nowPlayingItem}>
<div className={classes.trackInfo}>
<span className={classes.trackTitle}>{track.title}</span>
<span className={classes.trackArtist}>
{[track.artist, track.album].filter(Boolean).join(" · ")}
</span>
</div>
<Badge className={classes.trackUser} variant="outline" size="xs">
{track.username || track.playerName}
</Badge>
</div>
))}
</div>
)}
</div>
)}
{!hasContent && (
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
)}
</div>
);
}
@@ -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");
});
});
@@ -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;
}
@@ -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 <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
}
+62
View File
@@ -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"));
+169
View File
@@ -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<NavidromeDisplayOptions & AudiobookshelfDisplayOptions>;
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;
+1
View File
@@ -42,6 +42,7 @@ export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
maxIntegrations?: number;
createOptions: (
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
) => WidgetOptionsRecord;
+6
View File
@@ -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,
@@ -117,6 +117,10 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={
((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) >
1
}
{...form.getInputProps("integrationIds")}
/>
)}
@@ -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;
}
@@ -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: (
<div className={classes.heroText}>
<span className={classes.heroLabel}>{t("inboxRatio")}</span>
<div className={classes.heroCounts}>
<span className={classes.heroInbox}>
{stats.documentsInbox} {t("inbox")}
</span>
<span className={classes.heroTotal}>
/ {stats.documentsTotal} {t("total")}
</span>
</div>
</div>
),
ring: (
<RingProgress
className={classes.ring}
size={ringSize}
thickness={Math.max(4, Math.round(ringSize / 10))}
roundCaps
sections={[{ value: inboxProgress, color: "orange" }]}
label={
<Text ta="center" size={ringLabelSize} fw={700}>
{inboxProgress}%
</Text>
}
/>
),
} as const;
return (
<div className={classes.root}>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
{visibleHeroParts.map(([partKey]) => (
<Fragment key={partKey}>{heroPartRenderers[partKey as keyof typeof heroPartRenderers]}</Fragment>
))}
</div>
)}
{!hasContent && (
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
)}
{visibleStatKeys.length > 0 && (
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
return (
<div key={statKey} className={classes.statTile}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={classes.statValue}>{statValues[statKey]}</span>
<span className={classes.statLabel}>{t(statKey)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
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";
}
@@ -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"));
@@ -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;
}
@@ -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 <UptimeKumaContent integrationIds={integrationIds} isEditMode={isEditMode} options={options} width={width} />;
}
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<UptimeKumaDashboardData>(
(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 (
<div className={classes.root}>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
<div className={classes.heroText}>
<span className={classes.heroLabel}>{t("averageUptime")}</span>
<span className={classes.heroValue}>{formatNumber(uptimeValue, 1)}%</span>
</div>
{options.showUptimeRing && (
<RingProgress
className={classes.ring}
size={ringSize}
thickness={Math.max(4, Math.round(ringSize / 10))}
roundCaps
sections={[{ value: uptimeValue, color: uptimeColor }]}
label={
<Text ta="center" size="xs" fw={700}>
{formatNumber(uptimeValue, 0)}%
</Text>
}
/>
)}
</div>
)}
{!hasContent && (
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
)}
{visibleStatKeys.length > 0 && (
<div className={classes.statGrid} style={{ "--stat-cols": gridCols } as CSSProperties}>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
const color = statColorByKey[statKey];
return (
<div key={statKey} className={classes.statTile}>
<Icon
className={classes.statIcon}
size={iconSize}
stroke={1.5}
color={`var(--mantine-color-${color}-6)`}
/>
<span className={classes.statValue}>{formatNumber(combined[statKey], 0)}</span>
<span className={classes.statLabel}>{t(`stats.${statKey}`)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
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;
}
+43
View File
@@ -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"));
@@ -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();
}