feat: add Uptime Kuma, Audiobookshelf, Paperless-ngx, and Navidrome integrations (#5910)
This commit is contained in:
1 parent
a4db9cf0dc
commit
d77c6f8d4e
46 files changed
+2994
-16
No files matched your search
@@ -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";
|
||||
@@ -95,8 +98,11 @@ export const widgetImports = {
|
||||
timetable,
|
||||
"immich-serverStats": immichServerStats,
|
||||
"immich-albumCarousel": immichAlbumCarousel,
|
||||
paperlessNgx,
|
||||
tracearr,
|
||||
speedtestTracker,
|
||||
uptimeKuma,
|
||||
audioStats,
|
||||
umami,
|
||||
ups,
|
||||
beszelSystemTable,
|
||||
|
||||
@@ -117,6 +117,10 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={
|
||||
((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) >
|
||||
1
|
||||
}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
.root {
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: clamp(6px, 3cqi, 14px);
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: clamp(8px, 3cqi, 18px);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.heroExpanded {
|
||||
flex: 1;
|
||||
border-bottom: none;
|
||||
justify-content: center;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.heroTextOnly {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.heroTextOnly .heroText {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroTextOnly .heroInbox {
|
||||
font-size: clamp(18px, 8cqi, 32px);
|
||||
}
|
||||
|
||||
.heroText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(2px, 1cqi, 4px);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heroLabel {
|
||||
font-size: clamp(9px, 2.8cqi, 13px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.heroCounts {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: clamp(4px, 1.5cqi, 6px);
|
||||
}
|
||||
|
||||
.heroInbox {
|
||||
font-size: clamp(16px, 6cqi, 24px);
|
||||
font-weight: 700;
|
||||
color: var(--mantine-color-orange-6);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.heroTotal {
|
||||
font-size: clamp(11px, 3.5cqi, 15px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ring {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 12px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(14px, 5.5cqi, 24px);
|
||||
font-weight: 700;
|
||||
color: var(--mantine-primary-color-filled);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: clamp(11px, 3cqi, 14px);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { RingProgress, Text } from "@mantine/core";
|
||||
import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
const statVisibilityByOption = {
|
||||
showDocumentsTotal: "documentsTotal",
|
||||
showDocumentsInbox: "documentsInbox",
|
||||
showCorrespondents: "correspondents",
|
||||
showTags: "tags",
|
||||
showDocumentTypes: "documentTypes",
|
||||
} as const;
|
||||
|
||||
const statIcons = {
|
||||
documentsTotal: IconFileText,
|
||||
documentsInbox: IconInbox,
|
||||
correspondents: IconUsers,
|
||||
tags: IconTag,
|
||||
documentTypes: IconFileDescription,
|
||||
} as const;
|
||||
|
||||
const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]);
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 380, cols: 3 },
|
||||
{ minWidth: 220, cols: 2 },
|
||||
{ minWidth: 0, cols: 1 },
|
||||
] as const;
|
||||
|
||||
const ringSizeByWidth = [
|
||||
{ minWidth: 400, size: 88 },
|
||||
{ minWidth: 320, size: 72 },
|
||||
{ minWidth: 220, size: 56 },
|
||||
{ minWidth: 0, size: 44 },
|
||||
] as const;
|
||||
|
||||
const iconSizeByWidth = [
|
||||
{ minWidth: 320, size: 22 },
|
||||
{ minWidth: 200, size: 18 },
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
const ringLabelSizeByRingSize = [
|
||||
{ minSize: 72, size: "sm" },
|
||||
{ minSize: 56, size: "xs" },
|
||||
{ minSize: 0, size: "xs" },
|
||||
] as const;
|
||||
|
||||
const heroLayoutBySecondaryStats = {
|
||||
false: classes.heroExpanded,
|
||||
true: "",
|
||||
} as const;
|
||||
|
||||
const heroVariantByRing = {
|
||||
true: "",
|
||||
false: classes.heroTextOnly,
|
||||
} as const;
|
||||
|
||||
const heroPartVisibility = {
|
||||
ratio: "showInboxRatio",
|
||||
ring: "showInboxRing",
|
||||
} as const;
|
||||
|
||||
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
|
||||
const t = useScopedI18n("widget.paperlessNgx");
|
||||
const [stats] = clientApi.widget.paperlessNgx.getStats.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
|
||||
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
|
||||
const showHero = options.showInboxRatio && canShowInboxHero;
|
||||
const inboxProgress = computeInboxProgress(stats.documentsTotal, stats.documentsInbox);
|
||||
|
||||
const statValues = {
|
||||
documentsTotal: stats.documentsTotal,
|
||||
documentsInbox: stats.documentsInbox,
|
||||
correspondents: stats.correspondentsCount,
|
||||
tags: stats.tagsCount,
|
||||
documentTypes: stats.documentTypesCount,
|
||||
} as const;
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey)
|
||||
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
|
||||
|
||||
const gridCols = getGridCols(width);
|
||||
const ringSize = getRingSize(width);
|
||||
const iconSize = getIconSize(width);
|
||||
const ringLabelSize = getRingLabelSize(ringSize);
|
||||
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||
|
||||
const heroLayoutClass =
|
||||
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
|
||||
const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
|
||||
|
||||
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
|
||||
([, optionKey]) => options[optionKey as keyof typeof options],
|
||||
);
|
||||
|
||||
const heroPartRenderers = {
|
||||
ratio: (
|
||||
<div className={classes.heroText}>
|
||||
<span className={classes.heroLabel}>{t("inboxRatio")}</span>
|
||||
<div className={classes.heroCounts}>
|
||||
<span className={classes.heroInbox}>
|
||||
{stats.documentsInbox} {t("inbox")}
|
||||
</span>
|
||||
<span className={classes.heroTotal}>
|
||||
/ {stats.documentsTotal} {t("total")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
ring: (
|
||||
<RingProgress
|
||||
className={classes.ring}
|
||||
size={ringSize}
|
||||
thickness={Math.max(4, Math.round(ringSize / 10))}
|
||||
roundCaps
|
||||
sections={[{ value: inboxProgress, color: "orange" }]}
|
||||
label={
|
||||
<Text ta="center" size={ringLabelSize} fw={700}>
|
||||
{inboxProgress}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
),
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{showHero && (
|
||||
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||
{visibleHeroParts.map(([partKey]) => (
|
||||
<Fragment key={partKey}>{heroPartRenderers[partKey as keyof typeof heroPartRenderers]}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasContent && (
|
||||
<div className={classes.emptyState}>
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleStatKeys.length > 0 && (
|
||||
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||
{visibleStatKeys.map((statKey) => {
|
||||
const Icon = statIcons[statKey];
|
||||
return (
|
||||
<div key={statKey} className={classes.statTile}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<span className={classes.statValue}>{statValues[statKey]}</span>
|
||||
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeInboxProgress(total: number, inbox: number): number {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((inbox / total) * 100);
|
||||
}
|
||||
|
||||
function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 1;
|
||||
}
|
||||
|
||||
function getRingSize(width: number): number {
|
||||
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.size ?? 44;
|
||||
}
|
||||
|
||||
function getIconSize(width: number): number {
|
||||
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.size ?? 16;
|
||||
}
|
||||
|
||||
function getRingLabelSize(ringSize: number): "xs" | "sm" {
|
||||
const match = ringLabelSizeByRingSize.find(({ minSize }) => ringSize >= minSize);
|
||||
return match?.size ?? "xs";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IconFileText, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
|
||||
icon: IconFileText,
|
||||
supportedIntegrations: ["paperlessNgx"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showInboxRatio: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showInboxRing: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showDocumentsTotal: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showDocumentsInbox: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showCorrespondents: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showTags: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showDocumentTypes: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.paperlessNgx.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,121 @@
|
||||
.root {
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: clamp(6px, 3cqi, 14px);
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: clamp(8px, 3cqi, 18px);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.heroExpanded {
|
||||
flex: 1;
|
||||
border-bottom: none;
|
||||
justify-content: center;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.heroTextOnly {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.heroTextOnly .heroText {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroTextOnly .heroValue {
|
||||
font-size: clamp(22px, 10cqi, 36px);
|
||||
}
|
||||
|
||||
.heroText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(2px, 1cqi, 4px);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heroLabel {
|
||||
font-size: clamp(9px, 2.8cqi, 13px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.heroValue {
|
||||
font-size: clamp(18px, 8cqi, 32px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ring {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statGrid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(14px, 5.5cqi, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 12px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: clamp(11px, 3cqi, 14px);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { RingProgress, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
const emptyDashboard: UptimeKumaDashboardData = {
|
||||
totalMonitors: 0,
|
||||
upCount: 0,
|
||||
downCount: 0,
|
||||
pausedCount: 0,
|
||||
averageUptimePercent: 0,
|
||||
monitors: [],
|
||||
};
|
||||
|
||||
const statVisibilityByOption = {
|
||||
showTotalMonitors: "totalMonitors",
|
||||
showUpCount: "upCount",
|
||||
showDownCount: "downCount",
|
||||
showPausedCount: "pausedCount",
|
||||
} as const;
|
||||
|
||||
const statIcons = {
|
||||
totalMonitors: IconServer,
|
||||
upCount: IconArrowUp,
|
||||
downCount: IconArrowDown,
|
||||
pausedCount: IconClockPause,
|
||||
} as const;
|
||||
|
||||
const statColorByKey = {
|
||||
totalMonitors: "gray",
|
||||
upCount: "green",
|
||||
downCount: "red",
|
||||
pausedCount: "yellow",
|
||||
} as const;
|
||||
|
||||
const uptimeTierByThreshold = [
|
||||
[99, "excellent"],
|
||||
[95, "good"],
|
||||
[0, "poor"],
|
||||
] as const;
|
||||
|
||||
const uptimeColorByTier = {
|
||||
excellent: "green",
|
||||
good: "yellow",
|
||||
poor: "red",
|
||||
} as const;
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 380, cols: 4 },
|
||||
{ minWidth: 260, cols: 2 },
|
||||
{ minWidth: 0, cols: 2 },
|
||||
] as const;
|
||||
|
||||
const ringSizeByWidth = [
|
||||
{ minWidth: 400, size: 88 },
|
||||
{ minWidth: 320, size: 72 },
|
||||
{ minWidth: 220, size: 56 },
|
||||
{ minWidth: 0, size: 44 },
|
||||
] as const;
|
||||
|
||||
const iconSizeByWidth = [
|
||||
{ minWidth: 320, size: 22 },
|
||||
{ minWidth: 200, size: 18 },
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
const heroLayoutBySecondaryStats = {
|
||||
false: classes.heroExpanded,
|
||||
true: "",
|
||||
} as const;
|
||||
|
||||
const heroVariantByRing = {
|
||||
true: "",
|
||||
false: classes.heroTextOnly,
|
||||
} as const;
|
||||
|
||||
export default function UptimeKumaWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
options,
|
||||
width,
|
||||
}: WidgetComponentProps<"uptimeKuma">) {
|
||||
if (integrationIds.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
}
|
||||
|
||||
return <UptimeKumaContent integrationIds={integrationIds} isEditMode={isEditMode} options={options} width={width} />;
|
||||
}
|
||||
|
||||
interface UptimeKumaContentProps {
|
||||
integrationIds: string[];
|
||||
isEditMode: boolean;
|
||||
options: WidgetComponentProps<"uptimeKuma">["options"];
|
||||
width: number;
|
||||
}
|
||||
|
||||
function UptimeKumaContent({ integrationIds, isEditMode, options, width }: UptimeKumaContentProps) {
|
||||
const t = useScopedI18n("widget.uptimeKuma");
|
||||
const [dashboardData] = clientApi.widget.uptimeKuma.getDashboard.useSuspenseQuery({ integrationIds });
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.uptimeKuma.subscribeToDashboard.useSubscription(
|
||||
{ integrationIds },
|
||||
{
|
||||
enabled: !isEditMode,
|
||||
onData(newData) {
|
||||
utils.widget.uptimeKuma.getDashboard.setData({ integrationIds }, (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return prevData.map((instance) => {
|
||||
if (instance.integrationId === newData.integrationId) {
|
||||
return { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp };
|
||||
}
|
||||
return instance;
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const combined = dashboardData.reduce<UptimeKumaDashboardData>(
|
||||
(acc, item) => ({
|
||||
totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors,
|
||||
upCount: acc.upCount + item.dashboard.upCount,
|
||||
downCount: acc.downCount + item.dashboard.downCount,
|
||||
pausedCount: acc.pausedCount + item.dashboard.pausedCount,
|
||||
averageUptimePercent: acc.averageUptimePercent + item.dashboard.averageUptimePercent,
|
||||
monitors: [...acc.monitors, ...item.dashboard.monitors],
|
||||
}),
|
||||
emptyDashboard,
|
||||
);
|
||||
|
||||
const instanceCount = dashboardData.length;
|
||||
const averageUptime = combined.averageUptimePercent / Math.max(instanceCount, 1);
|
||||
const uptimeValue = clampPercent(averageUptime);
|
||||
const uptimeColor = getUptimeColor(uptimeValue);
|
||||
const ringSize = getRingSize(width);
|
||||
const iconSize = getIconSize(width);
|
||||
const gridCols = getGridCols(width);
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const showHero = options.showAverageUptime;
|
||||
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||
|
||||
const heroLayoutClass =
|
||||
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
|
||||
const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing];
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{showHero && (
|
||||
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||
<div className={classes.heroText}>
|
||||
<span className={classes.heroLabel}>{t("averageUptime")}</span>
|
||||
<span className={classes.heroValue}>{formatNumber(uptimeValue, 1)}%</span>
|
||||
</div>
|
||||
{options.showUptimeRing && (
|
||||
<RingProgress
|
||||
className={classes.ring}
|
||||
size={ringSize}
|
||||
thickness={Math.max(4, Math.round(ringSize / 10))}
|
||||
roundCaps
|
||||
sections={[{ value: uptimeValue, color: uptimeColor }]}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700}>
|
||||
{formatNumber(uptimeValue, 0)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasContent && (
|
||||
<div className={classes.emptyState}>
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleStatKeys.length > 0 && (
|
||||
<div className={classes.statGrid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||
{visibleStatKeys.map((statKey) => {
|
||||
const Icon = statIcons[statKey];
|
||||
const color = statColorByKey[statKey];
|
||||
return (
|
||||
<div key={statKey} className={classes.statTile}>
|
||||
<Icon
|
||||
className={classes.statIcon}
|
||||
size={iconSize}
|
||||
stroke={1.5}
|
||||
color={`var(--mantine-color-${color}-6)`}
|
||||
/>
|
||||
<span className={classes.statValue}>{formatNumber(combined[statKey], 0)}</span>
|
||||
<span className={classes.statLabel}>{t(`stats.${statKey}`)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function getUptimeTier(uptime: number): keyof typeof uptimeColorByTier {
|
||||
const match = uptimeTierByThreshold.find(([min]) => uptime >= min);
|
||||
const tier = match?.[1] ?? "poor";
|
||||
return tier;
|
||||
}
|
||||
|
||||
function getUptimeColor(uptime: number): string {
|
||||
return uptimeColorByTier[getUptimeTier(uptime)];
|
||||
}
|
||||
|
||||
function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 2;
|
||||
}
|
||||
|
||||
function getRingSize(width: number): number {
|
||||
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.size ?? 44;
|
||||
}
|
||||
|
||||
function getIconSize(width: number): number {
|
||||
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.size ?? 16;
|
||||
}
|
||||
@@ -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