Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db64ded3f0 | ||
|
|
fe35a9cf68 | ||
|
|
ad76ce9cdc | ||
|
|
ced96a57b3 | ||
|
|
302a9f4e54 | ||
|
|
69ed608241 | ||
|
|
9da8ea1dec | ||
|
|
2b4a18385d | ||
|
|
c9f003dadd |
No files matched your search
+2
@@ -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]() };
|
||||
}),
|
||||
});
|
||||
@@ -6,6 +6,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)),
|
||||
@@ -30,6 +31,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"],
|
||||
|
||||
@@ -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"]],
|
||||
@@ -452,6 +486,7 @@ export const integrationDefs = {
|
||||
"notifications",
|
||||
"smartHomeServer",
|
||||
"ups",
|
||||
"uptimeMonitoring",
|
||||
],
|
||||
documentationUrl: null,
|
||||
},
|
||||
@@ -540,6 +575,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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -35,9 +35,18 @@ export const widgetKinds = [
|
||||
"timetable",
|
||||
"immich-serverStats",
|
||||
"immich-albumCarousel",
|
||||
"paperlessNgx",
|
||||
"tracearr",
|
||||
"speedtestTracker",
|
||||
"uptimeKuma",
|
||||
"audioStats",
|
||||
"umami",
|
||||
"ups",
|
||||
] 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>;
|
||||
@@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-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";
|
||||
@@ -30,12 +31,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";
|
||||
@@ -48,6 +51,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";
|
||||
@@ -122,8 +126,12 @@ export const integrationCreators = {
|
||||
glances: GlancesIntegration,
|
||||
searchCh: SearchChIntegration,
|
||||
immich: ImmichIntegration,
|
||||
paperlessNgx: PaperlessNgxIntegration,
|
||||
speedtestTracker: SpeedtestTrackerIntegration,
|
||||
audiobookshelf: AudiobookshelfIntegration,
|
||||
navidrome: NavidromeIntegration,
|
||||
umami: UmamiIntegration,
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,4 +17,8 @@ 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";
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -808,10 +808,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": {
|
||||
@@ -1084,6 +1087,10 @@
|
||||
"privateKey": {
|
||||
"label": "Private key",
|
||||
"newLabel": "New private key"
|
||||
},
|
||||
"slug": {
|
||||
"label": "Status page slug",
|
||||
"newLabel": "New status page slug"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1091,6 +1098,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": {
|
||||
@@ -2429,6 +2460,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",
|
||||
@@ -3037,6 +3113,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} />;
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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;
|
||||
@@ -42,6 +42,7 @@ export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
maxIntegrations?: number;
|
||||
createOptions: (
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
) => WidgetOptionsRecord;
|
||||
|
||||
@@ -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";
|
||||
@@ -91,8 +94,11 @@ export const widgetImports = {
|
||||
timetable,
|
||||
"immich-serverStats": immichServerStats,
|
||||
"immich-albumCarousel": immichAlbumCarousel,
|
||||
paperlessNgx,
|
||||
tracearr,
|
||||
speedtestTracker,
|
||||
uptimeKuma,
|
||||
audioStats,
|
||||
umami,
|
||||
ups,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user