Merge branch 'dev' into feat/global-refresh-indicator
This commit is contained in:
355 files changed
+30915
-15222
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;
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
Memory: MemoryStick,
|
||||
Disk: HardDrive,
|
||||
Bandwidth: Network,
|
||||
Temperature: Thermometer,
|
||||
"Load Average": Activity,
|
||||
LoadAvg1: Activity,
|
||||
LoadAvg5: Activity,
|
||||
LoadAvg15: Activity,
|
||||
Status: Server,
|
||||
GPU: Monitor,
|
||||
Battery: Battery,
|
||||
};
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function BeszelAlertsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: WidgetComponentProps<"beszelAlerts">) {
|
||||
const t = useScopedI18n("widget.beszelAlerts");
|
||||
const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({
|
||||
integrationIds,
|
||||
includeHistory: options.showHistory,
|
||||
maxHistoryItems: options.maxHistoryItems,
|
||||
});
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
clientApi.widget.beszel.subscribeAlerts.useSubscription(
|
||||
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
|
||||
{
|
||||
enabled: !isEditMode,
|
||||
onData(data) {
|
||||
utils.widget.beszel.getAlerts.setData(
|
||||
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
|
||||
(prev) => {
|
||||
if (!prev) return prev;
|
||||
return prev.map((r) =>
|
||||
r.integrationId === data.integrationId
|
||||
? { ...r, alerts: data.alerts.alerts, history: data.alerts.history, updatedAt: data.timestamp }
|
||||
: r,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of results) {
|
||||
if (r.systemNameMap) {
|
||||
Object.assign(map, r.systemNameMap);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [results]);
|
||||
|
||||
const alerts = useMemo(
|
||||
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
|
||||
[results],
|
||||
);
|
||||
|
||||
const history = useMemo(() => {
|
||||
const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` })));
|
||||
return all
|
||||
.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
.slice(0, options.maxHistoryItems);
|
||||
}, [results, options.maxHistoryItems]);
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered);
|
||||
const okAlerts = alerts.filter((a) => !a.triggered);
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{alerts.length === 0 && (
|
||||
<Stack align="center" justify="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconBellOff size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{triggeredAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{okAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("history")}
|
||||
</Text>
|
||||
</Group>
|
||||
{history.map((entry) => {
|
||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||
const isResolved = !!entry.resolved;
|
||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||
return (
|
||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={3}
|
||||
h={24}
|
||||
style={{ borderRadius: 2, flexShrink: 0 }}
|
||||
bg={isResolved ? "green.6" : "red.6"}
|
||||
/>
|
||||
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
||||
{isResolved ? t("resolved") : t("status.triggered")}
|
||||
</Badge>
|
||||
<Text size="10px" c="dimmed">
|
||||
{dayjs(entry.created).fromNow()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertRowProps {
|
||||
name: string;
|
||||
value: number;
|
||||
min: number;
|
||||
systemName: string;
|
||||
triggered: boolean;
|
||||
}
|
||||
|
||||
const unitSuffixMap: Record<string, string> = {
|
||||
CPU: "%",
|
||||
Memory: "%",
|
||||
Disk: "%",
|
||||
GPU: "%",
|
||||
Battery: "%",
|
||||
Temperature: "°",
|
||||
Bandwidth: " MB/s",
|
||||
"Load Average": "",
|
||||
LoadAvg1: "",
|
||||
LoadAvg5: "",
|
||||
LoadAvg15: "",
|
||||
Status: "",
|
||||
};
|
||||
|
||||
function formatAlertDescription(name: string, value: number, min: number): string {
|
||||
const suffix = unitSuffixMap[name] ?? "";
|
||||
if (name === "Status") return `down for ${min} min`;
|
||||
return `exceeds ${value}${suffix} over ${min} min`;
|
||||
}
|
||||
|
||||
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
|
||||
const Icon = alertIconMap[name] ?? Server;
|
||||
return (
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
py={4}
|
||||
pr={8}
|
||||
pl={12}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{formatAlertDescription(name, value, min)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IconBell } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
|
||||
icon: IconBell,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showHistory: factory.switch({ defaultValue: true }),
|
||||
maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
BatteryCharging,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
fontSize: string;
|
||||
progressSize: "xs" | "sm";
|
||||
labelMiw: number;
|
||||
valueMiw: number;
|
||||
rowHeight: number;
|
||||
cardPadding: number;
|
||||
badgeHeight: number;
|
||||
badgeSize: "xs" | "sm";
|
||||
gap: number;
|
||||
}
|
||||
|
||||
const defaultSizeConfig: SizeConfig = {
|
||||
iconSize: 10,
|
||||
fontSize: "10px",
|
||||
progressSize: "xs",
|
||||
labelMiw: 40,
|
||||
valueMiw: 30,
|
||||
rowHeight: 18,
|
||||
cardPadding: 4,
|
||||
badgeHeight: 20,
|
||||
badgeSize: "xs",
|
||||
gap: 2,
|
||||
};
|
||||
|
||||
const sizeThresholds: [number, SizeConfig][] = [
|
||||
[0, defaultSizeConfig],
|
||||
[
|
||||
160,
|
||||
{
|
||||
iconSize: 12,
|
||||
fontSize: "xs",
|
||||
progressSize: "xs",
|
||||
labelMiw: 50,
|
||||
valueMiw: 34,
|
||||
rowHeight: 22,
|
||||
cardPadding: 6,
|
||||
badgeHeight: 22,
|
||||
badgeSize: "xs",
|
||||
gap: 6,
|
||||
},
|
||||
],
|
||||
[
|
||||
280,
|
||||
{
|
||||
iconSize: 14,
|
||||
fontSize: "xs",
|
||||
progressSize: "sm",
|
||||
labelMiw: 55,
|
||||
valueMiw: 38,
|
||||
rowHeight: 24,
|
||||
cardPadding: 8,
|
||||
badgeHeight: 26,
|
||||
badgeSize: "sm",
|
||||
gap: 8,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => {
|
||||
const basis = Math.min(cellWidth, cellHeight);
|
||||
let config = defaultSizeConfig;
|
||||
for (const [threshold, cfg] of sizeThresholds) {
|
||||
if (basis >= threshold) config = cfg;
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
|
||||
const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap;
|
||||
return Math.max(1, Math.floor(available / size.rowHeight));
|
||||
};
|
||||
|
||||
const MIN_CELL_WIDTH = 120;
|
||||
const MIN_CELL_HEIGHT = 80;
|
||||
|
||||
const getColCount = (width: number, itemCount: number): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
|
||||
|
||||
let best = 1;
|
||||
for (let c = 1; c <= maxCols; c++) {
|
||||
const emptyCells = (c - (itemCount % c)) % c;
|
||||
const bestEmpty = (best - (itemCount % best)) % best;
|
||||
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
interface MetricRowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
progress?: { value: number; color: string };
|
||||
size: SizeConfig;
|
||||
}
|
||||
|
||||
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
|
||||
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
{progress && (
|
||||
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
|
||||
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
|
||||
{icon}
|
||||
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
|
||||
{label}
|
||||
</Text>
|
||||
<MetricValue value={value} progress={progress} size={size} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
interface SystemCardProps {
|
||||
system: BeszelSystemRow & { _key: string };
|
||||
options: WidgetComponentProps<"beszelSystemGrid">["options"];
|
||||
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
|
||||
size: SizeConfig;
|
||||
maxMetrics: number;
|
||||
}
|
||||
|
||||
const metricRenderers = [
|
||||
{
|
||||
key: "showCpu",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="cpu"
|
||||
icon={<Cpu size={sz.iconSize} />}
|
||||
label={t("metric.cpu")}
|
||||
value={formatPercent(s.cpu)}
|
||||
progress={{ value: s.cpu, color: thresholdColor(s.cpu) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu,
|
||||
},
|
||||
{
|
||||
key: "showMemory",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="mem"
|
||||
icon={<MemoryStick size={sz.iconSize} />}
|
||||
label={t("metric.memory")}
|
||||
value={formatPercent(s.memory)}
|
||||
progress={{ value: s.memory, color: thresholdColor(s.memory) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory,
|
||||
},
|
||||
{
|
||||
key: "showDisk",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="disk"
|
||||
icon={<HardDrive size={sz.iconSize} />}
|
||||
label={t("metric.disk")}
|
||||
value={formatPercent(s.disk)}
|
||||
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
|
||||
},
|
||||
{
|
||||
key: "showGpu",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="gpu"
|
||||
icon={<Monitor size={sz.iconSize} />}
|
||||
label={t("metric.gpu")}
|
||||
value={formatPercent(s.gpu)}
|
||||
progress={{ value: s.gpu, color: thresholdColor(s.gpu) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0,
|
||||
},
|
||||
{
|
||||
key: "showLoadAvg",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="la"
|
||||
icon={<Activity size={sz.iconSize} />}
|
||||
label={t("metric.loadAvg")}
|
||||
value={formatLoadAvg(s.loadAvg)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null,
|
||||
},
|
||||
{
|
||||
key: "showNet",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="net"
|
||||
icon={<Network size={sz.iconSize} />}
|
||||
label={t("metric.net")}
|
||||
value={formatByteRate(s.netBytes)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet,
|
||||
},
|
||||
{
|
||||
key: "showTemp",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="temp"
|
||||
icon={<Thermometer size={sz.iconSize} />}
|
||||
label={t("metric.temp")}
|
||||
value={formatTemp(s.temp, false)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null,
|
||||
},
|
||||
{
|
||||
key: "showBattery",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => {
|
||||
const batteryIcons = [Battery, BatteryCharging] as const;
|
||||
const Icon = batteryIcons[Math.min(1, s.battery?.[1] ?? 0)] ?? Battery;
|
||||
return (
|
||||
<MetricRow
|
||||
key="bat"
|
||||
icon={<Icon size={sz.iconSize} />}
|
||||
label={t("metric.battery")}
|
||||
value={`${s.battery?.[0] ?? 0}%`}
|
||||
size={sz}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null,
|
||||
},
|
||||
{
|
||||
key: "showServices",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="svc"
|
||||
icon={<Server size={sz.iconSize} />}
|
||||
label={t("metric.services")}
|
||||
value={String(s.services)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices,
|
||||
},
|
||||
{
|
||||
key: "showUptime",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="up"
|
||||
icon={<Activity size={sz.iconSize} />}
|
||||
label={t("metric.uptime")}
|
||||
value={formatUptime(s.uptime)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime,
|
||||
},
|
||||
{
|
||||
key: "showAgent",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="agent"
|
||||
icon={<Wifi size={sz.iconSize} />}
|
||||
label={t("metric.agent")}
|
||||
value={s.agentVersion}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => {
|
||||
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding={size.cardPadding}
|
||||
radius="sm"
|
||||
withBorder
|
||||
h="100%"
|
||||
style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }}
|
||||
>
|
||||
<Group gap="xs" mb={2}>
|
||||
<Badge
|
||||
size={size.badgeSize}
|
||||
variant="dot"
|
||||
color={statusColorMap[system.status]}
|
||||
styles={{ root: { textTransform: "none" } }}
|
||||
>
|
||||
{system.name}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
|
||||
{visibleMetrics.map((m) => m.render(system, t, size))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BeszelSystemGridWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
|
||||
|
||||
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
const cols = getColCount(width, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
|
||||
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
|
||||
const cellWidth = width / cols;
|
||||
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{filteredSystems.map((system) => (
|
||||
<SystemCard key={system._key} system={system} options={options} t={t} size={size} maxMetrics={maxMetrics} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { statusOptions } from "../beszel/_shared/options";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
|
||||
icon: IconLayoutGrid,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
|
||||
showCpu: factory.switch({ defaultValue: true }),
|
||||
showMemory: factory.switch({ defaultValue: true }),
|
||||
showDisk: factory.switch({ defaultValue: true }),
|
||||
showGpu: factory.switch({ defaultValue: true }),
|
||||
showLoadAvg: factory.switch({ defaultValue: true }),
|
||||
showNet: factory.switch({ defaultValue: true }),
|
||||
showTemp: factory.switch({ defaultValue: true }),
|
||||
showBattery: factory.switch({ defaultValue: true }),
|
||||
showServices: factory.switch({ defaultValue: true }),
|
||||
showUptime: factory.switch({ defaultValue: true }),
|
||||
showAgent: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { containerColors } from "../beszel/_shared/colors";
|
||||
import {
|
||||
BeszelAreaChart,
|
||||
ChartPanel,
|
||||
useContainerNames,
|
||||
useDockerChartData,
|
||||
useSystemChartData,
|
||||
} from "../beszel/_shared/chart";
|
||||
import {
|
||||
chartAxisFormatters,
|
||||
formatByteRate,
|
||||
formatGB,
|
||||
formatPercent,
|
||||
formatStorageBytes,
|
||||
} from "../beszel/_shared/format";
|
||||
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||
|
||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
||||
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
|
||||
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
|
||||
|
||||
const CHART_HEIGHT = 180;
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
}: WidgetComponentProps<"beszelSystemStats">) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
|
||||
|
||||
const systems = useMemo(
|
||||
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
|
||||
[systemsResult],
|
||||
);
|
||||
|
||||
const [selectedSystem, setSelectedSystem] = useState<string | null>(systems[0]?.value ?? null);
|
||||
|
||||
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
|
||||
|
||||
const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({
|
||||
integrationIds,
|
||||
systemId: selectedSystem ?? systems[0]?.value ?? "",
|
||||
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
|
||||
includeDocker: showDocker,
|
||||
});
|
||||
|
||||
// BeszelSystemStats fields are documented in beszel-types.ts.
|
||||
// mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s
|
||||
const BYTES_TO_GB = 1024 * 1024 * 1024;
|
||||
|
||||
const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]);
|
||||
const memoryMap = useCallback(
|
||||
(s: { mu: number; mb: number }) => ({
|
||||
[t("chart.memory.series")]: s.mu / BYTES_TO_GB,
|
||||
[t("chart.memory.cache")]: (s.mb ?? 0) / BYTES_TO_GB,
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du / BYTES_TO_GB }), [t]);
|
||||
const diskIOMap = useCallback(
|
||||
(s: { dr?: number; dw?: number }) => ({
|
||||
[t("chart.diskIO.read")]: s.dr ?? 0,
|
||||
[t("chart.diskIO.write")]: s.dw ?? 0,
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
// b = public interface bandwidth (preferred), ns/nr = all-interface traffic (fallback)
|
||||
const networkMap = useCallback(
|
||||
(s: { ns: number; nr: number; b?: [number, number] }) => ({
|
||||
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
|
||||
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const cpuData = useSystemChartData(statsResult?.systemStats, cpuMap);
|
||||
const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap);
|
||||
const diskData = useSystemChartData(statsResult?.systemStats, diskMap);
|
||||
const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap);
|
||||
const networkData = useSystemChartData(statsResult?.systemStats, networkMap);
|
||||
|
||||
const containerNames = useContainerNames(statsResult?.containerStats);
|
||||
const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu");
|
||||
const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory");
|
||||
const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network");
|
||||
|
||||
const containerSeries = containerNames.map((name, i) => ({
|
||||
name,
|
||||
color: containerColors[i % containerColors.length] as string,
|
||||
}));
|
||||
|
||||
const cols = width > 600 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="md" p="sm">
|
||||
<Select
|
||||
size="xs"
|
||||
data={systems}
|
||||
value={selectedSystem}
|
||||
onChange={setSelectedSystem}
|
||||
placeholder={t("selectSystem")}
|
||||
/>
|
||||
|
||||
{statsResult && (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{options.showCpu && cpuData.length > 0 && (
|
||||
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={cpuData}
|
||||
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
|
||||
fillOpacity={0.4}
|
||||
yAxisFormatter={chartAxisFormatters.percent}
|
||||
yAxisDomain={[0, "auto"]}
|
||||
tooltipProps={tooltipPercent}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showMemory && memoryData.length > 0 && (
|
||||
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={memoryData}
|
||||
series={[
|
||||
{ name: t("chart.memory.series"), color: "teal.5" },
|
||||
{ name: t("chart.memory.cache"), color: "teal.8" },
|
||||
]}
|
||||
fillOpacity={0.4}
|
||||
yAxisFormatter={chartAxisFormatters.gb}
|
||||
tooltipProps={tooltipGB}
|
||||
withLegend
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDisk && diskData.length > 0 && (
|
||||
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={diskData}
|
||||
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
|
||||
fillOpacity={0.4}
|
||||
yAxisFormatter={chartAxisFormatters.gb}
|
||||
tooltipProps={tooltipGB}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDiskIO && diskIOData.length > 0 && (
|
||||
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={diskIOData}
|
||||
series={[
|
||||
{ name: t("chart.diskIO.write"), color: "orange.6" },
|
||||
{ name: t("chart.diskIO.read"), color: "blue.6" },
|
||||
]}
|
||||
fillOpacity={0.3}
|
||||
yAxisFormatter={chartAxisFormatters.rate}
|
||||
tooltipProps={tooltipRate}
|
||||
withLegend
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showNetwork && networkData.length > 0 && (
|
||||
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={networkData}
|
||||
series={[
|
||||
{ name: t("chart.network.sent"), color: "blue.6" },
|
||||
{ name: t("chart.network.recv"), color: "teal.6" },
|
||||
]}
|
||||
fillOpacity={0.3}
|
||||
yAxisFormatter={chartAxisFormatters.rate}
|
||||
tooltipProps={tooltipRate}
|
||||
withLegend
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={dockerCpuData}
|
||||
type="stacked"
|
||||
series={containerSeries}
|
||||
fillOpacity={0.6}
|
||||
yAxisFormatter={chartAxisFormatters.percent}
|
||||
tooltipProps={tooltipPercentTotal}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={dockerMemData}
|
||||
type="stacked"
|
||||
series={containerSeries}
|
||||
fillOpacity={0.6}
|
||||
yAxisFormatter={chartAxisFormatters.bytes}
|
||||
tooltipProps={tooltipBytesTotal}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={dockerNetData}
|
||||
type="stacked"
|
||||
series={containerSeries}
|
||||
fillOpacity={0.6}
|
||||
yAxisFormatter={chartAxisFormatters.rate}
|
||||
tooltipProps={tooltipRate}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IconChartAreaLine } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const timePeriodOptions = [
|
||||
{ value: "1h", label: "1 Hour" },
|
||||
{ value: "12h", label: "12 Hours" },
|
||||
{ value: "24h", label: "24 Hours" },
|
||||
{ value: "1w", label: "1 Week" },
|
||||
{ value: "30d", label: "30 Days" },
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
|
||||
icon: IconChartAreaLine,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }),
|
||||
showCpu: factory.switch({ defaultValue: true }),
|
||||
showMemory: factory.switch({ defaultValue: true }),
|
||||
showDisk: factory.switch({ defaultValue: true }),
|
||||
showDiskIO: factory.switch({ defaultValue: true }),
|
||||
showNetwork: factory.switch({ defaultValue: true }),
|
||||
showDockerCpu: factory.switch({ defaultValue: true }),
|
||||
showDockerMemory: factory.switch({ defaultValue: true }),
|
||||
showDockerNetwork: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Group, Indicator, Progress, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||
|
||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||
|
||||
type SystemRowWithKey = BeszelSystemRow & { _key: string };
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
fontSize: "xs" | "10px";
|
||||
progressSize: "xs" | "sm";
|
||||
cellPadding: number;
|
||||
valueMiw: number;
|
||||
}
|
||||
|
||||
const getSizeConfig = (width: number): SizeConfig => {
|
||||
if (width < 400) {
|
||||
return { iconSize: 10, fontSize: "10px", progressSize: "xs", cellPadding: 2, valueMiw: 30 };
|
||||
}
|
||||
return { iconSize: 14, fontSize: "xs", progressSize: "sm", cellPadding: 4, valueMiw: 38 };
|
||||
};
|
||||
|
||||
export default function BeszelSystemTableWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||
const t = useScopedI18n("widget.beszelSystemTable");
|
||||
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
|
||||
const size = getSizeConfig(width);
|
||||
|
||||
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
|
||||
columnAccessor: options.sortBy,
|
||||
direction: options.sortDirection as "asc" | "desc",
|
||||
});
|
||||
|
||||
const sortedSystems = useMemo(() => {
|
||||
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
|
||||
const dir = directionMultiplier[sortStatus.direction] ?? 1;
|
||||
return [...filteredSystems].toSorted((a, b) => {
|
||||
const aVal = a[accessor] ?? 0;
|
||||
const bVal = b[accessor] ?? 0;
|
||||
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
|
||||
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
|
||||
return 0;
|
||||
});
|
||||
}, [filteredSystems, sortStatus]);
|
||||
|
||||
const PercentCell = ({ value }: { value: number }) => (
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
|
||||
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
|
||||
{
|
||||
accessor: "name",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Server size={size.iconSize} />
|
||||
<Text inherit>{t("column.system")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Indicator color={statusColorMap[record.status]} size={7} />
|
||||
<Text size={size.fontSize} fw={500} truncate>
|
||||
{record.name}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showCpu && {
|
||||
accessor: "cpu",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Cpu size={size.iconSize} />
|
||||
<Text inherit>{t("column.cpu")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.cpu} />,
|
||||
},
|
||||
options.showMemory && {
|
||||
accessor: "memory",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<MemoryStick size={size.iconSize} />
|
||||
<Text inherit>{t("column.memory")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.memory} />,
|
||||
},
|
||||
options.showDisk && {
|
||||
accessor: "disk",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<HardDrive size={size.iconSize} />
|
||||
<Text inherit>{t("column.disk")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.disk} />,
|
||||
},
|
||||
options.showGpu && {
|
||||
accessor: "gpu",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Monitor size={size.iconSize} />
|
||||
<Text inherit>{t("column.gpu")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.gpu} />,
|
||||
},
|
||||
options.showLoadAvg && {
|
||||
accessor: "loadAvg",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
<Text inherit>{t("column.loadAvg")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
|
||||
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showNet && {
|
||||
accessor: "netBytes",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Network size={size.iconSize} />
|
||||
<Text inherit>{t("column.net")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Text size={size.fontSize} style={{ whiteSpace: "nowrap" }}>
|
||||
{formatByteRate(record.netBytes)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
options.showTemp && {
|
||||
accessor: "temp",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Thermometer size={size.iconSize} />
|
||||
<Text inherit>{t("column.temp")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
|
||||
},
|
||||
options.showBattery && {
|
||||
accessor: "battery",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Battery size={size.iconSize} />
|
||||
<Text inherit>{t("column.battery")}</Text>
|
||||
</Group>
|
||||
),
|
||||
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
|
||||
},
|
||||
options.showServices && {
|
||||
accessor: "services",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Server size={size.iconSize} />
|
||||
<Text inherit>{t("column.services")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
|
||||
},
|
||||
options.showUptime && {
|
||||
accessor: "uptime",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
<Text inherit>{t("column.uptime")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
|
||||
},
|
||||
options.showAgent && {
|
||||
accessor: "agentVersion",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Wifi size={size.iconSize} />
|
||||
<Text inherit>{t("column.agent")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.agentVersion}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||
}, [options, t, size]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
fz={size.fontSize}
|
||||
records={sortedSystems}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
height="100%"
|
||||
className="beszel-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { statusOptions } from "../beszel/_shared/options";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "name", label: "System" },
|
||||
{ value: "cpu", label: "CPU" },
|
||||
{ value: "memory", label: "Memory" },
|
||||
{ value: "disk", label: "Disk" },
|
||||
{ value: "gpu", label: "GPU" },
|
||||
{ value: "loadAvg", label: "Load Avg" },
|
||||
{ value: "netBytes", label: "Net" },
|
||||
{ value: "temp", label: "Temp" },
|
||||
{ value: "services", label: "Services" },
|
||||
{ value: "uptime", label: "Uptime" },
|
||||
{ value: "agentVersion", label: "Agent" },
|
||||
];
|
||||
|
||||
const sortDirectionOptions = [
|
||||
{ value: "asc", label: "Ascending" },
|
||||
{ value: "desc", label: "Descending" },
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
|
||||
icon: IconTable,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
|
||||
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
|
||||
sortDirection: factory.select({ defaultValue: "asc", options: sortDirectionOptions }),
|
||||
showCpu: factory.switch({ defaultValue: true }),
|
||||
showMemory: factory.switch({ defaultValue: true }),
|
||||
showDisk: factory.switch({ defaultValue: true }),
|
||||
showGpu: factory.switch({ defaultValue: true }),
|
||||
showLoadAvg: factory.switch({ defaultValue: true }),
|
||||
showNet: factory.switch({ defaultValue: true }),
|
||||
showTemp: factory.switch({ defaultValue: true }),
|
||||
showBattery: factory.switch({ defaultValue: true }),
|
||||
showServices: factory.switch({ defaultValue: true }),
|
||||
showUptime: factory.switch({ defaultValue: true }),
|
||||
showAgent: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,24 @@
|
||||
.beszel-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.beszel-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.beszel-table td .mantine-Text-root,
|
||||
.beszel-table td .mantine-Group-root {
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.beszel-table .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import type { AreaChartProps } from "@mantine/charts";
|
||||
import { AreaChart } from "@mantine/charts";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
|
||||
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
|
||||
|
||||
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
|
||||
|
||||
interface ChartPanelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
|
||||
<Stack gap={4} style={{ minWidth: 0, overflow: "hidden" }}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
|
||||
yAxisFormatter: (value: number) => string;
|
||||
yAxisDomain?: [number, string];
|
||||
};
|
||||
|
||||
export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => (
|
||||
<AreaChart
|
||||
dataKey="time"
|
||||
curveType="monotone"
|
||||
withDots={false}
|
||||
withXAxis
|
||||
withYAxis
|
||||
style={{ minWidth: 0, ...style }}
|
||||
yAxisProps={{
|
||||
...yAxisBase,
|
||||
...(yAxisDomain ? { domain: yAxisDomain } : {}),
|
||||
tickFormatter: yAxisFormatter,
|
||||
...yAxisProps,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const useSystemChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!systemStats) return [];
|
||||
return [...systemStats].toReversed().map((r) => ({
|
||||
time: formatTime(r.created),
|
||||
...mapFn(r.stats),
|
||||
}));
|
||||
}, [systemStats, mapFn]);
|
||||
|
||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const names = new Set<string>();
|
||||
for (const record of containerStats) {
|
||||
for (const c of record.stats) {
|
||||
names.add(c.n);
|
||||
}
|
||||
}
|
||||
return [...names].slice(0, max);
|
||||
}, [containerStats, max]);
|
||||
|
||||
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
|
||||
|
||||
// c = CPU (%), m = memory (bytes), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s)
|
||||
const defaultContainerExtractors: Record<string, ContainerExtractor> = {
|
||||
cpu: (c) => c?.c ?? 0,
|
||||
memory: (c) => c?.m ?? 0,
|
||||
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
|
||||
};
|
||||
|
||||
export const useDockerChartData = (
|
||||
containerStats: BeszelContainerStatsRecord[] | undefined,
|
||||
containerNames: string[],
|
||||
metric: "cpu" | "memory" | "network",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const extract = defaultContainerExtractors[metric];
|
||||
if (!extract) return [];
|
||||
return [...containerStats].toReversed().map((record) => {
|
||||
const point: Record<string, unknown> = { time: formatTime(record.created) };
|
||||
for (const name of containerNames) {
|
||||
point[name] = extract(record.stats.find((c) => c.n === name));
|
||||
}
|
||||
return point;
|
||||
});
|
||||
}, [containerStats, containerNames, metric]);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { BeszelSystemStatus } from "./types";
|
||||
|
||||
export const thresholdColor = (value: number): string => {
|
||||
if (value >= 80) return "red";
|
||||
if (value >= 50) return "yellow";
|
||||
return "green";
|
||||
};
|
||||
|
||||
export const loadAvgColor = (la1: number, cores: number): string => {
|
||||
const ratio = la1 / cores;
|
||||
if (ratio >= 1.5) return "red";
|
||||
if (ratio >= 0.8) return "yellow";
|
||||
return "green";
|
||||
};
|
||||
|
||||
export const statusColorMap: Record<BeszelSystemStatus, string> = {
|
||||
up: "green",
|
||||
down: "red",
|
||||
paused: "gray",
|
||||
pending: "yellow",
|
||||
};
|
||||
|
||||
export const containerColors = [
|
||||
"blue.6",
|
||||
"teal.6",
|
||||
"orange.6",
|
||||
"grape.6",
|
||||
"cyan.6",
|
||||
"pink.6",
|
||||
"lime.6",
|
||||
"violet.6",
|
||||
"indigo.6",
|
||||
"yellow.6",
|
||||
"red.6",
|
||||
"green.6",
|
||||
"blue.4",
|
||||
"teal.4",
|
||||
"orange.4",
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const byteUnits = ["B", "KB", "MB", "GB", "TB"] as const;
|
||||
const rateUnits = ["B/s", "KB/s", "MB/s", "GB/s"] as const;
|
||||
|
||||
const formatScaled = (value: number, units: readonly string[], zeroLabel: string): string => {
|
||||
if (!value || !Number.isFinite(value)) return zeroLabel;
|
||||
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
|
||||
const scaled = value / 1024 ** i;
|
||||
const decimals = scaled < 10 ? 2 : 1;
|
||||
return `${scaled.toFixed(decimals)} ${units[i] ?? ""}`;
|
||||
};
|
||||
|
||||
const formatScaledCompact = (value: number, units: readonly string[], zeroLabel: string): string => {
|
||||
if (!value || !Number.isFinite(value)) return zeroLabel;
|
||||
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
|
||||
const scaled = value / 1024 ** i;
|
||||
return `${scaled < 10 ? scaled.toFixed(1) : Math.round(scaled)}${units[i] ?? ""}`;
|
||||
};
|
||||
|
||||
export const formatByteRate = (value: number): string => formatScaled(value, rateUnits, "0 B/s");
|
||||
|
||||
export const formatStorageBytes = (bytes: number): string => formatScaled(bytes, byteUnits, "0 B");
|
||||
|
||||
export const formatGB = (value: number): string => {
|
||||
if (!value || !Number.isFinite(value)) return "0 GB";
|
||||
return `${value.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
|
||||
|
||||
export const chartAxisFormatters = {
|
||||
percent: formatPercent,
|
||||
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
|
||||
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
|
||||
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
|
||||
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
|
||||
} as const;
|
||||
|
||||
export const formatUptime = (seconds: number): string => dayjs.duration(seconds, "seconds").humanize();
|
||||
|
||||
export const formatTemp = (celsius: number | null, fahrenheit: boolean): string => {
|
||||
if (celsius === null) return "—";
|
||||
if (fahrenheit) return `${((celsius * 9) / 5 + 32).toFixed(1)} °F`;
|
||||
return `${celsius.toFixed(2)} °C`;
|
||||
};
|
||||
|
||||
export const formatLoadAvg = (la: [number, number, number] | null): string => {
|
||||
if (!la) return "—";
|
||||
return la.map((v) => v.toFixed(2)).join(" ");
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { BeszelSystemRow } from "./types";
|
||||
|
||||
type SystemRowWithKey = BeszelSystemRow & { _key: string };
|
||||
|
||||
export const useBeszelSystemsSubscription = (integrationIds: string[], enabled = true) => {
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.beszel.subscribeSystems.useSubscription(
|
||||
{ integrationIds },
|
||||
{
|
||||
enabled,
|
||||
onData(data) {
|
||||
utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => {
|
||||
if (!prev) return prev;
|
||||
return prev.map((r) =>
|
||||
r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r,
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useBeszelFilteredSystems = (
|
||||
results: { integrationId: string; systems: BeszelSystemRow[] }[],
|
||||
statusFilter: string,
|
||||
): SystemRowWithKey[] => {
|
||||
const allSystems = useMemo(
|
||||
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
|
||||
[results],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (statusFilter === "all") return allSystems;
|
||||
return allSystems.filter((s) => s.status === statusFilter);
|
||||
}, [allSystems, statusFilter]);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const statusOptions = [
|
||||
{ value: "all", label: "All Systems" },
|
||||
{ value: "up", label: "Up" },
|
||||
{ value: "down", label: "Down" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
];
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
wrapper: {
|
||||
background: "var(--mantine-color-dark-7)",
|
||||
border: "1px solid var(--mantine-color-dark-4)",
|
||||
borderRadius: 6,
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
pointerEvents: "none",
|
||||
maxHeight: 320,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
},
|
||||
header: { marginBottom: 4, fontWeight: 500, color: "var(--mantine-color-gray-2)" },
|
||||
row: { display: "flex", alignItems: "center", gap: 6, padding: "1px 0" },
|
||||
indicator: { width: 3, height: 14, borderRadius: 1, flexShrink: 0 },
|
||||
name: {
|
||||
flex: 1,
|
||||
color: "var(--mantine-color-gray-3)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 140,
|
||||
},
|
||||
value: {
|
||||
fontWeight: 700,
|
||||
color: "var(--mantine-color-gray-1)",
|
||||
whiteSpace: "nowrap",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
},
|
||||
separator: { borderTop: "1px solid var(--mantine-color-dark-4)", marginTop: 3, paddingTop: 3 },
|
||||
};
|
||||
|
||||
export interface TooltipPayloadItem {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
dataKey: string;
|
||||
}
|
||||
|
||||
interface PortalTooltipProps {
|
||||
active?: boolean;
|
||||
label?: string;
|
||||
payload?: TooltipPayloadItem[];
|
||||
formatter: (value: number) => string;
|
||||
showTotal?: boolean;
|
||||
}
|
||||
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
const [mouse, setMouse] = useState({ x: 0, y: 0 });
|
||||
const rafRef = useRef(0);
|
||||
|
||||
const handleMouse = useCallback((e: MouseEvent) => {
|
||||
mouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setMouse(mouseRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isActive = active && (payload?.length ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
document.addEventListener("mousemove", handleMouse);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouse);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [isActive, handleMouse]);
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
const sorted = [...(payload ?? [])].filter((p) => p.value > 0).toSorted((a, b) => b.value - a.value);
|
||||
|
||||
if (!sorted.length) return null;
|
||||
|
||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
const portalStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
left: mouse.x + 14,
|
||||
top: mouse.y,
|
||||
transform: "translateY(-50%)",
|
||||
zIndex: 10000,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div style={portalStyle}>
|
||||
<div style={styles.wrapper}>
|
||||
<div style={styles.header}>{label}</div>
|
||||
{sorted.map((item) => (
|
||||
<div key={item.dataKey} style={styles.row}>
|
||||
<div style={{ ...styles.indicator, background: item.color }} />
|
||||
<span style={styles.name}>{item.name}</span>
|
||||
<span style={styles.value}>{formatter(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{showTotal && sorted.length > 1 && (
|
||||
<div style={{ ...styles.row, ...styles.separator }}>
|
||||
<span style={styles.name}>Total</span>
|
||||
<span style={styles.value}>{formatter(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
|
||||
content: (props: any) => (
|
||||
<PortalTooltipContent
|
||||
active={props.active}
|
||||
label={props.label != null ? String(props.label) : undefined}
|
||||
payload={props.payload}
|
||||
formatter={formatter}
|
||||
showTotal={showTotal}
|
||||
/>
|
||||
),
|
||||
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
|
||||
isAnimationActive: false,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export type { BeszelSystemRow, BeszelSystemStatus } from "@homarr/integrations/types";
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
||||
import { Group, Loader, Select, Stack } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface InnerProps {
|
||||
@@ -47,6 +47,7 @@ export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps })
|
||||
label={t("app.action.select.label")}
|
||||
searchable
|
||||
clearable
|
||||
data-autofocus
|
||||
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
|
||||
nothingFoundMessage={t("app.action.select.notFound")}
|
||||
renderOption={renderSelectOption}
|
||||
@@ -61,19 +62,13 @@ export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps })
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
<ModalFormFooter onCancel={actions.closeModal} submitLabel={confirmLabel} loading={loading} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.label"),
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
const iconProps = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
maxIntegrations?: number;
|
||||
createOptions: (
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
) => WidgetOptionsRecord;
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
@@ -28,25 +28,6 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const memoryUsageColor = (number: number, state: string) => {
|
||||
const mbUsage = number / 1024 / 1024;
|
||||
if (mbUsage === 0 && state !== "running") return "red";
|
||||
if (mbUsage < 128) return "green";
|
||||
if (mbUsage < 256) return "yellow";
|
||||
if (mbUsage < 512) return "orange";
|
||||
return "red";
|
||||
};
|
||||
|
||||
const cpuUsageColor = (number: number, state: string) => {
|
||||
if (number === 0 && state !== "running") return "red";
|
||||
if (number < 40) return "green";
|
||||
if (number < 60) return "yellow";
|
||||
if (number < 90) return "orange";
|
||||
return "red";
|
||||
};
|
||||
|
||||
const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback);
|
||||
|
||||
const actionIconIconStyle: IconProps["style"] = {
|
||||
height: "var(--ai-icon-size)",
|
||||
width: "var(--ai-icon-size)",
|
||||
|
||||
@@ -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,12 +43,18 @@ 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";
|
||||
import * as timetable from "./timetable";
|
||||
import * as tracearr from "./tracearr";
|
||||
import * as umami from "./umami";
|
||||
import * as ups from "./ups";
|
||||
import * as beszelSystemTable from "./beszel-system-table";
|
||||
import * as beszelSystemGrid from "./beszel-system-grid";
|
||||
import * as beszelAlerts from "./beszel-alerts";
|
||||
import * as beszelSystemStats from "./beszel-system-stats";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
|
||||
@@ -90,9 +98,17 @@ export const widgetImports = {
|
||||
timetable,
|
||||
"immich-serverStats": immichServerStats,
|
||||
"immich-albumCarousel": immichAlbumCarousel,
|
||||
paperlessNgx,
|
||||
tracearr,
|
||||
speedtestTracker,
|
||||
uptimeKuma,
|
||||
audioStats,
|
||||
umami,
|
||||
ups,
|
||||
beszelSystemTable,
|
||||
beszelSystemGrid,
|
||||
beszelAlerts,
|
||||
beszelSystemStats,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { MediaRequestStatus } from "@homarr/integrations/types";
|
||||
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "../search-button.module.css";
|
||||
|
||||
export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
@@ -63,25 +78,46 @@ export default function MediaServerWidget({
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Box className={classes.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={classes.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
|
||||
isTiny: boolean;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.searchRoot {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
position: absolute;
|
||||
top: var(--mantine-spacing-xs);
|
||||
right: var(--mantine-spacing-xs);
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.searchRoot:hover .searchButton,
|
||||
.searchRoot:focus-within .searchButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.searchButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceTv,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconMovie,
|
||||
IconPlayerPlay,
|
||||
IconReceipt,
|
||||
IconSearch,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -17,12 +18,14 @@ import combineClasses from "clsx";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { RequestStats } from "@homarr/integrations/types";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
const OVERSEERR_COLOR = "#ECB000";
|
||||
const JELLYSEERR_COLOR = "#6677CC";
|
||||
@@ -95,83 +98,104 @@ export default function MediaServerWidget({
|
||||
const isTiny = width < 256;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={searchClasses.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { AppFormHandle } from "@homarr/forms-collection";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export interface EmbeddedAppEditFormHandle {
|
||||
submitIfDirty: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface EmbeddedAppEditFormProps {
|
||||
appId: string;
|
||||
handleRef: React.Ref<EmbeddedAppEditFormHandle>;
|
||||
}
|
||||
|
||||
export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormProps) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const appFormRef = useRef<AppFormHandle>(null);
|
||||
const { data: app, isPending: isLoadingApp, isError, refetch } = clientApi.app.byId.useQuery({ id: appId });
|
||||
|
||||
const { mutateAsync, isPending: isMutating } = clientApi.app.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.app.invalidate();
|
||||
showSuccessNotification({
|
||||
title: t("app.page.edit.notification.success.title"),
|
||||
message: t("app.page.edit.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("app.page.edit.notification.error.title"),
|
||||
message: t("app.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => ({
|
||||
submitIfDirty: async () => {
|
||||
if (!appFormRef.current?.isDirty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return appFormRef.current.submit();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isLoadingApp) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !app) {
|
||||
return (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<Text c="dimmed">{t("app.action.select.notFound")}</Text>
|
||||
<Button variant="light" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.app.propagationNotice")}
|
||||
</Alert>
|
||||
<AppForm
|
||||
formRef={appFormRef}
|
||||
hideButtons
|
||||
showBackToOverview={false}
|
||||
buttonLabels={{ submit: "" }}
|
||||
initialValues={app}
|
||||
handleSubmit={async (values) => {
|
||||
await mutateAsync({ id: appId, ...values });
|
||||
}}
|
||||
isPending={isMutating}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Button, CloseButton, ColorInput, Group, Input, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
@@ -34,6 +34,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("item.edit.field.title.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("title")}
|
||||
rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />}
|
||||
/>
|
||||
@@ -53,12 +54,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.saveChanges")}</Button>
|
||||
</Group>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
@@ -66,7 +62,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.advancedOptions.title");
|
||||
},
|
||||
size: "lg",
|
||||
size: modalSizeForm,
|
||||
transitionProps: {
|
||||
duration: 0,
|
||||
},
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import { useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Button, Group, Stack, Tabs } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zodErrorMap } from "@homarr/validation/form/i18n";
|
||||
@@ -20,6 +22,8 @@ import type { OptionsBuilderResult } from "../options";
|
||||
import type { IntegrationSelectOption } from "../widget-integration-select";
|
||||
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
||||
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
|
||||
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
|
||||
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
|
||||
|
||||
export interface WidgetEditModalState {
|
||||
options: Record<string, unknown>;
|
||||
@@ -34,13 +38,16 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Translate the error messages
|
||||
z.config({
|
||||
customError: zodErrorMap(t),
|
||||
});
|
||||
@@ -77,55 +84,97 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
});
|
||||
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit({
|
||||
...values,
|
||||
advancedOptions,
|
||||
});
|
||||
actions.closeModal();
|
||||
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
|
||||
const appId = innerProps.appId;
|
||||
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const appSaved = showAppTab ? await (appEditRef.current?.submitIfDirty() ?? true) : true;
|
||||
|
||||
if (!appSaved) {
|
||||
return;
|
||||
}
|
||||
|
||||
innerProps.onSuccessfulEdit({
|
||||
...values,
|
||||
advancedOptions,
|
||||
});
|
||||
actions.closeModal();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const onFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
handleSubmit(event);
|
||||
};
|
||||
|
||||
const widgetFormContent = (
|
||||
<Stack>
|
||||
{innerProps.integrationSupport && (
|
||||
<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")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (
|
||||
!Input ||
|
||||
value.shouldHide?.(
|
||||
form.values.options as never,
|
||||
innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<FormProvider form={form}>
|
||||
<Stack>
|
||||
{innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
!Input ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
value.shouldHide?.(
|
||||
form.values.options as never,
|
||||
innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Group justify="space-between">
|
||||
{showAppTab ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess(options) {
|
||||
setAdvancedOptions(options);
|
||||
innerProps.onSuccessfulEdit({
|
||||
...innerProps.value,
|
||||
advancedOptions: options,
|
||||
});
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("item.edit.advancedOptions.label")}
|
||||
</Button>
|
||||
) : (
|
||||
<ModalFormFooter
|
||||
onCancel={actions.closeModal}
|
||||
leftSection={
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
@@ -141,14 +190,41 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
>
|
||||
{t("item.edit.advancedOptions.label")}
|
||||
</Button>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={onFormSubmit}>
|
||||
<FormProvider form={form}>
|
||||
{showAppTab ? (
|
||||
<Stack>
|
||||
<Tabs defaultValue="widget">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
||||
<Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="md">
|
||||
{widgetFormContent}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="app" pt="md">
|
||||
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.saveChanges")}</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
widgetFormContent
|
||||
)}
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
@@ -157,5 +233,5 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: "lg",
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -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,256 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Box, Card, Group, Progress, RingProgress, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatDuration } from "@homarr/common";
|
||||
import type { UpsStatus, UpsSummary } from "@homarr/integrations/types";
|
||||
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
||||
|
||||
const statusColors: Record<UpsStatus, string> = {
|
||||
online: "green",
|
||||
charging: "blue",
|
||||
onBattery: "yellow",
|
||||
lowBattery: "red",
|
||||
unknown: "gray",
|
||||
};
|
||||
|
||||
type UpsLayout = "mini" | "compact" | "full";
|
||||
|
||||
export default function UpsWidget({ options, integrationIds, isEditMode, width }: WidgetComponentProps<"ups">) {
|
||||
if (integrationIds.length === 0) {
|
||||
throw new NoIntegrationSelectedError();
|
||||
}
|
||||
|
||||
return <UpsContent integrationIds={integrationIds} options={options} isEditMode={isEditMode} width={width} />;
|
||||
}
|
||||
|
||||
interface UpsContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"ups">["options"];
|
||||
isEditMode: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function UpsContent({ integrationIds, options, isEditMode, width }: UpsContentProps) {
|
||||
const t = useScopedI18n("widget.ups");
|
||||
const [data] = clientApi.widget.ups.getSummaries.useSuspenseQuery({ integrationIds });
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.ups.subscribeSummaries.useSubscription(
|
||||
{ integrationIds },
|
||||
{
|
||||
enabled: !isEditMode,
|
||||
onData(newData) {
|
||||
utils.widget.ups.getSummaries.setData({ integrationIds }, (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return prevData.map((instance) =>
|
||||
instance.integrationId === newData.integrationId
|
||||
? { ...instance, summaries: newData.summaries, updatedAt: newData.timestamp }
|
||||
: instance,
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const devices = data.flatMap((instance) =>
|
||||
instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })),
|
||||
);
|
||||
|
||||
if (devices.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
}
|
||||
|
||||
// Pick a layout from the available width so the widget stays useful at any size. The surrounding
|
||||
// ScrollArea keeps every device reachable at any height (e.g. a tall, narrow 1x3 widget scrolls).
|
||||
// - full: ring beside a column of stats (runtime, load, voltage)
|
||||
// - compact (~2 columns): ring beside the name and a status badge
|
||||
// - mini (~1 column): a text badge no longer fits, so status becomes a colour dot next to the name
|
||||
const layout: UpsLayout = width < 150 ? "mini" : width < 256 ? "compact" : "full";
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
{devices.map(({ key, summary }) => (
|
||||
<UpsDeviceCard key={key} summary={summary} options={options} layout={layout} t={t} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpsDeviceCardProps {
|
||||
summary: UpsSummary;
|
||||
options: WidgetComponentProps<"ups">["options"];
|
||||
layout: UpsLayout;
|
||||
t: ScopedTranslationFunction<"widget.ups">;
|
||||
}
|
||||
|
||||
function UpsDeviceCard({ summary, options, layout, t }: UpsDeviceCardProps) {
|
||||
const showRing = options.showBattery && summary.batteryCharge !== null;
|
||||
const statusBadge = (
|
||||
<Badge
|
||||
color={statusColors[summary.status]}
|
||||
variant="light"
|
||||
size={layout === "full" ? "sm" : "xs"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t(`status.${summary.status}`)}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (layout === "mini") {
|
||||
return (
|
||||
<Card p={6} radius="md">
|
||||
<Group gap={4} wrap="nowrap" justify="center" mb={showRing ? 4 : 0}>
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
backgroundColor: `var(--mantine-color-${statusColors[summary.status]}-6)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text fw={600} size="xs" truncate>
|
||||
{summary.name}
|
||||
</Text>
|
||||
</Group>
|
||||
{showRing && summary.batteryCharge !== null && (
|
||||
<Group justify="center">
|
||||
<RingProgress
|
||||
size={44}
|
||||
thickness={4}
|
||||
roundCaps
|
||||
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
|
||||
label={
|
||||
<Text ta="center" size="10px" fw={700}>
|
||||
{Math.round(summary.batteryCharge)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<Card p={6} radius="md">
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
{showRing && summary.batteryCharge !== null && (
|
||||
<RingProgress
|
||||
size={40}
|
||||
thickness={4}
|
||||
roundCaps
|
||||
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
|
||||
label={
|
||||
<Text ta="center" size="9px" fw={700}>
|
||||
{Math.round(summary.batteryCharge)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={600} size="xs" truncate>
|
||||
{summary.name}
|
||||
</Text>
|
||||
{statusBadge}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card p="xs" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{summary.name}
|
||||
</Text>
|
||||
{statusBadge}
|
||||
</Group>
|
||||
|
||||
<Group align="center" wrap="nowrap" gap="md">
|
||||
{showRing && summary.batteryCharge !== null && (
|
||||
<RingProgress
|
||||
size={68}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700}>
|
||||
{Math.round(summary.batteryCharge)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{options.showBattery && summary.batteryRuntime !== null && (
|
||||
<StatRow label={t("field.runtime")} value={formatDuration(summary.batteryRuntime * 1000)} />
|
||||
)}
|
||||
|
||||
{options.showLoad && summary.load !== null && (
|
||||
<Box>
|
||||
<Group justify="space-between" gap="xs" mb={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("field.load")}
|
||||
</Text>
|
||||
<Text size="xs">{Math.round(summary.load)}%</Text>
|
||||
</Group>
|
||||
<Progress value={clampPercent(summary.load)} size="sm" color={getLoadColor(summary.load)} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{options.showVoltage && (summary.inputVoltage !== null || summary.outputVoltage !== null) && (
|
||||
<Group gap="md">
|
||||
{summary.inputVoltage !== null && (
|
||||
<StatRow label={t("field.input")} value={`${Math.round(summary.inputVoltage)} V`} />
|
||||
)}
|
||||
{summary.outputVoltage !== null && (
|
||||
<StatRow label={t("field.output")} value={`${Math.round(summary.outputVoltage)} V`} />
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function getChargeColor(charge: number): string {
|
||||
if (charge > 50) return "green";
|
||||
if (charge > 20) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
function getLoadColor(load: number): string {
|
||||
if (load > 90) return "red";
|
||||
if (load > 70) return "yellow";
|
||||
return "blue";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IconBatteryCharging, IconBatteryOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("ups", {
|
||||
icon: IconBatteryCharging,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showBattery: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showLoad: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showVoltage: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("ups"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconBatteryOff,
|
||||
message: (t) => t("widget.ups.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