feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
boardId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
@@ -17,7 +17,13 @@ export interface DynamicSelectOption {
label: string;
}
export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => {
export const WidgetDynamicSelectInput = ({
property,
kind,
options,
itemId,
boardId,
}: CommonWidgetInputProps<"dynamicSelect">) => {
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -28,6 +34,8 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
debouncedSearch,
form.values.integrationIds,
form.values.options,
itemId,
boardId,
);
const currentOption = inputProps.value as DynamicSelectOption | null;
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
+115 -21
View File
@@ -3,13 +3,14 @@
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
@@ -106,6 +107,13 @@ const stringifyDelta = (delta: unknown): string => {
return JSON.stringify(emptyDelta());
};
export const storedContentToPlainText = (content?: string | null): string => {
return parseStoredContent(content)
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
};
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
};
@@ -119,7 +127,14 @@ const isForbiddenError = (error: unknown): boolean => {
return (data as { code?: unknown }).code === "FORBIDDEN";
};
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
export default function AnchorNoteWidget({
options,
integrationIds,
width,
height,
displayMode,
widgetStateRef,
}: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
@@ -134,27 +149,74 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
return <AnchorNoteWidgetContent options={options} integrationId={integrationId} noteId={noteId} />;
return (
<AnchorNoteWidgetContent
options={options}
integrationId={integrationId}
noteId={noteId}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
widgetStateRef={widgetStateRef}
/>
);
}
interface AnchorNotePersistedState {
integrationId: string;
noteId: string;
isEditing: boolean;
draftTitle: string;
draftContent: string;
saveError: string | null;
}
interface AnchorNoteWidgetContentProps {
options: WidgetComponentProps<"anchorNote">["options"];
integrationId: string;
noteId: string;
width: number;
height: number;
isAdvanced: boolean;
widgetStateRef?: WidgetComponentProps<"anchorNote">["widgetStateRef"];
}
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
const AnchorNoteWidgetContent = ({
options,
integrationId,
noteId,
width,
height,
isAdvanced,
widgetStateRef,
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const persistedState = widgetStateRef?.current?.anchorNote as AnchorNotePersistedState | undefined;
const canHydratePersistedState = persistedState?.integrationId === integrationId && persistedState.noteId === noteId;
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
const [draftContent, setDraftContent] = useState(note?.content ?? "");
const [saveError, setSaveError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
const [draftTitle, setDraftTitle] = useState(
canHydratePersistedState ? (persistedState?.draftTitle ?? "") : (note?.title ?? ""),
);
const [draftContent, setDraftContent] = useState(
canHydratePersistedState ? (persistedState?.draftContent ?? "") : (note?.content ?? ""),
);
const [saveError, setSaveError] = useState<string | null>(
canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
anchorNote: { integrationId, noteId, isEditing, draftTitle, draftContent, saveError },
};
}, [draftContent, draftTitle, integrationId, isEditing, noteId, saveError, widgetStateRef]);
useEffect(() => {
if (isEditing || !note) return;
@@ -165,7 +227,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
});
}, [isEditing, note]);
const canEdit = canEditPermission(note?.permission ?? "viewer");
const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess;
const isViewer = !note || note.permission === "viewer";
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
@@ -178,6 +241,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
@@ -260,7 +324,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
if (!note) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="xs" p="sm">
<Stack h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
<Group justify="space-between" align="flex-start">
<Stack gap={2} style={{ flex: 1 }}>
{isEditing ? (
@@ -278,6 +342,22 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
{t("readOnlyViewer")}
</Text>
)}
{!isEditing && isAdvanced && (
<Group gap={4}>
<Badge size="xs" variant="light">
{t(`permission.${note.permission}`)}
</Badge>
{note.isPinned && <Badge size="xs">{t("status.pinned")}</Badge>}
{note.isArchived && (
<Badge size="xs" color="gray">
{t("status.archived")}
</Badge>
)}
<Text size="xs" c="dimmed">
{t("createdAt", { date: new Date(note.createdAt).toLocaleDateString() })}
</Text>
</Group>
)}
{saveError && (
<Text size="xs" c="red">
{saveError}
@@ -301,17 +381,31 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
)}
</Group>
</Group>
<div className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`} style={{ flex: 1 }}>
<ReactQuill
theme="snow"
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
placeholder={t("emptyContent")}
/>
</div>
{isEditing || isAdvanced ? (
<div
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
style={{ flex: 1, minHeight: 0 }}
>
<ReactQuill
theme="snow"
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
placeholder={t("emptyContent")}
/>
</div>
) : (
<Text
size={width < 180 || height < 100 ? "xs" : "sm"}
c={plainText ? undefined : "dimmed"}
style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}
lineClamp={Math.max(2, Math.floor((height - 70) / 18))}
>
{plainText || t("emptyContent")}
</Text>
)}
</Stack>
);
};
@@ -5,7 +5,9 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
supportsAdvancedFocus: true,
queryKey: [["widget", "anchorNotes"]],
maxIntegrations: 1,
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
+19 -5
View File
@@ -19,7 +19,7 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
@@ -51,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isAdvanced = displayMode === "advanced";
const isColumnLayout = options.layout.startsWith("column");
return (
@@ -83,7 +84,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
{(options.showTitle || isAdvanced) && (
<Text
className="app-title"
fw={700}
@@ -93,13 +94,13 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
{(options.descriptionDisplayMode === "normal" || isAdvanced) && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
lineClamp={isAdvanced ? 10 : Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -109,6 +110,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
))}
</Text>
)}
{isAdvanced && app.href && (
<Text size="xs" c="dimmed" lineClamp={1}>
{getDisplayHost(app.href)}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
@@ -119,7 +125,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : "50%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
@@ -154,3 +160,11 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
) : (
children
);
const getDisplayHost = (href: string) => {
try {
return new URL(href).host;
} catch {
return href;
}
};
@@ -1,16 +1,20 @@
"use client";
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const integrationId = integrationIds[0];
@@ -18,23 +22,39 @@ export default function ArchiveTeamWarriorWidget({
return null;
}
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
return (
<ArchiveTeamWarriorWidgetContent
integrationId={integrationId}
options={options}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
/>
);
}
const ArchiveTeamWarriorWidgetContent = ({
integrationId,
options,
width,
height,
isAdvanced,
}: {
integrationId: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
width: number;
height: number;
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
const statusKey = getStatusKey(status.status);
return (
<Stack p="xs" gap="xs" h="100%">
<Stack p="xs" gap="xs" h="100%" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
@@ -43,12 +63,12 @@ const ArchiveTeamWarriorWidgetContent = ({
</Text>
</Group>
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
{status.status}
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
</Group>
{options.showBroadcastMessage && status.broadcastMessage && (
{options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && (
<Card withBorder p="xs">
<Text size="xs" lineClamp={3}>
{status.broadcastMessage}
@@ -56,19 +76,52 @@ const ArchiveTeamWarriorWidgetContent = ({
</Card>
)}
<SimpleGrid cols={2} spacing="xs">
<Metric label="Running" value={status.counts.running} />
<Metric label="Done" value={status.counts.completed} />
<Metric label="Failed" value={status.counts.failed} />
<Metric label="Canceled" value={status.counts.canceled} />
<SimpleGrid cols={width < 180 || height < 120 ? 4 : 2} spacing="xs">
<Metric label={t("metric.running")} value={status.counts.running} />
<Metric label={t("metric.completed")} value={status.counts.completed} />
<Metric label={t("metric.failed")} value={status.counts.failed} />
<Metric label={t("metric.canceled")} value={status.counts.canceled} />
</SimpleGrid>
{status.bandwidth && (
<SimpleGrid cols={2} spacing="xs">
<Metric label="Download" value={formatBandwidth(status.bandwidth.receiving)} />
<Metric label="Upload" value={formatBandwidth(status.bandwidth.sending)} />
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} />
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} />
</SimpleGrid>
)}
{isAdvanced && (
<>
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
</Text>
<Text size="xs" c="dimmed">
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })}
</Text>
</Group>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={4}>
{status.items.map((item) => (
<Card key={item.id} withBorder p="xs">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={600} lineClamp={1}>
{item.name}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{item.project ?? projectName}
</Text>
</Stack>
<Badge size="xs" color={getStatusColor(item.status)}>
{t(`status.${item.status}`)}
</Badge>
</Group>
</Card>
))}
</Stack>
</ScrollArea>
</>
)}
</Stack>
);
};
@@ -85,3 +138,33 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
);
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
const getStatusKey = (status?: string | null): WarriorStatusKey => {
switch (status?.trim().toLowerCase()) {
case "running":
return "running";
case "done":
case "completed":
return "completed";
case "failed":
return "failed";
case "canceled":
case "cancelled":
return "canceled";
case "stopped":
return "stopped";
case "idle":
return "idle";
default:
return "unknown";
}
};
const getStatusColor = (status: WarriorStatusKey): string => {
if (status === "running") return "green";
if (status === "failed") return "red";
if (status === "canceled" || status === "stopped") return "yellow";
return "gray";
};
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
queryKey: [["widget", "archiveTeamWarrior"]],
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
+76 -2
View File
@@ -1,17 +1,91 @@
"use client";
import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatBytes, formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
export default function AudioStatsWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
{ integrationIds, showOnlyPlaying: false },
{ enabled: displayMode === "advanced" && response?.kind === "navidrome" },
);
if (!response) return <WidgetEmptyState />;
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
const summary = (
<AudioStatsContent
backend={response.kind}
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
width={width}
/>
);
if (displayMode === "compact") return summary;
const sessions = streamResults.flatMap((result) =>
result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
);
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
return (
<Stack h="100%" gap="lg" p="md">
<div style={{ minHeight: 150 }}>{summary}</div>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{response.kind === "navidrome" ? (
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="xs">
{sessions.map(({ integrationId, session }) => {
const currentlyPlaying = session.currentlyPlaying;
if (!currentlyPlaying) return null;
return (
<Group key={`${integrationId}:${session.sessionId}`} justify="space-between" wrap="nowrap" p="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{currentlyPlaying.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
</Text>
</Stack>
<Badge variant="light">{session.sessionName}</Badge>
</Group>
);
})}
</SimpleGrid>
) : (
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="xs">
{(audiobookStats?.libraries ?? []).map((library) => (
<Stack key={library.id} gap={2} p="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{library.name}
</Text>
<Badge size="xs" variant="light">
{library.totalItems}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "}
{library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</ScrollArea>
</Stack>
);
}
@@ -13,6 +13,7 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]],
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
+19 -8
View File
@@ -36,7 +36,13 @@ const iconSizeByWidth = [
{ minWidth: 0, size: 16 },
] as const;
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
export default function BazarrWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
@@ -53,11 +59,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
} as const;
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const gridCols = getGridCols(width);
const iconSize = getIconSize(width);
const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
const iconSize = getIconSize(Math.min(width, height));
if (visibleStatKeys.length === 0) {
return (
@@ -92,12 +98,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
);
}
function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
if (itemCount <= 1) return 1;
if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const rowsAtWidth = Math.ceil(itemCount / widthMatch);
const rowHeight = height / rowsAtWidth;
return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
}
function getIconSize(width: number): number {
export function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
+2
View File
@@ -5,8 +5,10 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissingEpisodes: factory.switch({
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { getGridCols, getIconSize } from "./component";
describe("Bazarr responsive layout", () => {
test("uses two columns for a short widget so all stats remain visible", () => {
expect(getGridCols(220, 100, 4)).toBe(2);
});
test("uses four columns in a wide advanced surface", () => {
expect(getGridCols(800, 400, 4, true)).toBe(4);
});
test("sizes icons from the constrained dimension", () => {
expect(getIconSize(180)).toBe(16);
expect(getIconSize(340)).toBe(22);
});
});
@@ -35,6 +35,8 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -67,6 +69,8 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
if (alertsError) throw alertsError;
@@ -117,9 +121,9 @@ export default function BeszelAlertsWidget({
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
{okAlerts.length > 0 && (
{showOkAlerts && okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
@@ -140,7 +144,7 @@ export default function BeszelAlertsWidget({
</Stack>
)}
{options.showHistory && history.length > 0 && (
{showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}>
@@ -330,7 +330,9 @@ const metricRenderers = [
] as const;
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
return (
<Card
@@ -362,6 +364,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
{hiddenMetricCount > 0 && (
<Text size="10px" c="dimmed" ta="right">
+{hiddenMetricCount}
</Text>
)}
</Card>
);
};
@@ -372,6 +379,7 @@ export default function BeszelSystemGridWidget({
isEditMode,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard();
@@ -411,14 +419,18 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, height, filteredSystems.length);
const advanced = displayMode === "advanced";
const cols = advanced
? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
: getColCount(width, height, 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 minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT;
const scrollEnabled = rawCellHeight < minimumCellHeight;
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
@@ -428,7 +440,7 @@ export default function BeszelSystemGridWidget({
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -16,12 +16,15 @@ import type { WidgetComponentProps } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
height,
displayMode = "compact",
boardId,
itemId,
setOptions,
@@ -37,13 +40,11 @@ export default function BeszelSystemStatsWidget({
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
const handleSelectSystem = useCallback(
(value: string) => {
@@ -55,6 +56,16 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const handleTimePeriod = useCallback(
(value: string) => {
setOptions({ newOptions: { timePeriod: value as BeszelTimePeriod } });
if (hasChangeAccess && boardId && itemId) {
saveItemOptions({ boardId, itemId, newOptions: { timePeriod: value } });
}
},
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
);
if (systemsError) throw systemsError;
if (systemsPending)
return (
@@ -90,7 +101,7 @@ export default function BeszelSystemStatsWidget({
<Select
size="xs"
data={systems}
value={selectedSystem}
value={selectedValue}
placeholder={t("selectSystem")}
onChange={(value) => value && handleSelectSystem(value)}
/>
@@ -127,8 +138,8 @@ export default function BeszelSystemStatsWidget({
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
@@ -137,20 +148,35 @@ export default function BeszelSystemStatsWidget({
</Menu.Dropdown>
</Menu>
)}
{!isEditMode && displayMode === "advanced" && (
<SegmentedControl
size="xs"
value={options.timePeriod}
onChange={handleTimePeriod}
data={[
{ value: "1m", label: t("period.live") },
{ value: "1h", label: t("period.oneHour") },
{ value: "12h", label: t("period.twelveHours") },
{ value: "24h", label: t("period.oneDay") },
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
]}
/>
)}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
columns={displayMode === "advanced" || width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
network: options.showNetwork && (displayMode === "advanced" || height >= 260),
dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
@@ -4,6 +4,7 @@ import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { createBeszelSystemChoices } from "./selection";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
@@ -30,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
isPending,
isError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 });
const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
const selectData = createBeszelSystemChoices(data).map(({ value, label }) => ({ value, label }));
return { data: selectData, isPending, isError };
},
}),
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
const choices = createBeszelSystemChoices([
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
{ integrationId: "two", integrationName: "Backup", systems: [{ id: "same", name: "Host B" }] },
]);
describe("Beszel system selection", () => {
it("uses a composite integration and system identity", () => {
expect(choices.map((choice) => choice.value)).toEqual(["one:same", "two:same"]);
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
});
it("keeps legacy plain system ids working", () => {
expect(resolveBeszelSystemChoice(choices, "same")?.integrationId).toBe("one");
});
});
@@ -0,0 +1,32 @@
export interface BeszelSystemChoice {
value: string;
label: string;
integrationId: string;
systemId: string;
}
interface BeszelSystemsResult {
integrationId: string;
integrationName: string;
systems: { id: string; name: string }[];
}
export function createBeszelSystemChoices(results: BeszelSystemsResult[]): BeszelSystemChoice[] {
const includeIntegrationName = results.length > 1;
return results.flatMap((result) =>
result.systems.map((system) => ({
value: `${result.integrationId}:${system.id}`,
label: includeIntegrationName ? `${result.integrationName} · ${system.name}` : system.name,
integrationId: result.integrationId,
systemId: system.id,
})),
);
}
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
return (
choices.find((choice) => choice.value === storedValue) ??
choices.find((choice) => choice.systemId === storedValue) ??
choices[0]
);
}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import { Box, Center, Group, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import {
Activity,
@@ -20,12 +20,14 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { getUsableWidgetQueryData } from "../common/query-state";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
@@ -38,16 +40,24 @@ import {
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { getBeszelTableVisibleMetricKeys } from "./display";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string };
const columnAccessors = [
"name",
"integrationName",
"hostname",
"osName",
"cpuModel",
"cores",
"memoryTotal",
"cpu",
"memory",
"disk",
@@ -59,7 +69,7 @@ const columnAccessors = [
"services",
"uptime",
"agentVersion",
] as const satisfies readonly (keyof BeszelSystemRow)[];
] as const satisfies readonly (keyof SystemRowWithKey)[];
interface SizeConfig {
iconSize: number;
@@ -96,18 +106,23 @@ export default function BeszelSystemTableWidget({
boardId,
itemId,
setOptions,
displayMode,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsData = getUsableWidgetQueryData(systemsQuery);
const results = useMemo(() => systemsData ?? [], [systemsData]);
const { isPending } = systemsQuery;
const isAdvanced = displayMode === "advanced";
const size = useMemo(() => getSizeConfig(width), [width]);
const visibleMetricKeys = useMemo(
() => getBeszelTableVisibleMetricKeys(options, width, isAdvanced),
[options, width, isAdvanced],
);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
@@ -125,6 +140,18 @@ export default function BeszelSystemTableWidget({
});
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const integrationNames = useMemo(
() => new Map(results.map((result) => [result.integrationId, result.integrationName])),
[results],
);
const systemsWithSource = useMemo(
() =>
filteredSystems.map((system) => ({
...system,
integrationName: integrationNames.get(system.rowKey.split(":")[0] ?? "") ?? "—",
})),
[filteredSystems, integrationNames],
);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
@@ -138,16 +165,16 @@ export default function BeszelSystemTableWidget({
}, [options.sortBy, options.sortDirection]);
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...filteredSystems].toSorted((a, b) => {
return [...systemsWithSource].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]);
}, [systemsWithSource, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
@@ -178,14 +205,62 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} />
<Box w={7} h={7} bg={statusColorMap[record.status]} style={{ borderRadius: "50%", flexShrink: 0 }} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
</Text>
</Group>
),
},
options.showCpu && {
isAdvanced && {
accessor: "integrationName",
width: 140,
ellipsis: true,
title: t("column.source"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
},
isAdvanced && {
accessor: "hostname",
width: 150,
ellipsis: true,
title: t("column.hostname"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
},
isAdvanced && {
accessor: "osName",
width: 160,
ellipsis: true,
title: t("column.os"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
},
isAdvanced && {
accessor: "cpuModel",
width: 220,
ellipsis: true,
title: t("column.cpuModel"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
},
isAdvanced && {
accessor: "cores",
width: 80,
title: t("column.cores"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
},
isAdvanced && {
accessor: "memoryTotal",
width: 120,
title: t("column.memoryTotal"),
sortable: true,
render: (record) => (
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
),
},
visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
width: 140,
title: (
@@ -197,7 +272,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
},
options.showMemory && {
visibleMetricKeys.has("showMemory") && {
accessor: "memory",
width: 140,
title: (
@@ -209,7 +284,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
},
options.showDisk && {
visibleMetricKeys.has("showDisk") && {
accessor: "disk",
width: 160,
title: (
@@ -229,7 +304,7 @@ export default function BeszelSystemTableWidget({
/>
),
},
options.showGpu && {
visibleMetricKeys.has("showGpu") && {
accessor: "gpu",
width: 140,
title: (
@@ -241,7 +316,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
},
options.showLoadAvg && {
visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
width: 100,
title: (
@@ -253,12 +328,17 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
<Box
w={7}
h={7}
bg={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
</Group>
),
},
options.showNet && {
visibleMetricKeys.has("showNet") && {
accessor: "netBytes",
width: 100,
title: (
@@ -274,7 +354,7 @@ export default function BeszelSystemTableWidget({
</Text>
),
},
options.showTemp && {
visibleMetricKeys.has("showTemp") && {
accessor: "temp",
width: 80,
title: (
@@ -286,7 +366,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
visibleMetricKeys.has("showBattery") && {
accessor: "battery",
width: 70,
title: (
@@ -297,7 +377,7 @@ export default function BeszelSystemTableWidget({
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
},
options.showServices && {
visibleMetricKeys.has("showServices") && {
accessor: "services",
width: 80,
title: (
@@ -309,7 +389,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
visibleMetricKeys.has("showUptime") && {
accessor: "uptime",
width: 90,
title: (
@@ -321,7 +401,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
visibleMetricKeys.has("showAgent") && {
accessor: "agentVersion",
width: 90,
title: (
@@ -336,21 +416,7 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [
options.showAgent,
options.showBattery,
options.showCpu,
options.showDisk,
options.showGpu,
options.showLoadAvg,
options.showMemory,
options.showNet,
options.showServices,
options.showTemp,
options.showUptime,
size,
t,
]);
}, [t, size, visibleMetricKeys, isAdvanced]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
@@ -364,14 +430,12 @@ export default function BeszelSystemTableWidget({
const handleRowClick = useCallback(
({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
const integrationId = record.rowKey.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
},
[openModal],
);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
@@ -380,10 +444,13 @@ export default function BeszelSystemTableWidget({
);
}
return (
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -394,10 +461,12 @@ export default function BeszelSystemTableWidget({
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
idAccessor="rowKey"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
);
return table;
}
+88 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
@@ -12,11 +12,11 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
export default function BookmarksWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(data) {
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
@@ -48,7 +48,16 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
{options.title}
</Title>
)}
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
{displayMode === "advanced" && (
<AdvancedBookmarksLayout
data={data}
width={width}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && (options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
@@ -60,7 +69,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hasIconColor={board.iconColor !== null}
/>
)}
{options.layout !== "grid" && options.layout !== "gridHorizontal" && (
{displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && (
<FlexLayout
data={data}
direction={options.layout}
@@ -144,6 +153,77 @@ const FlexLayout = ({
);
};
interface AdvancedBookmarksLayoutProps {
data: RouterOutputs["app"]["byIds"];
width: number;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
export const getBookmarkHostname = (href: string | null): string | undefined => {
if (!href) return undefined;
try {
return new URL(href).hostname;
} catch {
return href;
}
};
const AdvancedBookmarksLayout = ({
data,
width,
openNewTab,
withBorder,
hasIconColor,
}: AdvancedBookmarksLayoutProps) => {
const board = useRequiredBoard();
return (
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => (
<UnstyledButton
key={app.id}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
</ScrollArea>
);
};
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
hideTitle: boolean;
@@ -247,7 +327,7 @@ const VerticalItem = ({
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
@@ -294,7 +374,7 @@ const HorizontalItem = ({
{!hideHostname && (
<Anchor component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
+2 -1
View File
@@ -10,7 +10,8 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
queryKeys: [[["app", "byIds"]]],
supportsAdvancedFocus: true,
queryKey: [["app", "byIds"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { getAdvancedBookmarkColumns, getBookmarkHostname } from "./component";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
});
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
expect(getBookmarkHostname(null)).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import { useId } from "react";
import {
Badge,
Box,
@@ -18,15 +19,25 @@ import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { groupEventsByDate } from "./calendar-events";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
groupByDate?: boolean;
locale?: string;
}
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
export const CalendarEventList = ({ events, groupByDate = false, locale }: CalendarEventListProps) => {
const headingIdPrefix = useId();
const dateFormatter = new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null;
return (
<ScrollArea
offsetScrollbars
@@ -38,129 +49,155 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
},
}}
>
<Stack>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
w={70}
mah={150}
style={{
aspectRatio: event.image.aspectRatio
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
{event.image.badge.content}
</Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
{event.title}
</Text>
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</>
)}
</Group>
</Group>
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
<Stack gap="md">
{groups ? (
groups.map(([date, groupedEvents]) => {
const headingId = `${headingIdPrefix}-${date}`;
return (
<Box key={date} component="section" aria-labelledby={headingId}>
<Text id={headingId} component="h3" size="sm" fw={600} tt="capitalize">
{dateFormatter.format(groupedEvents[0]?.startDate)}
</Text>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
<Stack mt="xs">
<CalendarEventRows events={groupedEvents} />
</Stack>
</Box>
);
})
) : (
<CalendarEventRows events={events} />
)}
</Stack>
</ScrollArea>
);
};
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return (
<>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
w={70}
mah={150}
style={{
aspectRatio: event.image.aspectRatio
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
{event.image.badge.content}
</Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
{event.title}
</Text>
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</>
)}
</Group>
</Group>
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
</Text>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
</>
);
};
const isAllDay = (event: Pick<CalendarEvent, "startDate" | "endDate">) => {
if (!event.endDate) return false;
@@ -0,0 +1,77 @@
import dayjs from "dayjs";
import type { CalendarEvent } from "@homarr/integrations/types";
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
export const getCalendarAgendaEvents = (
events: CalendarEvent[],
viewedMonth: Date,
releaseTypes: readonly string[],
): CalendarEvent[] => {
const monthStart = dayjs(viewedMonth).startOf("month");
const monthEnd = monthStart.add(1, "month");
return events
.filter((event) => {
if (!matchesReleaseType(event, releaseTypes)) return false;
const start = dayjs(event.startDate);
if (!event.endDate) return !start.isBefore(monthStart) && start.isBefore(monthEnd);
// Calendar intervals are half-open: an all-day event ending at midnight does not occupy the next day.
return start.isBefore(monthEnd) && dayjs(event.endDate).isAfter(monthStart);
})
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
};
export const groupEventsByDate = (events: CalendarEvent[]): Map<string, CalendarEvent[]> => {
const result = new Map<string, CalendarEvent[]>();
for (const event of events) {
const key = toCalendarDateKey(event.startDate);
const entries = result.get(key) ?? [];
entries.push(event);
result.set(key, entries);
}
return new Map(
[...result.entries()]
.toSorted(([dateA], [dateB]) => dateA.localeCompare(dateB))
.map(([date, entries]) => [
date,
entries.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()),
]),
);
};
/** Splits multi-day events into single-day calendar tiles. */
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
const splitEventEntries: CalendarEvent[] = [];
for (const event of events) {
if (!event.endDate) {
splitEventEntries.push(event);
continue;
}
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
splitEventEntries.push(event);
continue;
}
if (dayjs(event.startDate).isAfter(event.endDate)) continue;
let currentStart = dayjs(event.startDate);
while (currentStart.isBefore(event.endDate)) {
splitEventEntries.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
}
}
return splitEventEntries;
};
+40 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { CalendarEvent } from "@homarr/integrations/types";
import { splitEvents } from "./component";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents } from "./calendar-events";
describe("splitEvents should split multi-day events into multiple single-day events", () => {
test("2 day all-day event should be split up into two all-day events", () => {
@@ -53,6 +53,45 @@ describe("splitEvents should split multi-day events into multiple single-day eve
});
});
describe("groupEventsByDate", () => {
test("groups once per day and sorts each bucket", () => {
const later = createEvent(new Date(2025, 0, 1, 18));
const earlier = createEvent(new Date(2025, 0, 1, 9));
const nextDay = createEvent(new Date(2025, 0, 2, 9));
const result = groupEventsByDate([later, nextDay, earlier]);
expect(result.size).toBe(2);
expect([...result.values()][0]).toEqual([earlier, later]);
});
});
describe("getCalendarAgendaEvents", () => {
test("keeps overlapping original events once and excludes adjacent-month events", () => {
const spansIntoMonth = createEvent(new Date(2024, 11, 31, 22), new Date(2025, 0, 2, 8));
const inMonth = createEvent(new Date(2025, 0, 20, 9));
const endsAtMonthStart = createEvent(new Date(2024, 11, 31), new Date(2025, 0, 1));
const nextMonth = createEvent(new Date(2025, 1, 1));
const result = getCalendarAgendaEvents(
[nextMonth, spansIntoMonth, endsAtMonthStart, inMonth],
new Date(2025, 0, 15),
[],
);
expect(result).toEqual([spansIntoMonth, inMonth]);
});
test("applies Radarr release filters without splitting multi-day events", () => {
const cinema = createEvent(new Date(2025, 0, 5), new Date(2025, 0, 8));
cinema.metadata = { type: "radarr", releaseType: "inCinemas" };
const digital = createEvent(new Date(2025, 0, 7));
digital.metadata = { type: "radarr", releaseType: "digitalRelease" };
expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]);
});
});
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
title: "Test",
subTitle: null,
+123 -122
View File
@@ -1,8 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
@@ -14,10 +14,24 @@ import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
import { CalendarEventList } from "./calendar-event-list";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
const persistedState = props.widgetStateRef?.current?.calendar as { monthTimestamp?: number } | undefined;
const [month, setMonth] = useState(() => {
const timestamp = persistedState?.monthTimestamp;
return typeof timestamp === "number" && Number.isFinite(timestamp) ? new Date(timestamp) : new Date();
});
useEffect(() => {
if (!props.widgetStateRef) return;
props.widgetStateRef.current = {
...props.widgetStateRef.current,
calendar: { monthTimestamp: month.getTime() },
};
}, [month, props.widgetStateRef]);
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
@@ -31,7 +45,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -43,7 +57,16 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
return (
<CalendarBase
isEditMode={isEditMode}
events={events}
month={month}
setMonth={setMonth}
options={options}
displayMode={displayMode}
/>
);
};
interface CalendarBaseProps {
@@ -52,9 +75,10 @@ interface CalendarBaseProps {
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -65,124 +89,101 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const visibleEvents = useMemo(
() =>
normalizedEvents.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
),
[normalizedEvents, options.releaseType],
);
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
const agendaEvents = useMemo(
() => getCalendarAgendaEvents(events, month, options.releaseType),
[events, month, options.releaseType],
);
return (
<Calendar
defaultDate={new Date()}
onPreviousMonth={(month) => setMonth(new Date(month))}
onNextMonth={(month) => setMonth(new Date(month))}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h="100%"
ref={ref}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
},
calendarHeaderLevel: {
pointerEvents: "none",
fontSize: isSmall ? "0.75rem" : undefined,
height: "100%",
},
levelsGroup: {
height: "100%",
padding: "md",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
monthCell: {
textAlign: "center",
position: "relative",
},
day: {
borderRadius: actualItemRadius,
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
month: {
height: "100%",
},
weekday: {
padding: 0,
},
weekdaysRow: {
height: 22,
},
}}
renderDay={(tileDate) => {
const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
)
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
<Calendar
defaultDate={new Date()}
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h={displayMode === "advanced" ? "62%" : "100%"}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
},
calendarHeaderLevel: {
pointerEvents: "none",
fontSize: isSmall ? "0.75rem" : undefined,
height: "100%",
},
levelsGroup: {
height: "100%",
padding: "md",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
monthCell: {
textAlign: "center",
position: "relative",
},
day: {
borderRadius: actualItemRadius,
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
month: {
height: "100%",
},
weekday: {
padding: 0,
},
weekdaysRow: {
height: 22,
},
}}
renderDay={(tileDate) => {
const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
return (
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
return (
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
{displayMode === "advanced" && (
<Box px="md" pb="sm" style={{ flex: 1, minHeight: 0 }}>
<Text size="sm" fw={600} mb={4}>
{new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length}
</Text>
<CalendarEventList events={agendaEvents} groupByDate locale={locale} />
</Box>
)}
</Stack>
);
};
/**
* Splits multi-day events into multiple single-day events.
* @param events The events to split.
* @returns The split events.
*/
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
const splitEvents: CalendarEvent[] = [];
for (const event of events) {
if (!event.endDate) {
splitEvents.push(event);
continue;
}
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
splitEvents.push(event);
continue;
}
if (dayjs(event.startDate).isAfter(event.endDate)) {
// Invalid event, skip it
continue;
}
// Event spans multiple days, split it
let currentStart = dayjs(event.startDate);
while (currentStart.isBefore(event.endDate)) {
splitEvents.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
}
}
return splitEvents;
};
+1
View File
@@ -9,6 +9,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
queryKey: [["widget", "calendar"]],
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
+90 -16
View File
@@ -1,32 +1,39 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Stack, Text, Title } from "@mantine/core";
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { clientApi } from "@homarr/api/client";
import { metricToImperial } from "@homarr/common";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
import { WeatherDescription } from "../weather/icon";
dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) {
const secondsFormat = options.showSeconds ? ":ss" : "";
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
const isAdvanced = displayMode === "advanced";
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
const customTimeFormat = options.customTimeFormat;
const customDateFormat = options.customDateFormat;
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime(options);
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
const minimumAxis = Math.min(width, height * 1.5);
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
const showWeatherCorner = options.showWeather && sizing !== "xs";
return (
@@ -35,7 +42,9 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
<ClockWeatherCorner
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit}
showDetails={isAdvanced}
/>
)}
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
@@ -45,13 +54,20 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
</Text>
)}
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
<time dateTime={zonedTime?.toISOString()}>
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
</time>
</Title>
{options.showDate && (
{(isAdvanced || options.showDate) && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
</Text>
)}
{isAdvanced && (
<Text size="xs" c="dimmed" lineClamp={1}>
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
</Text>
)}
</Stack>
</Box>
);
@@ -60,28 +76,86 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
interface ClockWeatherCornerProps {
latitude: number;
longitude: number;
locationName: string;
isFahrenheit: boolean;
showDetails: boolean;
}
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const ClockWeatherCorner = ({
latitude,
longitude,
locationName,
isFahrenheit,
showDetails,
}: ClockWeatherCornerProps) => {
const t = useI18n();
const tCommon = useScopedI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
if (!weather) return null;
if (!weather) {
return (
<Box pos="absolute" top={4} left={4}>
{isInitialWidgetQueryPending(weatherQuery) ? (
<Loader size="xs" aria-label={tCommon("action.loading")} />
) : (
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
)}
</Box>
);
}
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
const unit = isFahrenheit ? "°F" : "°C";
const today = weather.daily[0];
const formatTemperature = (value: number | undefined) => {
if (value === undefined) return "—";
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
return `${Math.round(preferred)}${unit}`;
};
return (
<Stack className="clock-weather-corner" pos="absolute" top={4} left={4} gap={0} align="center">
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
{Math.round(temp)}
{unit}
</Text>
<Stack
className="clock-weather-corner"
pos="absolute"
top={4}
left={4}
gap={showDetails ? 4 : 0}
align="flex-start"
>
<Group gap={4} wrap="nowrap">
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
{Math.round(temp)}
{unit}
</Text>
{showDetails && <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />}
</Group>
{showDetails && (
<Stack gap={1}>
<Text size="xs" fw={600}>
{locationName}
</Text>
<Text size="xs" c="dimmed">
{formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
</Text>
<Text size="xs" c="dimmed">
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
</Text>
<Text size="xs" c="dimmed">
{t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
{t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
</Text>
</Stack>
)}
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
</Stack>
);
};
const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
interface UseCurrentTimeProps {
showSeconds: boolean;
}
+30 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { ScrollArea, Stack, Text } from "@mantine/core";
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
@@ -10,7 +10,12 @@ import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -21,29 +26,45 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
);
}
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
return (
<CoolifyContent
integrationIds={integrationIds}
options={options}
width={width}
isAdvanced={displayMode === "advanced"}
/>
);
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
const isTiny = width < 256;
const isTiny = !isAdvanced && width < 256;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
return (
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
/>
);
}
return (
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
<SimpleGrid cols={isAdvanced && width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
@@ -51,9 +72,10 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
/>
))}
</Stack>
</SimpleGrid>
</ScrollArea>
);
}
+1
View File
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
icon: IconCloud,
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
+16 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -18,9 +19,10 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
}
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
@@ -31,6 +33,12 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -90,7 +98,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
</Group>
</Group>
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
<Accordion
variant="filled"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -18,9 +19,10 @@ interface SingleInstanceLayoutProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
}
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isAdvanced }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -30,6 +32,12 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -56,7 +64,13 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
</Anchor>
</Group>
<Accordion variant="contained" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
<Accordion
variant="contained"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
@@ -87,7 +87,6 @@ export default function CustomApiWidget({
/>
);
}
function Unavailable({
message,
danger = false,
+17 -1
View File
@@ -1,4 +1,4 @@
import { IconApi } from "@tabler/icons-react";
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
@@ -6,6 +6,22 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi"]],
contextActions({ options, widgetStateRef }) {
return [
{
key: "togglePolling",
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetStateRef.current?.togglePolling !== "function",
onClick: () => {
const action = widgetStateRef.current?.togglePolling;
if (typeof action === "function") action();
},
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { getWidgetQueryKeys } from "./definition";
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
expect(getWidgetQueryKeys({ kind: "example" })).toEqual([[["widget", "example"]]]);
});
test("wraps a singular explicit key", () => {
expect(getWidgetQueryKeys({ kind: "example", queryKey: [["app", "byId"]] })).toEqual([[["app", "byId"]]]);
});
test("preserves every explicit key for multi-root widgets", () => {
const queryKeys = [[["widget", "audioStats"]], [["widget", "mediaServer"]]] as const;
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
});
+88 -13
View File
@@ -1,6 +1,7 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -10,7 +11,7 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
@@ -26,18 +27,50 @@ export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
canInteractWithSelectedIntegrations: boolean;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
export interface WidgetRuntimeActions {
togglePolling?: () => void;
testAllIndexers?: () => void;
previousPhoto?: () => void;
nextPhoto?: () => void;
toggleSlideshow?: () => void;
}
export interface WidgetRuntimeState {
queries: readonly NormalizedWidgetQuery[];
actions: WidgetRuntimeActions;
}
export type WidgetRuntimeRef = React.MutableRefObject<WidgetRuntimeState>;
export const createWidgetRuntimeState = (): WidgetRuntimeState => ({
queries: [],
actions: {},
});
export interface NormalizedWidgetQuery {
path: readonly string[];
input: unknown;
}
export interface WidgetQueryMatcherScope {
itemId: string;
boardId: string | undefined;
integrationIds: readonly string[];
options: Record<string, unknown>;
runtimeQueries: readonly NormalizedWidgetQuery[];
}
export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean;
export interface WidgetContextActionProps {
options: Record<string, unknown>;
setOptions: (partial: Record<string, unknown>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
}
const createWithDynamicImport =
@@ -69,8 +102,10 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
supportsAdvancedFocus?: boolean;
queryKey?: QueryKey;
queryKeys?: QueryKey[];
queryKeys?: readonly QueryKey[];
queryMatcher?: WidgetQueryMatcher;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
@@ -88,11 +123,49 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
contextActions?: (props: WidgetContextActionProps) => WidgetContextMenuAction[];
}
export const getWidgetQueryKeys = (definition: WidgetDefinition, kind: WidgetKind): QueryKey[] =>
definition.queryKeys ?? [definition.queryKey ?? [["widget", kind]]];
export const supportsAdvancedFocus = (definition: object) =>
"supportsAdvancedFocus" in definition && definition.supportsAdvancedFocus === true;
export const getWidgetQueryKeys = (
definition: {
kind?: string;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
},
kind?: WidgetKind,
): readonly QueryKey[] => {
if (definition.queryKeys && definition.queryKeys.length > 0) return definition.queryKeys;
return [definition.queryKey ?? [["widget", definition.kind ?? kind]]];
};
export const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return { path, input };
};
export const getWidgetRuntimeQueries = (widgetRuntimeRef: WidgetRuntimeRef): readonly NormalizedWidgetQuery[] =>
widgetRuntimeRef.current.queries;
export const matchesWidgetRuntimeQuery: WidgetQueryMatcher = (query, scope) =>
scope.runtimeQueries.some(
(runtimeQuery) =>
widgetQueryValueEquals(runtimeQuery.path, query.path) && widgetQueryValueEquals(runtimeQuery.input, query.input),
);
export const widgetQueryInputMatches = (input: unknown, expected: Record<string, unknown>) =>
isRecord(input) && Object.entries(expected).every(([key, value]) => widgetQueryValueEquals(input[key], value));
export const widgetQueryValueEquals = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
@@ -103,9 +176,11 @@ export interface WidgetProps<TKind extends WidgetKind> {
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
removeItem?: () => void;
};
@@ -44,6 +44,8 @@ export default function DnsHoleControlsWidget({
integrationIds,
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -56,13 +58,21 @@ export default function DnsHoleControlsWidget({
});
const utils = clientApi.useUtils();
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
const {
mutateAsync: enableDns,
isPending: isEnabling,
error: enableError,
} = clientApi.widget.dnsHole.enable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
const {
mutateAsync: disableDns,
isPending: isDisabling,
error: disableError,
} = clientApi.widget.dnsHole.disable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const toggleDns = (integrationId: string) => {
const toggleDns = async (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary.status) return;
utils.widget.dnsHole.summary.setData(
@@ -86,9 +96,9 @@ export default function DnsHoleControlsWidget({
},
);
if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 });
await disableDns({ integrationId, duration: 0 });
} else {
enableDns({ integrationId });
await enableDns({ integrationId });
}
};
@@ -103,9 +113,21 @@ export default function DnsHoleControlsWidget({
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
const [bulkPending, setBulkPending] = useState(false);
const [bulkFailureCount, setBulkFailureCount] = useState(0);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
const controlAllButtonsVisible =
(options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
const actionsPending = bulkPending || isEnabling || isDisabling;
const actionError = enableError ?? disableError;
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
setBulkPending(true);
setBulkFailureCount(0);
const results = await Promise.allSettled(integrationIdsToToggle.map(toggleDns));
setBulkFailureCount(results.filter(({ status }) => status === "rejected").length);
setBulkPending(false);
};
return (
<Stack
@@ -123,8 +145,8 @@ export default function DnsHoleControlsWidget({
size="xs"
p={0}
className="dns-hole-controls-enable-all-button"
onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.disabled.length === 0}
onClick={() => void runBulkToggle(integrationsSummaries.disabled)}
disabled={integrationsSummaries.disabled.length === 0 || actionsPending}
variant="light"
color="green"
bd={0}
@@ -144,7 +166,7 @@ export default function DnsHoleControlsWidget({
setSelectedIntegrationIds(integrationsSummaries.enabled);
open();
}}
disabled={integrationsSummaries.enabled.length === 0}
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="yellow"
bd={0}
@@ -160,8 +182,8 @@ export default function DnsHoleControlsWidget({
size="xs"
p={0}
className="dns-hole-controls-disable-all-button"
onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.enabled.length === 0}
onClick={() => void runBulkToggle(integrationsSummaries.enabled)}
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="red"
bd={0}
@@ -192,16 +214,32 @@ export default function DnsHoleControlsWidget({
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
rootHeight={height}
isAdvanced={displayMode === "advanced"}
actionsPending={actionsPending}
/>
))}
</Stack>
</ScrollArea.Autosize>
{bulkFailureCount > 0 && (
<Text size="xs" c="red" ta="center">
{t("widget.dnsHoleControls.error.bulkActionsFailed", { count: bulkFailureCount })}
</Text>
)}
{actionError && (
<Tooltip label={actionError.message} multiline>
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
{t("widget.dnsHoleControls.error.internalServerError")}
</Text>
</Tooltip>
)}
<TimerModal
opened={opened}
close={close}
selectedIntegrationIds={selectedIntegrationIds}
disableDns={disableDns}
disableDns={(input) => void disableDns(input).catch(() => undefined)}
/>
</Stack>
);
@@ -209,13 +247,16 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => void;
toggleDns: (integrationId: string) => Promise<void>;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
isAdvanced: boolean;
actionsPending: boolean;
}
const ControlsCard: React.FC<ControlsCardProps> = ({
@@ -227,16 +268,19 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
t,
hasIconColor,
rootWidth,
rootHeight,
isAdvanced,
actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
// Use all factors to infer the state of the action buttons
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected && !actionsPending;
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = rootWidth < 256 ? "sm" : "md";
const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
return (
<Indicator
@@ -296,7 +340,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
<Group gap="xs" grow wrap="nowrap" w="100%">
{!isEnabled ? (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="green"
@@ -306,7 +350,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
) : (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="red"
@@ -323,6 +367,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
size="sm"
color="yellow"
variant="light"
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
>
<IconClockPause size={12} />
</ActionIcon>
@@ -334,7 +380,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
disabled={!controlEnabled}
display="contents"
style={{ cursor: controlEnabled ? "pointer" : "default" }}
onClick={() => toggleDns(data.integration.id)}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
>
<Badge
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
@@ -2,7 +2,19 @@
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Card, Flex, SimpleGrid, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
import {
Avatar,
AvatarGroup,
Badge,
Card,
Flex,
Group,
SimpleGrid,
Stack,
Text,
Tooltip,
TooltipFloating,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
@@ -19,7 +31,11 @@ import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
export default function DnsHoleSummaryWidget({
options,
integrationIds,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
@@ -27,28 +43,44 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const layoutProps = boxPropsByLayout(options.layout);
return (
<SimpleGrid cols={2} spacing="xs" h="100%" p={"xs"} {...boxPropsByLayout(options.layout)}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
<Stack h="100%" gap={0}>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
)}
</SimpleGrid>
{displayMode === "advanced" && summaries.length > 0 && (
<Group px="xs" pb="xs" gap="xs">
{summaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
</Badge>
))}
</Group>
)}
</SimpleGrid>
</Stack>
);
}
+82 -19
View File
@@ -1,7 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Avatar,
Badge,
Box,
Center,
getDefaultZIndex,
Group,
Menu,
Portal,
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import {
IconBrandDocker,
IconCategoryPlus,
@@ -29,8 +43,11 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type ContainerAction = "start" | "stop" | "restart" | "remove";
@@ -63,10 +80,32 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
const label = t(state);
return (
<Tooltip label={label} withArrow>
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
<Box
component="span"
aria-hidden="true"
w={8}
h={8}
bg={`${containerStateColorMap[state]}.6`}
style={{ borderRadius: "50%" }}
/>
<VisuallyHidden>{label}</VisuallyHidden>
</Box>
</Tooltip>
);
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
inlineState: boolean,
): DataTableColumn<DockerContainer>[] => [
{
accessor: "name",
@@ -76,6 +115,7 @@ const createColumns = (
sortable: sortingEnabled,
render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
@@ -188,6 +228,7 @@ export default function DockerWidget({
options,
width,
isEditMode,
displayMode,
boardId,
itemId,
setOptions,
@@ -198,9 +239,11 @@ export default function DockerWidget({
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const isTiny = width <= 256;
const isAdvanced = displayMode === "advanced";
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
const containersQuery = clientApi.docker.getContainers.useQuery();
const data = getUsableWidgetQueryData(containersQuery);
const { refetch, isFetching } = containersQuery;
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -279,13 +322,18 @@ export default function DockerWidget({
[containers],
);
const selectedColumns = useMemo(() => new Set<string>(options.columns), [options.columns]);
const columnVisibility = useMemo(
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
[isAdvanced, options.columns, width],
);
const inlineState =
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
const columns = useMemo(() => {
const sortingEnabled = options.enableRowSorting && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled).filter(({ accessor }) =>
selectedColumns.has(String(accessor)),
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled, inlineState).filter(
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
);
}, [actionHandlers, isEditMode, options.enableRowSorting, selectedColumns, t]);
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
@@ -303,7 +351,7 @@ export default function DockerWidget({
setContextMenu({ x: event.clientX, y: event.clientY, container: record });
}, []);
if (options.columns.length === 0) {
if (!isAdvanced && options.columns.length === 0) {
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
@@ -311,6 +359,8 @@ export default function DockerWidget({
);
}
const footerVisibility = getDockerFooterVisibility(width, isAdvanced);
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Box style={{ flex: 1, minHeight: 0 }}>
@@ -325,7 +375,7 @@ export default function DockerWidget({
columns={effectiveColumns}
storeColumnsKey={storeKey}
sortStatus={sortStatus}
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
onSortStatusChange={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
idAccessor="id"
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
onScroll={() => {
@@ -334,8 +384,15 @@ export default function DockerWidget({
/>
</Box>
{!isTiny && (
<Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
{footerVisibility.footer && (
<Group
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
wrap="nowrap"
>
<Group gap={4} wrap="nowrap">
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
<Text size="sm" truncate>
@@ -344,14 +401,19 @@ export default function DockerWidget({
</Group>
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
{footerVisibility.cpu && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
)}
{footerVisibility.memory && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
)}
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
className={actionTargetClasses.root}
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
@@ -386,6 +448,7 @@ function ContainerMenuButton({
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
color="gray"
size="sm"
@@ -486,7 +549,7 @@ function ContainerContextMenu({
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
style={{ zIndex: getDefaultZIndex("popover") }}
onClick={onClose}
onContextMenu={(event) => {
event.preventDefault();
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { getDockerColumnVisibility } from "./layout";
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
describe("getDockerColumnVisibility", () => {
test("keeps only essential columns in a narrow compact widget", () => {
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
name: true,
state: true,
host: false,
cpuUsage: false,
memoryUsage: false,
actions: true,
});
});
test("reveals compact metrics as width becomes available", () => {
expect(getDockerColumnVisibility(columns, 380, false)).toMatchObject({
host: false,
cpuUsage: true,
memoryUsage: true,
});
});
test("uses every expert column in advanced mode", () => {
expect(getDockerColumnVisibility([], 240, true)).toEqual({
name: true,
state: true,
host: true,
cpuUsage: true,
memoryUsage: true,
actions: true,
});
});
});
+19
View File
@@ -0,0 +1,19 @@
export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage" | "actions";
export const getDockerColumnVisibility = (
configuredColumns: readonly DockerColumn[],
width: number,
isAdvanced: boolean,
): Record<DockerColumn, boolean> => {
const configured = new Set(configuredColumns);
const isVisible = (column: DockerColumn) => isAdvanced || configured.has(column);
return {
name: isVisible("name"),
state: isVisible("state"),
host: isVisible("host") && (isAdvanced || width >= 440),
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 280),
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 360),
actions: isVisible("actions"),
};
};
+108 -85
View File
@@ -3,7 +3,7 @@
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Avatar,
@@ -50,11 +50,21 @@ import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/co
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { HomarrDataTable } from "../common/homarr-data-table";
import { formatLocalizedDateTime } from "../common/locale";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import {
DOWNLOAD_COLUMN_ACCESSORS,
filterDownloadItemsByStatus,
getAvailableDownloadStates,
getDownloadColumnAccessors,
} from "./helpers";
dayjs.extend(relativeTime);
@@ -81,6 +91,7 @@ interface ColumnContext {
hasTorrents: boolean;
hasMultipleClients: boolean;
hasMultipleTypes: boolean;
isAdvanced: boolean;
size: SizeConfig;
}
@@ -132,16 +143,16 @@ const stateColorMap: Record<DownloadState, string> = {
};
const rowBgAlpha: Partial<Record<DownloadState, string>> = {
failed: "rgba(255, 0, 0, 0.04)",
paused: "rgba(255, 200, 0, 0.03)",
failed: "var(--mantine-color-red-light)",
paused: "var(--mantine-color-yellow-light)",
};
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
ratio: (ctx) => ctx.hasTorrents,
sent: (ctx) => ctx.hasTorrents,
integration: (ctx) => ctx.hasMultipleClients,
type: (ctx) => ctx.hasMultipleTypes,
integration: (ctx) => ctx.isAdvanced || ctx.hasMultipleClients,
type: (ctx) => ctx.isAdvanced || ctx.hasMultipleTypes,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
state: (ctx) => ctx.size.showStateColumn,
@@ -152,7 +163,10 @@ const speedColumnConfig = {
up: { Icon: IconUpload, color: "green" as const },
} as const;
function getSizeConfig(width: number): SizeConfig {
export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
if (isAdvanced) return DEFAULT_SIZE_CONFIG;
if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
@@ -165,24 +179,6 @@ function progressColor(state: DownloadState, progress: number): string {
return "blue";
}
const columnAccessors = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const;
function toCategoryArray(category: string | string[]): string[] {
if (Array.isArray(category)) return category;
return [category];
@@ -298,31 +294,39 @@ export default function DownloadClientsWidget({
boardId,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"downloads">) {
const allInteractAccess = useIntegrationsWithInteractAccess();
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const allInteractAccess = useIntegrationsWithInteractAccess();
const integrationInteractSet = useMemo(
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
[allInteractAccess, integrationIds],
);
const {
data: currentItems = [],
isFetching,
isError,
} = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
const downloadsQuery = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
});
const currentItems = getUsableWidgetQueryData(downloadsQuery);
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
const { isFetching } = downloadsQuery;
const t = useScopedI18n("widget.downloads");
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={currentItems ?? []} />
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
</Group>
);
const [clientFilter, setClientFilter] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [showStats, { toggle: toggleStats }] = useDisclosure(false);
const isAdvanced = displayMode === "advanced";
const utils = clientApi.useUtils();
const mutationOptions = {
@@ -336,6 +340,7 @@ export default function DownloadClientsWidget({
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(mutationOptions);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
scope: { id: `downloads-options:${boardId ?? "preview"}:${itemId ?? "preview"}` },
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
});
const persistOption = useTableLayoutPersistence({
@@ -348,26 +353,27 @@ export default function DownloadClientsWidget({
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
if (options.descendingDefaultSort) defaultSortDirection = "desc";
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>({
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>(() => ({
columnAccessor: options.defaultSort,
direction: defaultSortDirection,
});
}));
const hasMountedSortDefaults = useRef(false);
useEffect(() => {
if (!hasMountedSortDefaults.current) {
hasMountedSortDefaults.current = true;
return;
}
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
}, [options.defaultSort, defaultSortDirection]);
const data = useMemo<ExtendedDownloadClientItem[]>(() => {
return currentItems
const eligibleData = useMemo<ExtendedDownloadClientItem[]>(() => {
return availableItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.flatMap((pair) =>
pair.data.items
.filter(({ category }) => matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist))
.filter((item) => showCompletedItem(item, options))
.filter(
({ state }) =>
(clientFilter.length === 0 || clientFilter.includes(pair.integration.id)) &&
(statusFilter.length === 0 || statusFilter.includes(state)),
)
.filter(() => clientFilter.length === 0 || clientFilter.includes(pair.integration.id))
.map((item): ExtendedDownloadClientItem => {
const received = item.received ?? Math.floor(item.size * item.progress);
const ids = [pair.integration.id];
@@ -390,7 +396,7 @@ export default function DownloadClientsWidget({
}),
);
}, [
currentItems,
availableItems,
integrationIds,
integrationInteractSet,
mutateDeleteItem,
@@ -398,9 +404,10 @@ export default function DownloadClientsWidget({
mutateResumeItem,
options,
clientFilter,
statusFilter,
]);
const data = useMemo(() => filterDownloadItemsByStatus(eligibleData, statusFilter), [eligibleData, statusFilter]);
const sortedData = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof ExtendedDownloadClientItem;
const multiplier = getSortMultiplier(sortStatus.direction);
@@ -412,7 +419,7 @@ export default function DownloadClientsWidget({
}, [data, sortStatus]);
const clients = useMemo<ExtendedClientStatus[]>(() => {
return currentItems
return availableItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.map(({ integration, data: clientData }): ExtendedClientStatus => {
const interact = integrationInteractSet.has(integration.id);
@@ -449,7 +456,7 @@ export default function DownloadClientsWidget({
};
});
}, [
currentItems,
availableItems,
integrationIds,
integrationInteractSet,
options.applyFilterToRatio,
@@ -461,11 +468,14 @@ export default function DownloadClientsWidget({
const hasMultipleClients = clients.length > 1;
const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1;
const size = useMemo(() => getSizeConfig(width), [width]);
const optionsColumnSet = useMemo(() => new Set<string>(options.columns), [options.columns]);
const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]);
const optionsColumnSet = useMemo(
() => new Set<string>(getDownloadColumnAccessors(options.columns, isAdvanced)),
[isAdvanced, options.columns],
);
const columnContext = useMemo<ColumnContext>(
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }),
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size],
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size }),
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size],
);
let progressColumnWidth = 120;
@@ -479,7 +489,7 @@ export default function DownloadClientsWidget({
title: "",
width: 36,
render: (record) => (
<Tooltip label={record.integration.name} withArrow>
<Tooltip key={displayMode} label={record.integration.name} withArrow>
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
</Tooltip>
),
@@ -491,6 +501,7 @@ export default function DownloadClientsWidget({
ellipsis: true,
render: (record) => (
<Tooltip
key={displayMode}
label={buildHoverTooltip(record, t)}
multiline
w={280}
@@ -525,7 +536,14 @@ export default function DownloadClientsWidget({
render: (record) => {
const pct = Math.floor(record.progress * 100);
return (
<Tooltip label={buildProgressTooltip(record)} withArrow openDelay={300} position="top" color="dark">
<Tooltip
key={displayMode}
label={buildProgressTooltip(record)}
withArrow
openDelay={300}
position="top"
color="dark"
>
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size="xs" fw={500} w={36} ta="right" style={{ flexShrink: 0 }}>
{`${pct}%`}
@@ -666,11 +684,11 @@ export default function DownloadClientsWidget({
},
];
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
}, [columnContext, t, size, progressColumnWidth]);
}, [columnContext, t, size, progressColumnWidth, displayMode]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnAccessors: DOWNLOAD_COLUMN_ACCESSORS,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
@@ -701,7 +719,7 @@ export default function DownloadClientsWidget({
return traffic.up / traffic.down;
}, [clients]);
const availableStatuses = useMemo(() => [...new Set(data.map(({ state }) => state))], [data]);
const availableStatuses = useMemo(() => getAvailableDownloadStates(eligibleData), [eligibleData]);
const queueStats = useMemo(() => {
const stateCounts: Record<string, number> = {};
@@ -715,19 +733,16 @@ export default function DownloadClientsWidget({
return { stateCounts, totalSize, completedSize, totalItems: data.length };
}, [data]);
if (options.columns.length === 0) {
if (!isAdvanced && options.columns.length === 0) {
return (
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
);
}
if (isError && currentItems.length === 0) {
return (
<Center h="100%">
<Text c="red">{t("errors.noCommunications")}</Text>
</Center>
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
</Box>
);
}
@@ -736,8 +751,9 @@ export default function DownloadClientsWidget({
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
{showStats && (
{(showStats || isAdvanced) && (
<GlobalStatsBar
key={displayMode}
queueStats={queueStats}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
@@ -748,10 +764,13 @@ export default function DownloadClientsWidget({
)}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fetching={isFetching && currentItems.length === 0}
fetching={isFetching && (currentItems?.length ?? 0) === 0}
fz={size.fontSize}
records={sortedData}
columns={effectiveColumns}
@@ -780,22 +799,25 @@ export default function DownloadClientsWidget({
/>
</Box>
<WidgetFooter
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={showStats}
toggleStats={toggleStats}
/>
{(isAdvanced || height >= 96) && (
<WidgetFooter
key={displayMode}
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={showStats}
toggleStats={toggleStats}
/>
)}
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
</Stack>
@@ -847,6 +869,7 @@ function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): R
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
const t = useScopedI18n("widget.downloads");
const locale = useCurrentIntlLocale();
const progressPercent = Math.floor(item.progress * 100);
const categoryDisplay = formatCategoryDisplay(item.category);
@@ -909,7 +932,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
<DetailPair label={t("items.sent.detailsTitle")} value={formatBytes(item.sent)} />
)}
{item.added !== undefined && (
<DetailPair label={t("items.added.detailsTitle")} value={dayjs(item.added).format("YYYY-MM-DD HH:mm")} />
<DetailPair label={t("items.added.detailsTitle")} value={formatLocalizedDateTime(item.added, locale)} />
)}
{categoryDisplay && <DetailPair label={t("items.category.detailsTitle")} value={categoryDisplay} />}
<DetailPair label={t("items.id.detailsTitle")} value={item.id} />
@@ -1046,7 +1069,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
style={{ zIndex: "var(--mantine-z-index-popover)" }}
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
describe("downloads filters", () => {
const eligibleItems = [{ state: "downloading" as const }, { state: "seeding" as const }];
test("keeps all eligible statuses available after applying a status filter", () => {
expect(filterDownloadItemsByStatus(eligibleItems, ["downloading"])).toEqual([{ state: "downloading" }]);
expect(getAvailableDownloadStates(eligibleItems)).toEqual(["downloading", "seeding"]);
});
test("deduplicates available statuses", () => {
expect(getAvailableDownloadStates([...eligibleItems, { state: "downloading" }])).toEqual([
"downloading",
"seeding",
]);
});
});
describe("downloads column widths", () => {
test("serializes numeric and pixel widths while ignoring automatic widths", () => {
expect(columnWidthsToRecord([{ name: 240 }, { progress: "120px" }, { state: "auto" }])).toEqual({
name: 240,
progress: 120,
});
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
type DownloadState = ExtendedDownloadClientItem["state"];
export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
items: readonly T[],
statusFilter: readonly string[],
): T[] {
if (statusFilter.length === 0) return [...items];
return items.filter(({ state }) => statusFilter.includes(state));
}
export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] {
return [...new Set(items.map(({ state }) => state))];
}
export function columnWidthsToRecord(columnsWidth: readonly Record<string, string | number>[]): Record<string, number> {
const result: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const width = entry[key];
if (typeof width === "number") result[key] = width;
else if (typeof width === "string" && width.endsWith("px")) result[key] = parseInt(width, 10);
}
return result;
}
+1
View File
@@ -42,6 +42,7 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
queryKey: [["widget", "downloads"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
@@ -1,10 +1,6 @@
import { describe, expect, test } from "vitest";
import {
filterStorageVolumes,
normalizeStorageDeviceName,
toScopedStorageVolumeValue,
} from "./filter-storage-volumes";
import { filterStorageVolumes, normalizeStorageDeviceName, toScopedStorageVolumeValue } from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
describe("normalizeStorageDeviceName", () => {
@@ -35,11 +35,7 @@ const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string
);
};
const matchesVisibleStorageVolume = (
visibleVolume: string,
integrationId: string,
deviceName: string,
): boolean => {
const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
describe("calculateBandwidth", () => {
test("sorts samples before calculating a rate", () => {
const result = calculateBandwidth([
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 200 }] },
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 300, transmit: 500 }] },
]);
expect(result.data).toEqual([{ name: "wan", receive: 160, transmit: 240 }]);
});
test("treats counter resets as zero instead of negative traffic", () => {
const result = calculateBandwidth([
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 10, transmit: 10 }] },
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 100 }] },
]);
expect(result.data).toEqual([{ name: "wan", receive: 0, transmit: 0 }]);
});
test("rejects duplicate timestamps", () => {
const timestamp = new Date("2026-01-01T00:00:00Z");
expect(
calculateBandwidth([
{ timestamp, data: [{ name: "wan", receive: 100, transmit: 100 }] },
{ timestamp, data: [{ name: "wan", receive: 50, transmit: 50 }] },
]).data,
).toEqual([]);
});
});
describe("formatBitsPerSec", () => {
test("handles invalid and negative rates", () => {
expect(formatBitsPerSec(Number.NaN, 2)).toBe("0 b/s");
expect(formatBitsPerSec(-1, 2)).toBe("0 b/s");
});
});
@@ -0,0 +1,39 @@
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
export function formatBitsPerSec(bitsPerSecond: number, decimals: number): string {
if (!Number.isFinite(bitsPerSecond) || bitsPerSecond <= 0) return "0 b/s";
const unitSize = 1024;
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
const unitIndex = Math.min(Math.floor(Math.log(bitsPerSecond) / Math.log(unitSize)), sizes.length - 1);
return `${parseFloat((bitsPerSecond / Math.pow(unitSize, unitIndex)).toFixed(decimals))} ${sizes[unitIndex]}`;
}
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
const samples = data
.filter((sample) => Number.isFinite(new Date(sample.timestamp).getTime()))
.toSorted((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
const latest = samples[0];
const previous = samples[1];
if (!latest || !previous) return { data: [] };
const timeDiffInSeconds = (new Date(latest.timestamp).getTime() - new Date(previous.timestamp).getTime()) / 1000;
if (timeDiffInSeconds <= 0) return { data: [] };
return {
data: latest.data.flatMap((latestInterface) => {
const previousInterface = previous.data.find((item) => item.name === latestInterface.name);
if (!previousInterface) return [];
return [
{
name: latestInterface.name,
receive: Math.max(0, (8 * (latestInterface.receive - previousInterface.receive)) / timeDiffInSeconds),
transmit: Math.max(0, (8 * (latestInterface.transmit - previousInterface.transmit)) / timeDiffInSeconds),
},
];
}),
};
}
+277 -185
View File
@@ -1,32 +1,51 @@
"use client";
import { useCallback, useState } from "react";
import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core";
import {
Accordion,
Badge,
Center,
Flex,
Group,
Paper,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
import type { FirewallInterfacesSummary } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
import { FirewallVersion } from "./firewall-version";
import { FirewallVersion, formatVersion } from "./firewall-version";
export interface Firewall {
label: string;
value: string;
}
export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState<string>("");
export default function FirewallWidget({
integrationIds,
width,
itemId,
displayMode,
}: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
const t = useI18n();
const handleSelect = useCallback((value: string | null) => {
if (value !== null) {
setSelectedFirewall(value);
} else {
setSelectedFirewall("default_value");
}
setSelectedFirewall(value ?? "");
}, []);
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
@@ -40,202 +59,275 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
integrationIds,
});
const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
const isTiny = width < 256;
const [accordionValue, setAccordionValue] = useLocalStorage<string | null>({
key: `homarr-${itemId}-firewall`,
defaultValue: "interfaces",
});
const initialSelectedFirewall = firewallsVersionData[0]?.integration.id ?? "";
const activeFirewall = firewallsVersionData.some(({ integration }) => integration.id === selectedFirewall)
? selectedFirewall
: initialSelectedFirewall;
const displayedFirewallIds = isAdvanced
? firewallsVersionData.map(({ integration }) => integration.id)
: activeFirewall
? [activeFirewall]
: [];
const dropdownItems = firewallsVersionData.map((firewall) => ({
label: firewall.integration.name,
value: firewall.integration.id,
}));
const t = useI18n();
return (
<ScrollArea h="100%">
<Group justify="space-between" w="100%" style={{ padding: "8px" }}>
<FirewallMenu
onChange={handleSelect}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
<FirewallVersion
firewallsVersionData={firewallsVersionData}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
isTiny={isTiny}
/>
</Group>
<Flex justify="center" align="center" wrap="wrap">
{/* Render CPU and Memory data */}
{firewallsCpuData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-cpu`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.total.toFixed(2)}%`}</Text>
<IconCpu size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.total.toFixed(1)),
color: summary.total > 50 ? (summary.total < 75 ? "yellow" : "red") : "green",
},
]}
<Stack gap="xs" p={isAdvanced ? "xs" : 0}>
{!isAdvanced && (
<Group justify="space-between" w="100%" p="xs">
<FirewallMenu
onChange={handleSelect}
selectedFirewall={activeFirewall}
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
))}
{firewallsMemoryData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-memory`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.percent.toFixed(1)}%`}</Text>
<IconBrain size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.percent.toFixed(1)),
color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green",
},
]}
<FirewallVersion
firewallsVersionData={firewallsVersionData}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
))}
</Flex>
{firewallsInterfacesData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary }) => (
<Accordion key="interfaces" value={accordionValue} onChange={setAccordionValue}>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}> {t("widget.firewall.widget.interfaces.title")} </Text>
</Accordion.Control>
<Accordion.Panel>
<Flex direction="column" key="interfaces">
{Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? (
calculateBandwidth(summary).data.map(({ name, receive, transmit }) => (
<Flex
key={name}
direction={isTiny ? "column" : "row"}
style={{
width: "100%",
padding: isTiny ? "2px" : "0px",
}}
>
<Flex w={isTiny ? "100%" : "33%"} style={{ justifyContent: "flex-start" }}>
<Text
size={isTiny ? "8px" : "xs"}
color="lightblue"
style={{
maxWidth: "100px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textAlign: "left",
}}
>
{name}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarUp size={isTiny ? "8" : "12"} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="lightgreen" style={{ textAlign: "left" }}>
{formatBitsPerSec(transmit, 2)}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarDown size={isTiny ? "8" : "12"} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow" style={{ textAlign: "left" }}>
{formatBitsPerSec(receive, 2)}
</Text>
</Flex>
</Flex>
))
) : (
<Box>No data available</Box>
)}
</Flex>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
))}
</Group>
)}
<SimpleGrid cols={isAdvanced && width >= 720 ? 2 : 1} spacing="xs">
{displayedFirewallIds.map((firewallId) => {
const cpu = firewallsCpuData.find(({ integration }) => integration.id === firewallId);
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
return (
<FirewallPanel
key={firewallId}
name={version?.integration.name ?? cpu?.integration.name ?? firewallId}
kind={version?.integration.kind}
version={version?.summary.version}
cpu={cpu?.summary.total}
memory={memory?.summary.percent}
interfaces={interfaces?.summary}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={t("widget.firewall.error.internalServerError")}
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
t={t}
/>
);
})}
</SimpleGrid>
</Stack>
</ScrollArea>
);
}
export function formatBitsPerSec(bytes: number, decimals: number): string {
if (bytes === 0) return "0 b/s";
const kilobyte = 1024;
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
const i = Math.floor(Math.log(bytes) / Math.log(kilobyte));
return `${parseFloat((bytes / Math.pow(kilobyte, i)).toFixed(decimals))} ${sizes[i]}`;
interface FirewallPanelProps {
name: string;
kind?: string;
version?: string;
cpu?: number;
memory?: number;
interfaces?: FirewallInterfacesSummary[];
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
noDataLabel: string;
interfacesLabel: string;
t: TranslationFunction;
}
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
const result = {
data: [] as FirewallInterface[],
timestamp: new Date().toISOString(),
};
const FirewallPanel = ({
name,
kind,
version,
cpu,
memory,
interfaces,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
noDataLabel,
interfacesLabel,
t,
}: FirewallPanelProps) => (
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
<Stack gap="xs">
{isAdvanced && (
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={600} truncate="end">
{name}
</Text>
{kind && (
<Text size="xs" c="dimmed">
{kind}
</Text>
)}
</Stack>
<Group gap={4} wrap="nowrap">
{hasError && (
<Tooltip label={noDataLabel}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
</Badge>
</Tooltip>
)}
<Badge variant="outline" color="gray" size="xs">
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
</Badge>
</Group>
</Group>
)}
if (data.length > 1) {
const firstData = data[0];
const secondData = data[1];
<Flex justify="center" align="center" wrap="wrap">
{cpu !== undefined && (
<MetricRing value={cpu} icon={IconCpu} isTiny={isTiny} label={t("widget.firewall.widget.cpu")} t={t} />
)}
{memory !== undefined && (
<MetricRing
value={memory}
icon={IconBrain}
isTiny={isTiny}
label={t("widget.firewall.widget.memory")}
t={t}
/>
)}
</Flex>
if (firstData && secondData) {
const time1 = new Date(firstData.timestamp);
const time2 = new Date(secondData.timestamp);
const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000;
<InterfacesPanel
summary={interfaces ?? []}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={noDataLabel}
label={interfacesLabel}
/>
</Stack>
</Paper>
);
firstData.data.forEach((iface) => {
const ifaceName = iface.name;
const recv1 = iface.receive;
const trans1 = iface.transmit;
const iface2 = secondData.data.find((i) => i.name === ifaceName);
if (iface2) {
const recv2 = iface2.receive;
const trans2 = iface2.transmit;
const recvDiff = recv1 - recv2;
const transDiff = trans1 - trans2;
result.data.push({
name: ifaceName,
receive: (8 * recvDiff) / timeDiffInSeconds,
transmit: (8 * transDiff) / timeDiffInSeconds,
});
}
});
}
}
return result;
interface MetricRingProps {
value: number;
icon: typeof IconCpu;
isTiny: boolean;
label: string;
t: TranslationFunction;
}
const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) => {
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const status = getMetricStatus(safeValue);
const statusLabel = t(`widget.firewall.status.${status}`);
return (
<RingProgress
aria-label={t("widget.firewall.metricAccessible", {
metric: label,
value: safeValue.toFixed(1),
status: statusLabel,
})}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
<Icon size={isTiny ? 8 : 16} />
{!isTiny && <Text size="8px">{statusLabel}</Text>}
</Center>
}
sections={[
{
value: safeValue,
color: status === "warning" ? "yellow" : status === "critical" ? "red" : "green",
},
]}
/>
);
};
const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
if (value >= 75) return "critical";
if (value > 50) return "warning";
return "normal";
};
interface InterfacesPanelProps {
summary: FirewallInterfacesSummary[];
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
noDataLabel: string;
label: string;
}
const InterfacesPanel = ({
summary,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
noDataLabel,
label,
}: InterfacesPanelProps) => {
const bandwidth = calculateBandwidth(summary).data;
return (
<Accordion
value={isAdvanced ? "interfaces" : accordionValue}
onChange={isAdvanced ? undefined : setAccordionValue}
variant={isAdvanced ? "contained" : "default"}
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
{bandwidth.length > 0 ? (
bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
{name}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
{formatBitsPerSec(transmit, 2)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
</Group>
))
) : (
<Text size="xs" c="dimmed">
{noDataLabel}
</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
};
@@ -1,6 +1,7 @@
import { Badge, Box } from "@mantine/core";
import type { FirewallVersionSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
interface FirewallVersionProps {
firewallsVersionData: {
@@ -18,24 +19,34 @@ export interface FirewallIntegration {
updatedAt: Date;
}
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => (
<Box>
<Badge autoContrast variant="outline" color="lightgray" size={isTiny ? "8px" : "xs"} style={{ minHeight: "24px" }}>
{firewallsVersionData
.filter(({ integration }) => integration.id === selectedFirewall)
.map(({ summary, integration }) => (
<span key={integration.id}>{formatVersion(summary.version)}</span>
))}
</Badge>
</Box>
);
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => {
const t = useI18n();
function formatVersion(inputString: string): string {
return (
<Box>
<Badge
autoContrast
variant="outline"
color="lightgray"
size={isTiny ? "8px" : "xs"}
style={{ minHeight: "24px" }}
>
{firewallsVersionData
.filter(({ integration }) => integration.id === selectedFirewall)
.map(({ summary, integration }) => (
<span key={integration.id}>{formatVersion(summary.version, t("widget.firewall.versionUnknown"))}</span>
))}
</Badge>
</Box>
);
};
export function formatVersion(inputString: string, fallback: string): string {
const regex = /([\d._]+)/;
const match = regex.exec(inputString);
if (match?.[1]) {
return match[1];
} else {
return "Unknown Version";
return fallback;
}
}
+1
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { getClusterAccordionDefault } from "./accordion-state";
describe("getClusterAccordionDefault", () => {
test("opens only the first visible section in compact mode", () => {
expect(getClusterAccordionDefault("compact", ["node", "qemu", "storage"])).toEqual(["node"]);
});
test("opens every visible section in advanced mode", () => {
expect(getClusterAccordionDefault("advanced", ["node", "qemu", "storage"])).toEqual(["node", "qemu", "storage"]);
});
test("does not invent a section when none are visible", () => {
expect(getClusterAccordionDefault("compact", [])).toEqual([]);
});
});
@@ -0,0 +1,4 @@
export const getClusterAccordionDefault = (
displayMode: "compact" | "advanced" | undefined,
visibleSections: readonly string[],
): string[] => (displayMode === "advanced" ? [...visibleSections] : visibleSections.slice(0, 1));
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
@@ -8,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { getClusterAccordionDefault } from "./accordion-state";
import { ResourceAccordionItem } from "./resource-accordion-item";
import { ResourceTable } from "./resource-table";
@@ -33,11 +35,16 @@ export const ClusterHealthMonitoring = ({
integrationId,
options,
width,
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
integrationId,
});
const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
const [accordionValues, setAccordionValues] = useState<Record<string, string[]>>({});
const accordionValue = accordionValues[accordionScope] ?? accordionDefault;
if (!healthData) return <WidgetEmptyState />;
@@ -53,15 +60,13 @@ export const ClusterHealthMonitoring = ({
(sum, item) => (item.isRunning ? item.cpu.utilization * item.cpu.cores + sum : sum),
0,
);
const uptime = healthData.nodes.reduce((sum, { uptime }) => (sum > uptime ? sum : uptime), 0);
const uptime = healthData.nodes.reduce((sum, { uptime: nodeUptime }) => (sum > nodeUptime ? sum : nodeUptime), 0);
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
const defaultValue = [options.visibleClusterSections.at(0) ?? "node"];
const isTiny = width < 256;
const isTiny = displayMode !== "advanced" && width < 256;
return (
<Stack h="100%" p="xs" gap={isTiny ? "xs" : "md"}>
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
{options.showUptime && (
<Group justify="center" wrap="nowrap">
<Text fz={isTiny ? 8 : "xs"} tt="uppercase" fw={700} c="dimmed" ta="center">
@@ -81,7 +86,13 @@ export const ClusterHealthMonitoring = ({
isTiny={isTiny}
/>
{options.visibleClusterSections.length >= 1 && (
<Accordion variant="contained" chevronPosition="right" multiple defaultValue={defaultValue}>
<Accordion
variant="contained"
chevronPosition="right"
multiple
value={accordionValue}
onChange={(value) => setAccordionValues((current) => ({ ...current, [accordionScope]: value }))}
>
{options.visibleClusterSections.includes("node") && (
<ResourceAccordionItem
value="node"
@@ -1,38 +1,54 @@
"use client";
import { ScrollArea, Tabs } from "@mantine/core";
import { Box, ScrollArea, SimpleGrid, Stack, Tabs } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
import { SystemHealthMonitoring } from "./system-health";
dayjs.extend(duration);
const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
if (!clusterIntegrationId) {
if (clusterIntegrationIds.length === 0) {
return <SystemHealthMonitoring {...props} />;
}
const otherIntegrationIds = integrations
// We want to have the mock integration also in the system tab, so we use it for both
.filter((integration) => integration.kind !== "proxmox")
.map((integration) => integration.id);
if (otherIntegrationIds.length === 0) {
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
const clusters = (
<Stack gap="sm">
{clusterIntegrationIds.map((integrationId) => (
<ClusterHealthMonitoring key={integrationId} {...props} integrationId={integrationId} />
))}
</Stack>
);
if (systemIntegrationIds.length === 0) {
const onlyClusterIntegrationId = clusterIntegrationIds[0];
if (clusterIntegrationIds.length === 1 && onlyClusterIntegrationId) {
return <ClusterHealthMonitoring {...props} integrationId={onlyClusterIntegrationId} />;
}
return <ScrollArea h="100%">{clusters}</ScrollArea>;
}
if (props.displayMode === "advanced") {
return (
<SimpleGrid cols={props.width >= 900 ? 2 : 1} spacing="sm" p="xs" h="100%">
<Box style={{ minHeight: 0, overflow: "hidden" }}>
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
</Box>
<ScrollArea h="100%">{clusters}</ScrollArea>
</SimpleGrid>
);
}
return (
@@ -47,11 +63,9 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="system">
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">{clusters}</Tabs.Panel>
</Tabs>
</ScrollArea>
);
@@ -8,7 +8,8 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
queryKeys: [[["widget", "healthMonitoring"]], [["integration", "byIds"]]],
supportsAdvancedFocus: true,
queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
describe("partitionHealthMonitoringIntegrations", () => {
test("preserves every Proxmox integration", () => {
expect(
partitionHealthMonitoringIntegrations([
{ id: "proxmox-a", kind: "proxmox" },
{ id: "proxmox-b", kind: "proxmox" },
{ id: "system", kind: "glances" },
]),
).toEqual({
clusterIntegrationIds: ["proxmox-a", "proxmox-b"],
systemIntegrationIds: ["system"],
});
});
test("keeps mock integrations in both views", () => {
expect(partitionHealthMonitoringIntegrations([{ id: "mock", kind: "mock" }])).toEqual({
clusterIntegrationIds: ["mock"],
systemIntegrationIds: ["mock"],
});
});
});
@@ -0,0 +1,12 @@
import type { IntegrationKind } from "@homarr/definitions";
interface HealthMonitoringIntegration {
id: string;
kind: IntegrationKind;
}
export const partitionHealthMonitoringIntegrations = (integrations: HealthMonitoringIntegration[]) => ({
clusterIntegrationIds: integrations.filter(({ kind }) => kind === "proxmox" || kind === "mock").map(({ id }) => id),
// Mock implements both contracts and remains visible in both views.
systemIntegrationIds: integrations.filter(({ kind }) => kind !== "proxmox").map(({ id }) => id),
});
@@ -1,5 +1,7 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Box,
@@ -11,10 +13,10 @@ import {
List,
Modal,
Progress,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconBrain,
IconClock,
@@ -30,6 +32,7 @@ import combineClasses from "clsx";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
@@ -51,206 +54,222 @@ export const SystemHealthMonitoring = ({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"healthMonitoring">) => {
const t = useI18n();
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const [opened, { open, close }] = useDisclosure(false);
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(null);
const board = useRequiredBoard();
const isTiny = width < 256;
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
if (healthData.length === 0) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes(
healthInfo.fileSystem,
options.visibleStorageVolumes,
integrationId,
);
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return (
<Stack
gap="sm"
key={integrationId}
h="100%"
className={`health-monitoring-information health-monitoring-${integrationName}`}
p="sm"
pos="relative"
>
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
processing
styles={{ indicator: { pointerEvents: "none" } }}
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
position="top-end"
size={16}
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
<ActionIcon
className="health-monitoring-information-icon-avatar"
variant={"light"}
color="var(--mantine-color-text)"
size="sm"
radius={board.itemRadius}
<ScrollArea h="100%">
<Stack mih="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes(
healthInfo.fileSystem,
options.visibleStorageVolumes,
integrationId,
);
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return (
<Stack
gap="sm"
key={integrationId}
h={!isAdvanced && healthData.length === 1 ? "100%" : "auto"}
className={`health-monitoring-information health-monitoring-${integrationName}`}
p="sm"
pos="relative"
>
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
processing
styles={{ indicator: { pointerEvents: "none" } }}
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
position="top-end"
size={16}
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
</ActionIcon>
</Indicator>
<Modal
opened={opened}
onClose={close}
size="auto"
title={t("widget.healthMonitoring.popover.information")}
centered
>
<Stack gap="10px" className="health-monitoring-modal-stack">
<Divider />
<List className="health-monitoring-information-list" center spacing="xs">
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={30} />}>
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
{t("widget.healthMonitoring.popover.memoryAvailable", {
memoryAvailable: memoryUsage.memFree.GB,
percent: String(memoryUsage.memFree.percent),
})}
</List.Item>
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={30} />}>
{t("widget.healthMonitoring.popover.version", {
version: healthInfo.version,
})}
</List.Item>
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
{formatUptime(healthInfo.uptime, t)}
</List.Item>
{healthInfo.loadAverage && (
<>
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
{t("widget.healthMonitoring.popover.loadAverage")}
</List.Item>
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
<List.Item className="health-monitoring-information-load-average-1min">
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
</List.Item>
<List.Item className="health-monitoring-information-load-average-5min">
{t("widget.healthMonitoring.popover.minutes", { count: "5" })}{" "}
{healthInfo.loadAverage["5min"]}%
</List.Item>
<List.Item className="health-monitoring-information-load-average-15min">
{t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "}
{healthInfo.loadAverage["15min"]}%
</List.Item>
</List>
</>
)}
</List>
</Stack>
</Modal>
</Box>
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{options.cpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{options.memory && (
<MemoryRing
available={healthInfo.memAvailableInBytes}
used={healthInfo.memUsedInBytes}
isTiny={isTiny}
/>
)}
{options.gpu &&
healthInfo.gpu.map((gpu) => (
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
))}
</Flex>
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
className={combineClasses(
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
classes.card,
)}
style={{ overflow: "visible" }}
key={disk.deviceName}
<ActionIcon
className="health-monitoring-information-icon-avatar"
variant={"light"}
color="var(--mantine-color-text)"
size="sm"
radius={board.itemRadius}
p="xs"
onClick={() => setOpenedIntegrationId(integrationId)}
aria-label={t("widget.healthMonitoring.popover.information")}
>
<Stack gap="xs">
<Group
className="health-monitoring-disk-status"
justify="space-between"
align="center"
wrap="wrap"
gap={8}
>
<Group gap={4} wrap="nowrap">
<IconServer className="health-monitoring-disk-icon" size="1rem" />
<Text className="dihealth-monitoring-disk-name" size="xs">
{disk.deviceName}
</Text>
</Group>
{disk.temperature !== null && (
<IconInfoCircle className="health-monitoring-information-icon" size={30} />
</ActionIcon>
</Indicator>
<Modal
opened={openedIntegrationId === integrationId}
onClose={() => setOpenedIntegrationId(null)}
size="auto"
title={t("widget.healthMonitoring.popover.information")}
centered
>
<Stack gap="10px" className="health-monitoring-modal-stack">
<Divider />
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} />
</Stack>
</Modal>
</Box>
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{options.cpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{options.memory && (
<MemoryRing
available={healthInfo.memAvailableInBytes}
used={healthInfo.memUsedInBytes}
isTiny={isTiny}
/>
)}
{options.gpu &&
healthInfo.gpu.map((gpu) => (
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
))}
</Flex>
{isAdvanced && (
<Card radius={board.itemRadius} p="sm">
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
</Card>
)}
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
className={combineClasses(
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
classes.card,
)}
style={{ overflow: "visible" }}
key={disk.deviceName}
radius={board.itemRadius}
p="xs"
>
<Stack gap="xs">
<Group
className="health-monitoring-disk-status"
justify="space-between"
align="center"
wrap="wrap"
gap={8}
>
<Group gap={4} wrap="nowrap">
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
<Text className="health-monitoring-disk-temperature-value" size="xs">
{options.fahrenheit
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
: `${disk.temperature}°C`}
<IconServer className="health-monitoring-disk-icon" size="1rem" />
<Text className="dihealth-monitoring-disk-name" size="xs">
{disk.deviceName}
</Text>
</Group>
)}
<Group gap={4} wrap="nowrap">
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
<Text className="health-monitoring-disk-status-value" size="xs">
{disk.overallStatus ? disk.overallStatus : "N/A"}
{disk.temperature !== null && (
<Group gap={4} wrap="nowrap">
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
<Text className="health-monitoring-disk-temperature-value" size="xs">
{options.fahrenheit
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
: `${disk.temperature}°C`}
</Text>
</Group>
)}
<Group gap={4} wrap="nowrap">
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
<Text className="health-monitoring-disk-status-value" size="xs">
{disk.overallStatus ? disk.overallStatus : "N/A"}
</Text>
</Group>
</Group>
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} size="lg">
<Progress.Section
value={disk.percentage}
color={progressColor(disk.percentage)}
className="health-monitoring-disk-use-percentage"
/>
<Progress.Section
className="health-monitoring-disk-available-percentage"
value={100 - disk.percentage}
color="default"
/>
</Progress.Root>
<Group justify="space-between" gap={8} wrap="nowrap">
<Text className="health-monitoring-disk-use-value" size="xs" c="dimmed">
{t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
</Text>
<Text className="health-monitoring-disk-available-value" size="xs" c="dimmed">
{formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
</Text>
</Group>
</Group>
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} size="lg">
<Progress.Section
value={disk.percentage}
color={progressColor(disk.percentage)}
className="health-monitoring-disk-use-percentage"
/>
<Progress.Section
className="health-monitoring-disk-available-percentage"
value={100 - disk.percentage}
color="default"
/>
</Progress.Root>
<Group justify="space-between" gap={8} wrap="nowrap">
<Text className="health-monitoring-disk-use-value" size="xs" c="dimmed">
{t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
</Text>
<Text className="health-monitoring-disk-available-value" size="xs" c="dimmed">
{formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
</Text>
</Group>
</Stack>
</Card>
);
})}
</Stack>
);
})}
</Stack>
</Stack>
</Card>
);
})}
</Stack>
);
})}
</Stack>
</ScrollArea>
);
};
type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"];
const SystemInformationList = ({
healthInfo,
memoryUsage,
t,
compact = false,
}: {
healthInfo: HealthInfo;
memoryUsage: ReturnType<typeof formatMemoryUsage>;
t: TranslationFunction;
compact?: boolean;
}) => (
<List
className="health-monitoring-information-list"
center
spacing={compact ? 4 : "xs"}
size={compact ? "sm" : undefined}
>
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.memoryAvailable", {
memoryAvailable: memoryUsage.memFree.GB,
percent: String(memoryUsage.memFree.percent),
})}
</List.Item>
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.version", { version: healthInfo.version })}
</List.Item>
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={compact ? 18 : 30} />}>
{formatUptime(healthInfo.uptime, t)}
</List.Item>
{healthInfo.loadAverage && (
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.loadAverage")}: {healthInfo.loadAverage["1min"]}% /{" "}
{healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}%
</List.Item>
)}
</List>
);
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const months = uptimeDuration.months();
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { getAllowedPermissions, getSandboxFlags, isSupportedProtocol } from "./component";
const permissions = {
allowFullScreen: true,
allowPayment: false,
allowAutoPlay: true,
allowMicrophone: false,
allowCamera: false,
allowGeolocation: false,
allowModals: true,
};
describe("iframe policy", () => {
it("keeps sandbox-only modal permission out of Permissions Policy", () => {
expect(getAllowedPermissions(permissions)).toBe("fullscreen *; autoplay *");
expect(getSandboxFlags(permissions)).toContain("allow-modals");
});
it("accepts only HTTP protocols", () => {
expect(isSupportedProtocol("https://example.com")).toBe(true);
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
});
+48 -24
View File
@@ -1,6 +1,6 @@
"use client";
import { Box, Stack, Text, Title } from "@mantine/core";
import { Anchor, Box, Group, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
@@ -9,7 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
@@ -21,25 +21,37 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
}
return (
<Box h="100%" w="100%">
<iframe
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title="widget iframe"
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
>
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
</iframe>
</Box>
<Stack h="100%" w="100%" gap={0}>
{displayMode === "advanced" && (
<Group key="advanced-header" px="xs" py={4} wrap="nowrap" justify="space-between">
<Text size="xs" c="dimmed" truncate>
{getFrameTitle(embedUrl)}
</Text>
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
{embedUrl}
</Anchor>
</Group>
)}
<Box key="frame" style={{ flex: 1, minHeight: 0 }}>
<iframe
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title={getFrameTitle(embedUrl)}
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
>
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
</iframe>
</Box>
</Stack>
);
}
const supportedProtocols = ["http", "https"];
const isSupportedProtocol = (url: string) => {
export const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
@@ -74,19 +86,22 @@ const UnsupportedProtocol = () => {
);
};
const getAllowedPermissions = (
export const getAllowedPermissions = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
return (
objectEntries(permissions)
.filter(([_key, value]) => value)
// * means it applies to all origins
.map(([key]) => `${permissionMapping[key]} *`)
// * means it applies to all origins. Sandbox-only flags such as
// allow-modals intentionally have no Permissions Policy mapping.
.flatMap(([key, value]) => {
const permission = permissionMapping[key];
return value && permission ? [`${permission} *`] : [];
})
.join("; ")
);
};
const getSandboxFlags = (
export const getSandboxFlags = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
const baseSandbox = [
@@ -112,12 +127,21 @@ const getSandboxFlags = (
return baseSandbox;
};
const permissionMapping = {
const permissionMapping: Partial<
Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>
> = {
allowAutoPlay: "autoplay",
allowCamera: "camera",
allowFullScreen: "fullscreen",
allowGeolocation: "geolocation",
allowMicrophone: "microphone",
allowPayment: "payment",
allowModals: "allow-modals",
} satisfies Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>;
};
const getFrameTitle = (url: string) => {
try {
return new URL(url).hostname;
} catch {
return "iframe";
}
};
@@ -2,11 +2,19 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
import { ActionIcon, Box, Center, Group, Image, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import {
IconAlertCircle,
IconCalendar,
IconChevronLeft,
IconChevronRight,
IconPlayerPause,
IconPlayerPlay,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
@@ -16,8 +24,16 @@ import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({
integrationIds,
options,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const persistedState = widgetStateRef?.current?.immichAlbumCarousel as
| { currentPhotoIndex?: number; paused?: boolean }
| undefined;
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(() =>
typeof persistedState?.currentPhotoIndex === "number" ? persistedState.currentPhotoIndex : 0,
);
const [paused, setPaused] = useState(persistedState?.paused === true);
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
@@ -33,8 +49,42 @@ export default function ImmichAlbumCarouselWidget({
}, [album?.assets, options.randomizePhotos]);
useEffect(() => {
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (photoAssets.length === 0) return;
setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
}, [photoAssets.length]);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
immichAlbumCarousel: { currentPhotoIndex, paused },
};
}, [currentPhotoIndex, paused, widgetStateRef]);
useEffect(() => {
if (!widgetStateRef) return;
const clearActions = () => {
if (!widgetStateRef.current) return;
delete widgetStateRef.current.previousPhoto;
delete widgetStateRef.current.nextPhoto;
delete widgetStateRef.current.toggleSlideshow;
};
if (photoAssets.length <= 1) {
clearActions();
return;
}
const previousPhoto = () =>
setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length);
const nextPhoto = () => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length);
const toggleSlideshow = () => setPaused((value) => !value);
widgetStateRef.current = {
...widgetStateRef.current,
previousPhoto,
nextPhoto,
toggleSlideshow,
};
return clearActions;
}, [photoAssets.length, widgetStateRef]);
if (!album) return <WidgetEmptyState />;
@@ -53,6 +103,10 @@ export default function ImmichAlbumCarouselWidget({
setCurrentIndex={setCurrentPhotoIndex}
rotationInterval={options.rotationIntervalSeconds}
showPhotoInfo={options.showPhotoInfo}
albumName={album.albumName}
advanced={displayMode === "advanced"}
paused={paused}
setPaused={setPaused}
/>
);
}
@@ -73,7 +127,6 @@ function shuffle<T>(items: T[]) {
interface CarouselProps {
assets: {
id: string;
originalPath: string;
fileModifiedAt: string;
publicLink: string;
}[];
@@ -81,36 +134,126 @@ interface CarouselProps {
setCurrentIndex: Dispatch<SetStateAction<number>>;
rotationInterval: number;
showPhotoInfo: boolean;
albumName: string;
advanced: boolean;
paused: boolean;
setPaused: Dispatch<SetStateAction<boolean>>;
}
function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, showPhotoInfo }: CarouselProps) {
function Carousel({
assets,
currentIndex,
setCurrentIndex,
rotationInterval,
showPhotoInfo,
albumName,
advanced,
paused,
setPaused,
}: CarouselProps) {
const t = useScopedI18n("widget.immich-albumCarousel");
const reduceMotion = useReducedMotion();
useEffect(() => {
if (paused || reduceMotion || assets.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % assets.length);
}, rotationInterval * 1000);
return () => clearInterval(interval);
}, [assets.length, rotationInterval, setCurrentIndex]);
}, [assets.length, paused, reduceMotion, rotationInterval, setCurrentIndex]);
const safeCurrentIndex = Math.min(currentIndex, assets.length - 1);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const currentAsset = assets[currentIndex]!;
const currentAsset = assets[safeCurrentIndex]!;
const move = (offset: number) => setCurrentIndex((current) => (current + offset + assets.length) % assets.length);
return (
<Box w="100%" h="100%" className={classes.carouselContainer}>
<Image src={currentAsset.publicLink} alt="Album photo" className={classes.carouselImage} />
<Stack w="100%" h="100%" gap={0}>
<Box w="100%" style={{ flex: 1, minHeight: 0 }} className={classes.carouselContainer}>
<Image src={currentAsset.publicLink} alt={t("albumPhoto")} className={classes.carouselImage} />
{showPhotoInfo && (
<Stack gap="xs" className={classes.photoInfo} p="md">
<Group gap="xs">
<IconCalendar size={16} />
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString()}</Text>
{advanced && (
<>
<ActionIcon
aria-label={t("actions.previousPhoto")}
variant="filled"
radius="xl"
pos="absolute"
left="md"
top="50%"
onClick={() => move(-1)}
>
<IconChevronLeft size={18} />
</ActionIcon>
<Group pos="absolute" right="md" top="md" gap="xs">
<ActionIcon
aria-label={paused ? t("actions.resumeSlideshow") : t("actions.pauseSlideshow")}
variant="filled"
radius="xl"
onClick={() => setPaused((value) => !value)}
>
{paused ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
</ActionIcon>
</Group>
<ActionIcon
aria-label={t("actions.nextPhoto")}
variant="filled"
radius="xl"
pos="absolute"
right="md"
top="50%"
onClick={() => move(1)}
>
<IconChevronRight size={18} />
</ActionIcon>
</>
)}
{(showPhotoInfo || advanced) && (
<Stack gap="xs" className={classes.photoInfo} p="md">
{albumName && (
<Text size="xs" fw={600}>
{albumName}
</Text>
)}
<Group gap="xs">
<IconCalendar size={16} />
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString()}</Text>
</Group>
<Text size="xs" c="dimmed">
{safeCurrentIndex + 1} / {assets.length}
</Text>
</Stack>
)}
</Box>
{advanced && (
<ScrollArea type="never" scrollbarSize={0} px="xs" py={6}>
<Group gap={6} wrap="nowrap">
{assets.slice(0, 24).map((asset, index) => (
<UnstyledButton
key={asset.id}
onClick={() => setCurrentIndex(index)}
aria-label={t("actions.photo", { number: index + 1 })}
aria-pressed={index === safeCurrentIndex}
aria-current={index === safeCurrentIndex ? "true" : undefined}
>
<Image
src={asset.publicLink}
alt=""
w={64}
h={44}
radius="sm"
fit="cover"
style={{ opacity: index === currentIndex ? 1 : 0.55 }}
/>
</UnstyledButton>
))}
</Group>
<Text size="xs" c="dimmed">
{currentIndex + 1} / {assets.length}
</Text>
</Stack>
</ScrollArea>
)}
</Box>
</Stack>
);
}
@@ -1,4 +1,4 @@
import { IconPhoto } from "@tabler/icons-react";
import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
@@ -14,6 +14,37 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions({ widgetStateRef }) {
const call = (key: string) => () => {
const action = widgetStateRef.current?.[key];
if (typeof action === "function") action();
};
const disabled = (key: string) => typeof widgetStateRef.current?.[key] !== "function";
return [
{
key: "previousPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.previousPhoto"),
icon: IconChevronLeft,
disabled: disabled("previousPhoto"),
onClick: call("previousPhoto"),
},
{
key: "nextPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.nextPhoto"),
icon: IconChevronRight,
disabled: disabled("nextPhoto"),
onClick: call("nextPhoto"),
},
{
key: "toggleSlideshow",
label: (t) => t("widget.immich-albumCarousel.actions.toggleSlideshow"),
icon: IconPlayerPause,
disabled: disabled("toggleSlideshow"),
onClick: call("toggleSlideshow"),
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { Group, Stack, Text } from "@mantine/core";
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -15,16 +15,22 @@ import classes from "./component.module.css";
export default function ImmichServerStatsWidget({
integrationIds,
options,
displayMode = "compact",
width,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: displayMode === "advanced" && integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
if (!stats) return <WidgetEmptyState />;
return (
<Stack gap="md" h="100%" p="md">
const statsContent = (
<SimpleGrid cols={displayMode === "advanced" ? 4 : width >= 320 ? 2 : 1} spacing="sm">
{options.showUsers && (
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
)}
@@ -49,6 +55,39 @@ export default function ImmichServerStatsWidget({
value={formatBytes(stats.totalLibraryUsageInBytes)}
/>
)}
</SimpleGrid>
);
if (displayMode === "compact") {
return (
<Stack gap="md" h="100%" p="md" justify="center">
{statsContent}
</Stack>
);
}
const sortedAlbums = [...albums].toSorted((left, right) => right.assetCount - left.assetCount);
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return (
<Stack gap="lg" h="100%" p="lg">
{statsContent}
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
{sortedAlbums.map((album) => (
<Stack key={album.id} gap={4} p="xs">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{album.albumName}
</Text>
<Text size="xs" c="dimmed">
{album.assetCount.toLocaleString()}
</Text>
</Group>
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
</Stack>
))}
</SimpleGrid>
</ScrollArea>
</Stack>
);
}
@@ -9,6 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showUsers: factory.switch({
+1
View File
@@ -72,6 +72,7 @@ export type {
WidgetContextActionProps,
WidgetOptionsSettings,
} from "./definition";
export { getWidgetQueryKeys } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -1,6 +1,7 @@
"use client";
import { ActionIcon, Anchor, Button, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { useEffect, useMemo } from "react";
import { ActionIcon, Anchor, Badge, Button, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -16,6 +17,9 @@ export default function IndexerManagerWidget({
integrationIds,
width,
height,
displayMode,
isEditMode,
widgetStateRef,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
@@ -25,8 +29,22 @@ export default function IndexerManagerWidget({
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
});
const board = useRequiredBoard();
const hasSmallWidth = width < 256;
const hasSmallHeight = height < 256;
const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256;
const hasSmallHeight = !isAdvanced && height < 256;
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
const unavailableCount = allIndexers.filter((indexer) => !indexer.status || !indexer.enabled).length;
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
testAllIndexers: () => testAll({ integrationIds }),
};
return () => {
if (widgetStateRef.current) delete widgetStateRef.current.testAllIndexers;
};
}, [integrationIds, testAll, widgetStateRef]);
return (
<Flex className="indexer-manager-container" h="100%" direction="column" gap="sm" p="sm" align="center">
@@ -39,6 +57,18 @@ export default function IndexerManagerWidget({
<Text size={hasSmallWidth ? "xs" : "md"} fw="bold">
{t("widget.indexerManager.title")}
</Text>
{isAdvanced && (
<Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light">
{allIndexers.length - unavailableCount}
</Badge>
{unavailableCount > 0 && (
<Badge size="xs" color="red" variant="light">
{unavailableCount}
</Badge>
)}
</Group>
)}
{hasSmallHeight && (
<ActionIcon
className="indexer-manager-test-action-icon"
@@ -46,6 +76,7 @@ export default function IndexerManagerWidget({
radius={board.itemRadius}
variant="light"
loading={isPending}
disabled={isEditMode}
loaderProps={{ type: "dots" }}
onClick={() => {
testAll({ integrationIds });
@@ -63,8 +94,22 @@ export default function IndexerManagerWidget({
flex={1}
>
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, indexers }) => (
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{(isAdvanced || indexersData.length > 1) && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
</Text>
{error && (
<Tooltip label={error} multiline maw={360}>
<Badge size="xs" color="red" variant="light">
{t("common.error")}
</Badge>
</Tooltip>
)}
</Group>
)}
{indexers.map((indexer) => (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name}`}
@@ -82,6 +127,11 @@ export default function IndexerManagerWidget({
{indexer.name}
</Text>
</Anchor>
{isAdvanced && (
<Text size="xs" c="dimmed" truncate="end" style={{ flex: 1 }}>
{indexer.url}
</Text>
)}
{indexer.status === false || indexer.enabled === false ? (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
@@ -110,6 +160,7 @@ export default function IndexerManagerWidget({
variant="light"
leftSection={<IconTestPipe size={"1rem"} />}
loading={isPending}
disabled={isEditMode}
loaderProps={{ type: "dots" }}
onClick={() => {
testAll({ integrationIds });
+17 -1
View File
@@ -1,4 +1,4 @@
import { IconReportSearch, IconServerOff } from "@tabler/icons-react";
import { IconReportSearch, IconServerOff, IconTestPipe } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -7,6 +7,22 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
queryKey: [["widget", "indexerManager"]],
contextActions: ({ widgetStateRef, context }) => [
{
key: "test-all-indexers",
label: (t) => t("widget.indexerManager.testAll"),
icon: IconTestPipe,
disabled:
context.isEditMode ||
!context.canInteractWithSelectedIntegrations ||
typeof widgetStateRef.current?.testAllIndexers !== "function",
onClick: () => {
const action = widgetStateRef.current?.testAllIndexers;
if (typeof action === "function") action();
},
},
],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+124 -20
View File
@@ -1,7 +1,23 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
import { useEffect, useState } from "react";
import {
Badge,
Box,
Center,
Group,
Image,
Paper,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Tabs,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -12,14 +28,29 @@ 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 type { MediaMissingTab } from "./tabs";
import { resolveMediaMissingTab } from "./tabs";
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
export default function MediaMissingWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const pageSize = Number(options.pageSize);
const isAdvanced = displayMode === "advanced";
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
{ integrationIds, pageSize },
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
);
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
useEffect(() => {
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
}, [activeTab, selectedTab]);
if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError();
@@ -32,25 +63,26 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
</Center>
);
const missing = data.flatMap((entry) =>
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
);
const queued = data.flatMap((entry) =>
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
);
const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
const isThin = width > 0 && width < 160;
const isShort = height > 0 && height < 180;
const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const isShort = !isAdvanced && height > 0 && height < 180;
const targetCardWidth = isShort ? 130 : 200;
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable";
const tabLabel = (label: string, shown: number, total: number) =>
isThin ? total : `${label} (${shown}/${total})`;
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
const renderPanel = (
entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
emptyLabel: string,
) => (
<ScrollArea h="100%" scrollbarSize={4}>
<Box p="xs">
{entries.length === 0 ? (
@@ -68,12 +100,60 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
</ScrollArea>
);
const partialFailures = failedIntegrations.length > 0 && (
<Group gap={4} p={4} wrap="wrap">
{failedIntegrations.map((entry) => (
<Tooltip key={entry.integrationId} label={entry.error} multiline maw={360}>
<Badge size="xs" color="red" variant="light">
{entry.integrationName}
</Badge>
</Tooltip>
))}
</Group>
);
if (isAdvanced) {
return (
<Stack h="100%" gap={0}>
{partialFailures}
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
{options.showMissing && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconQuestionMark size={16} />
<Text size="sm" fw={600}>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Text>
</Group>
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
</Paper>
)}
{options.showQueued && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconDownload size={16} />
<Text size="sm" fw={600}>
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Text>
</Group>
<Box h="calc(100% - 40px)">{renderPanel(queued, t("empty.queued"))}</Box>
</Paper>
)}
</SimpleGrid>
</Stack>
);
}
return (
<Tabs
defaultValue={options.showMissing ? "missing" : "queued"}
value={activeTab}
onChange={(value) => {
if (value === "missing" || value === "queued") setSelectedTab(value);
}}
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
{partialFailures}
<Tabs.List grow>
{options.showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
@@ -116,7 +196,14 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
}
return (
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
<ThemeIcon
className={classes.poster}
h={size}
w={w}
radius="sm"
variant="light"
color={type === "movie" ? "yellow" : "blue"}
>
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
</ThemeIcon>
);
@@ -183,8 +270,25 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
);
};
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
const CardShell = ({
item,
density,
children,
}: {
item: MissingMediaItem | QueuedMediaItem;
density: Density;
children: ReactNode;
}) => (
<Paper
className={classes.card}
component="a"
href={item.link}
target="_blank"
rel="noreferrer"
radius="sm"
p="xs"
h={CARD_HEIGHT[density]}
>
{item.imageUrl && (
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
)}
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { resolveMediaMissingTab } from "./tabs";
describe("resolveMediaMissingTab", () => {
test("preserves the selected tab while it remains enabled", () => {
expect(resolveMediaMissingTab("queued", true, true)).toBe("queued");
});
test("falls back when the selected tab is disabled", () => {
expect(resolveMediaMissingTab("missing", false, true)).toBe("queued");
expect(resolveMediaMissingTab("queued", true, false)).toBe("missing");
});
test("returns no tab when both panels are disabled", () => {
expect(resolveMediaMissingTab("missing", false, false)).toBeNull();
});
});
@@ -0,0 +1,13 @@
export type MediaMissingTab = "missing" | "queued";
export const resolveMediaMissingTab = (
current: MediaMissingTab,
showMissing: boolean,
showQueued: boolean,
): MediaMissingTab | null => {
if (current === "missing" && showMissing) return current;
if (current === "queued" && showQueued) return current;
if (showMissing) return "missing";
if (showQueued) return "queued";
return null;
};
@@ -1,7 +1,20 @@
"use client";
import { Fragment } from "react";
import { Avatar, Badge, Box, Divider, Group, Image, Stack, Text, TooltipFloating, UnstyledButton } from "@mantine/core";
import {
Avatar,
Badge,
Box,
Divider,
Group,
Image,
ScrollArea,
SimpleGrid,
Stack,
Text,
TooltipFloating,
UnstyledButton,
} from "@mantine/core";
import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
@@ -17,26 +30,36 @@ import { OverflowBadge } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
export default function MediaReleasesWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
const isAdvanced = displayMode === "advanced";
return (
<Stack p="xs" gap="sm">
{releases.map((item, index) => (
<Fragment key={item.id}>
{index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} />
</Fragment>
))}
</Stack>
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
);
}
interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
isAdvanced: boolean;
}
const formatReleaseDate = (value: unknown, locale: string) => {
@@ -52,7 +75,7 @@ const formatReleaseDate = (value: unknown, locale: string) => {
}).format(date);
};
const Item = ({ item, options }: ItemProps) => {
const Item = ({ item, options, isAdvanced }: ItemProps) => {
const locale = useCurrentLocale();
const t = useI18n();
const length = formatLength(item.length, item.type, t);
@@ -62,7 +85,12 @@ const Item = ({ item, options }: ItemProps) => {
label={item.description}
w={300}
multiline
disabled={item.description === undefined || item.description.trim() === "" || !options.showDescriptionTooltip}
disabled={
isAdvanced ||
item.description === undefined ||
item.description.trim() === "" ||
!options.showDescriptionTooltip
}
>
<UnstyledButton
component="a"
@@ -70,9 +98,18 @@ const Item = ({ item, options }: ItemProps) => {
target="_blank"
rel="noopener noreferrer"
pos="relative"
p={options.layout === "poster" ? 0 : 4}
p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
h="100%"
style={
isAdvanced
? {
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-sm)",
}
: undefined
}
>
{options.layout === "backdrop" && (
{!isAdvanced && options.layout === "backdrop" && (
<Box
w="100%"
h="100%"
@@ -91,7 +128,9 @@ const Item = ({ item, options }: ItemProps) => {
)}
<Group justify="space-between" h="100%" wrap="nowrap">
<Group align="start" wrap="nowrap" style={{ zIndex: 0 }}>
{options.layout === "poster" && <Image w={60} src={item.imageUrls.poster} alt={item.title} />}
{(isAdvanced || options.layout === "poster") && (
<Image w={isAdvanced ? 80 : 60} src={item.imageUrls.poster} alt={item.title} radius="xs" />
)}
<Stack gap={4}>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={2}>
@@ -136,15 +175,20 @@ const Item = ({ item, options }: ItemProps) => {
groupGap={4}
data={item.tags}
overflowCount={3}
disablePopover
disablePopover={!isAdvanced}
style={{ cursor: "pointer" }}
/>
)}
{isAdvanced && item.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{item.description}
</Text>
)}
</Stack>
</Group>
{(options.showType || options.showSource) && (
{(isAdvanced || options.showType || options.showSource) && (
<Stack justify="space-between" align="end" h="100%" style={{ zIndex: 0 }}>
{options.showType && (
{(isAdvanced || options.showType) && (
<Badge
w="max-content"
size="xs"
@@ -155,7 +199,7 @@ const Item = ({ item, options }: ItemProps) => {
</Badge>
)}
{options.showSource && (
{(isAdvanced || options.showSource) && (
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
)}
</Stack>
@@ -18,6 +18,7 @@ import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { toValidDate } from "@homarr/common";
import type { MediaRequestStatus } from "@homarr/integrations/types";
@@ -35,7 +36,14 @@ export default function MediaServerWidget({
isEditMode,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
.filter(({ id }) => integrationIds.includes(id))
.map(({ id }) => id),
);
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
@@ -61,7 +69,9 @@ export default function MediaServerWidget({
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={width <= 256}
isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)}
isAdvanced={displayMode === "advanced"}
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
options={options}
/>
))}
@@ -92,10 +102,12 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean;
isAdvanced: boolean;
canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) => {
const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useScopedI18n("widget.mediaRequests-requestList");
@@ -152,6 +164,11 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
{t(`availability.${request.availability}`)}
</Badge>
)}
{isAdvanced && (
<Badge size="xs" variant="outline">
{request.integration.name}
</Badge>
)}
</Group>
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap">
<Avatar
@@ -186,7 +203,11 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
{request.name || "unknown"}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons requestId={request.id} integrationId={request.integrationId} />
<DecisionButtons
requestId={request.id}
integrationId={request.integrationId}
canInteract={canInteract}
/>
) : (
<StatusBadge status={request.status} />
)}
@@ -201,15 +222,21 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
interface DecisionButtonsProps {
requestId: number;
integrationId: string;
canInteract: boolean;
}
const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => {
const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButtonsProps) => {
const utils = clientApi.useUtils();
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation({
const {
mutate: mutateRequestAnswer,
isPending,
error,
} = clientApi.widget.mediaRequests.answerRequest.useMutation({
onSettled: () => void utils.widget.mediaRequests.invalidate(),
});
const t = useScopedI18n("widget.mediaRequests-requestList");
const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => {
if (!canInteract || isPending) return;
mutateRequestAnswer({
integrationId,
requestId,
@@ -218,13 +245,15 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
};
return (
<Group className="mediaRequests-list-item-pending-buttons" gap="sm">
<Group className="mediaRequests-list-item-pending-buttons" gap="sm" title={error?.message}>
<Tooltip label={t("pending.approve")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-approve"
variant="light"
color="green"
size="xs"
disabled={!canInteract || isPending}
aria-label={t("pending.approve")}
onClick={() => {
handleDecision("approve");
}}
@@ -238,6 +267,8 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
variant="light"
color="red"
size="xs"
disabled={!canInteract || isPending}
aria-label={t("pending.decline")}
onClick={() => {
handleDecision("decline");
}}
@@ -1,6 +1,6 @@
"use client";
import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Card, Grid, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import type { Icon } from "@tabler/icons-react";
import {
IconDeviceTv,
@@ -34,6 +34,8 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({
@@ -88,7 +90,12 @@ export default function MediaServerWidget({
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
const isTiny = width < 256;
const isTiny = displayMode !== "advanced" && (width < 256 || height < 180);
const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8);
const visibleUsers = requestStats.users.slice(
0,
displayMode === "advanced" ? 50 : Math.max(1, Math.floor(height / 44) - 3),
);
return (
<Box className={searchClasses.searchRoot}>
@@ -107,7 +114,7 @@ export default function MediaServerWidget({
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
{data.map((stat) => (
{visibleStats.map((stat) => (
<Grid.Col
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
key={stat.name}
@@ -131,44 +138,61 @@ export default function MediaServerWidget({
<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}
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} w="100%">
<Stack gap={4}>
{visibleUsers.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.integration.id}:${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>
<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>
))}
</Stack>
</Card>
))}
</Stack>
</ScrollArea>
{displayMode === "advanced" && (
<Group justify="center" gap={4}>
{requestStats.integrations.map((integration) => (
<Badge key={integration.id} size="xs" variant="light">
{integration.name}
</Badge>
))}
</Group>
)}
</Stack>
</Stack>
</Box>
+377 -238
View File
@@ -1,8 +1,19 @@
"use client";
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
import { useMemo, useState } from "react";
import {
Avatar,
Badge,
Divider,
Group,
Popover,
Progress,
SimpleGrid,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import {
IconDeviceTv,
IconHeadphones,
@@ -17,14 +28,17 @@ import { MantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
import { getIconUrl, integrationDefs } from "@homarr/definitions";
import { getIconUrl } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import classes from "./component.module.css";
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
@@ -53,135 +67,117 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
}
export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
export default function MediaServerWidget({
options,
integrationIds,
width,
height,
isEditMode,
displayMode,
}: WidgetComponentProps<"mediaServer">) {
const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
});
const currentStreamsData = getUsableWidgetQueryData(currentStreamsQuery);
const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]);
const t = useScopedI18n("widget.mediaServer");
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [
{
accessorKey: "user.username",
header: t("items.user"),
size: 160,
const isAdvanced = displayMode === "advanced";
const showLocation = isAdvanced || options.showLocation;
const showBitrate = isAdvanced || options.showBitrate;
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
const result: MRT_ColumnDef<StreamSession>[] = [
...(width >= 300 || isAdvanced
? [
{
accessorKey: "user.username",
header: t("items.user"),
size: 160,
Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Avatar size={28} src={row.original.user.profilePictureUrl} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.original.user.username}
</Text>
<Text
size="10px"
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{row.original.sessionName}
</Text>
</Stack>
</Group>
),
},
Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Avatar size={28} src={row.original.user?.profilePictureUrl} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.original.user?.username ?? t("items.unknownUser")}
</Text>
<Text
size="10px"
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{row.original.sessionName}
</Text>
</Stack>
</Group>
),
} satisfies MRT_ColumnDef<StreamSession>,
]
: []),
{
accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name
header: t("items.currentlyPlaying"),
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
return (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Icon
size={16}
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
style={{ flexShrink: 0 }}
/>
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused && (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
)}
{!isPaused && remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)}
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "rgba(255, 255, 255, 0.15)" }}
/>
)}
</Stack>
<SessionDetailsPopover
item={row.original}
opened={selectedRowId === row.id}
onChange={(opened) => setSelectedRowId(opened ? row.id : null)}
>
<CurrentlyPlaying item={row.original} />
</SessionDetailsPopover>
);
},
},
{
id: "status",
header: t("items.status"),
size: 110,
} satisfies MRT_ColumnDef<StreamSession>,
...(width >= 420 || isAdvanced
? [
{
id: "status",
header: t("items.status"),
size: 110,
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
const bitrateLabel = formatBitrate(currentlyPlaying.metadata?.bitrateKbps);
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
const location = showLocation ? currentlyPlaying.location : null;
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null;
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{(currentlyPlaying.location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center">
{currentlyPlaying.location &&
(currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{currentlyPlaying.location && (
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{(location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center">
{location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{location && (
<Text size="10px" c="dimmed" tt="uppercase">
{location}
</Text>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed">
{bitrateLabel}
</Text>
)}
</Group>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed">
{bitrateLabel}
</Text>
)}
</Group>
)}
</Stack>
);
},
},
],
[t],
);
</Stack>
);
},
} satisfies MRT_ColumnDef<StreamSession>,
]
: []),
];
return result;
}, [isAdvanced, selectedRowId, showBitrate, showLocation, t, width]);
// Only render the flat list of sessions when the currentStreams change
// Otherwise it will always create a new array reference and cause the table to re-render
@@ -190,37 +186,34 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
currentStreams.flatMap((pair) =>
pair.sessions.map((session) => ({
...session,
integrationId: pair.integrationId,
integrationKind: pair.integrationKind,
integrationName: integrationDefs[pair.integrationKind].name,
integrationName: pair.integrationName,
integrationIcon: getIconUrl(pair.integrationKind),
})),
),
[currentStreams],
);
const { openModal } = useModalAction(ItemInfoModal);
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
enablePagination: false,
enableTopToolbar: false,
enableTopToolbar: isAdvanced,
enableBottomToolbar: false,
enableSorting: false,
enableSorting: isAdvanced,
enableColumnActions: false,
enableStickyHeader: false,
enableColumnOrdering: false,
enableRowSelection: false,
enableFullScreenToggle: false,
enableGlobalFilter: false,
enableGlobalFilter: isAdvanced,
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
enableColumnPinning: true,
enableColumnPinning: false,
initialState: {
density: "xs",
columnPinning: {
right: ["currentlyPlaying", "status"],
},
},
mantineTableHeadProps: {
fz: "xs",
@@ -232,6 +225,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
flex: 1,
withBorder: false,
shadow: undefined,
className: classes.tablePaper,
},
mantineTableProps: {
className: "media-server-widget-table",
@@ -240,21 +234,32 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
},
},
mantineTableContainerProps: {
className: classes.tableContainer,
style: {
height: "100%",
pointerEvents: isEditMode ? "none" : undefined,
},
},
mantineTableBodyCellProps: ({ row }) => ({
onClick: () => {
openModal(
{
item: row.original,
},
{
title: row.original.sessionName,
},
);
},
mantineTableBodyRowProps: ({ row }) => {
const toggleDetails = () => setSelectedRowId((current) => (current === row.id ? null : row.id));
return {
className: isEditMode ? undefined : classes.sessionRow,
tabIndex: isEditMode ? -1 : 0,
"aria-label": row.original.sessionName,
"aria-haspopup": isEditMode ? undefined : "dialog",
"aria-expanded": isEditMode ? undefined : selectedRowId === row.id,
onClick: isEditMode ? undefined : toggleDetails,
onKeyDown: isEditMode
? undefined
: (event) => {
if (event.currentTarget !== event.target) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggleDetails();
},
};
},
mantineTableBodyCellProps: () => ({
py: 4,
style: {
overflowX: "hidden",
@@ -263,145 +268,279 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
}),
});
const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
const session = flatSessions.find((session) => session.integrationKind === kind);
return {
integrationKind: kind,
integrationIcon: session?.integrationIcon,
integrationName: session?.integrationName,
};
});
const uniqueIntegrations = currentStreams.map((stream) => ({
integrationId: stream.integrationId,
integrationKind: stream.integrationKind,
integrationIcon: getIconUrl(stream.integrationKind),
integrationName: stream.integrationName,
}));
const playingCount = flatSessions.filter(
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
).length;
const transcodingCount = flatSessions.filter((session) => {
const status = getPlaybackStatus(session.currentlyPlaying?.metadata?.transcoding);
return status !== "directPlay" && status !== "directStream";
}).length;
const totalBitrateKbps = flatSessions.reduce(
(sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0),
0,
);
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
const totalBitrateLabel = showBitrate ? formatBitrate(totalBitrateKbps) : null;
const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error));
return (
<Stack gap={0} h="100%" display="flex">
<Stack className={classes.root} gap={0} h="100%" display="flex">
{isAdvanced && (
<Group px="xs" py={4} gap="xs">
<Badge variant="light">{t("summary.sessions", { count: flatSessions.length })}</Badge>
<Badge variant="light" color="green">
{t("summary.playing", { count: playingCount })}
</Badge>
<Badge variant="light" color="orange">
{t("summary.transcoding", { count: transcodingCount })}
</Badge>
<Group ml="auto">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
<Group px="xs" justify="flex-end">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
<MantineReactTable table={table} />
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
<Group gap={4} wrap="nowrap">
<IconVideo size={16} style={{ flexShrink: 0 }} />
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
{(isAdvanced || height >= 144) && (
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
<Group gap={4} wrap="nowrap">
<IconVideo size={16} style={{ flexShrink: 0 }} />
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
)}
</Group>
<Group gap="xs">
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
{totalBitrateLabel && (isAdvanced || width >= 300) && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</Group>
))}
)}
</Group>
<Group gap="xs">
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationId} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
{(isAdvanced || width >= 480) && (
<Text className="media-server-name" size="sm" truncate="end">
{integration.integrationName}
</Text>
)}
</Group>
))}
</Group>
</Group>
</Group>
)}
</Stack>
);
}
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
const t = useScopedI18n("widget.mediaServer.items");
const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null;
function CurrentlyPlaying({ item }: { item: StreamSession }) {
const t = useScopedI18n("widget.mediaServer");
const currentlyPlaying = item.currentlyPlaying;
if (!currentlyPlaying) {
return (
<Text size="xs" c="dimmed">
{item.sessionName}
</Text>
);
}
const metadata = useMemo(() => {
return innerProps.item.currentlyPlaying?.metadata
? constructMetadata(innerProps.item.currentlyPlaying.metadata)
const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
}, [innerProps.item.currentlyPlaying?.metadata]);
const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
return (
<Stack align="center">
<Flex direction="column" gap="xs" align="center">
{Icon && innerProps.item.currentlyPlaying !== null && (
<Group gap="sm" align="center">
<Icon size={24} />
<Title order={2}>{innerProps.item.currentlyPlaying.name}</Title>
</Group>
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Icon size={16} color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} style={{ flexShrink: 0 }} />
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused ? (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
) : (
remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)
)}
{innerProps.item.currentlyPlaying?.episodeName && (
<Group>
<Title order={4}>{innerProps.item.currentlyPlaying.episodeName}</Title>
{innerProps.item.currentlyPlaying.seasonName && (
<>
{" - "}
<Title order={4}>{innerProps.item.currentlyPlaying.seasonName}</Title>
</>
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "var(--mantine-color-default-border)" }}
/>
)}
</Stack>
);
}
export function SessionDetailsPopover({
item,
opened,
onChange,
children,
}: {
item: StreamSession;
opened: boolean;
onChange: (opened: boolean) => void;
children: ReactNode;
}) {
return (
<Popover
opened={opened}
onChange={onChange}
position="bottom-start"
width="min(26.25rem, calc(100vw - 1.5rem))"
shadow="md"
withArrow
withinPortal
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.detailsTarget}
aria-label={item.sessionName}
onClick={(event) => {
event.stopPropagation();
onChange(!opened);
}}
>
{children}
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<SessionDetails item={item} />
</Popover.Dropdown>
</Popover>
);
}
function SessionDetails({ item }: { item: StreamSession }) {
const t = useScopedI18n("widget.mediaServer.items");
const Icon = item.currentlyPlaying ? mediaTypeIconMap[item.currentlyPlaying.type] : null;
const metadata = item.currentlyPlaying?.metadata ? constructMetadata(item.currentlyPlaying.metadata) : null;
return (
<Stack gap="sm" style={{ minWidth: 0 }}>
{Icon && item.currentlyPlaying && (
<Group gap="sm" align="flex-start" wrap="nowrap">
<Icon size={22} style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fw={600} lineClamp={2}>
{item.currentlyPlaying.name}
</Text>
{item.currentlyPlaying.episodeName && (
<Text size="xs" c="dimmed" lineClamp={2}>
{[item.currentlyPlaying.episodeName, item.currentlyPlaying.seasonName].filter(Boolean).join(" · ")}
</Text>
)}
</Group>
)}
</Flex>
</Stack>
</Group>
)}
<NormalizedLine
itemKey={t("user")}
value={
<Group gap="sm" align="center">
<Avatar size="sm" src={innerProps.item.user.profilePictureUrl} />{" "}
<Text>{innerProps.item.user.username}</Text>
<Group gap="xs" align="center" wrap="nowrap">
<Avatar size="xs" src={item.user?.profilePictureUrl} />
<Text size="sm" truncate>
{item.user?.username ?? t("unknownUser")}
</Text>
</Group>
}
/>
<NormalizedLine itemKey={t("name")} value={<Text>{innerProps.item.sessionName}</Text>} />
<NormalizedLine itemKey={t("id")} value={<Text>{innerProps.item.sessionId}</Text>} />
<NormalizedLine
itemKey={t("name")}
value={
<Text size="sm" truncate>
{item.sessionName}
</Text>
}
/>
<NormalizedLine
itemKey={t("id")}
value={
<Text size="xs" ff="monospace" truncate>
{item.sessionId}
</Text>
}
/>
{metadata ? (
<Stack w="100%" gap={0}>
<Divider label={t("metadata.title")} labelPosition="center" mt="lg" mb="sm" />
<Group align="flex-start">
{objectEntries(metadata).map(([key, value], index) => (
<Fragment key={key}>
{index !== 0 && <Divider key={index} orientation="vertical" />}
<Stack gap={4}>
<Text fw="bold">{t(`metadata.${key}.title`)}</Text>
{Object.entries(value)
.filter(([_, value]) => Boolean(value))
.map(([innerKey, value]) => (
<Group justify="space-between" w="100%" key={innerKey} wrap="nowrap">
<Text>{t(`metadata.${key}.${innerKey}` as never)}</Text>
<Text>{value}</Text>
</Group>
))}
</Stack>
</Fragment>
<Stack gap="xs">
<Divider label={t("metadata.title")} labelPosition="left" />
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm" verticalSpacing="xs">
{objectEntries(metadata).map(([key, entries]) => (
<Stack key={key} gap={4} style={{ minWidth: 0 }}>
<Text fw={600} size="xs">
{t(`metadata.${key}.title`)}
</Text>
{Object.entries(entries)
.filter(([_, entryValue]) => Boolean(entryValue))
.map(([innerKey, entryValue]) => (
<Group justify="space-between" gap="xs" key={innerKey} wrap="nowrap">
<Text size="xs" c="dimmed">
{t(`metadata.${key}.${innerKey}` as never)}
</Text>
<Text size="xs" ta="right" truncate>
{entryValue}
</Text>
</Group>
))}
</Stack>
))}
</Group>
</SimpleGrid>
</Stack>
) : null}
</Stack>
);
}).withOptions({
defaultTitle() {
return "";
},
size: "lg",
centered: true,
});
}
const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => {
return (
<Group w="100%" align="top" justify="space-between">
<Text>{itemKey}:</Text>
{value}
<Group w="100%" gap="md" align="flex-start" justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{itemKey}:
</Text>
<Stack gap={0} align="flex-end" style={{ minWidth: 0 }}>
{value}
</Stack>
</Group>
);
};
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
queryKey: [["widget", "mediaServer"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -1,7 +1,19 @@
"use client";
import { useState } from "react";
import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import {
Center,
Divider,
Group,
Pagination,
Paper,
ScrollArea,
SegmentedControl,
SimpleGrid,
Stack,
Text,
} from "@mantine/core";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -30,23 +42,73 @@ export default function MediaTranscodingWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaTranscoding">) {
const [queuePage, setQueuePage] = useState(1);
const queuePageSize = 10;
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({
page: 1,
pageSize: queuePageSize,
isAdvanced,
}));
const requestPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, null);
const input = {
integrationId: integrationIds[0] ?? "",
pageSize: queuePageSize,
page: queuePage,
page: requestPagination.page,
};
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState<View>(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
const totalQueuePages = transcodingData
? Math.max(1, Math.ceil(transcodingData.data.queue.totalCount / queuePageSize))
: null;
const resolvedPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, totalQueuePages);
const queuePage = resolvedPagination.page;
useEffect(() => {
setQueuePagination((current) => {
const next = resolveQueuePagination(current, queuePageSize, isAdvanced, totalQueuePages);
return current.page === next.page && current.pageSize === next.pageSize && current.isAdvanced === next.isAdvanced
? current
: next;
});
}, [isAdvanced, queuePageSize, totalQueuePages]);
const handleQueuePageChange = (page: number) => {
setQueuePagination({ page, pageSize: queuePageSize, isAdvanced });
};
if (!transcodingData) return <WidgetEmptyState />;
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const isTiny = width < 256;
const queuePageCount = totalQueuePages ?? 1;
const isTiny = !isAdvanced && width < 256;
if (isAdvanced) {
return (
<Stack gap="xs" h="100%" p="xs">
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
<WorkersPanel workers={transcodingData.data.workers} isTiny={false} />
</AdvancedPanel>
<AdvancedPanel title={t("tab.queue")} icon={IconClipboardList}>
<QueuePanel queue={transcodingData.data.queue} />
</AdvancedPanel>
<AdvancedPanel title={t("tab.statistics")} icon={IconReportAnalytics}>
<StatisticsPanel statistics={transcodingData.data.statistics} />
</AdvancedPanel>
</SimpleGrid>
</ScrollArea>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Pagination total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs" />
<HealthCheckStatus statistics={transcodingData.data.statistics} />
</Group>
</Stack>
);
}
return (
<Stack gap={4} h="100%">
@@ -89,12 +151,12 @@ export default function MediaTranscodingWidget({
<Group gap="xs" ml="auto">
{view === "queue" && (
<>
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="xs">
<Pagination.Root total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs">
<Group gap={2} justify="center">
{!isTiny && <Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />}
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
{!isTiny && <Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />}
{!isTiny && <Pagination.First disabled={queuePage === 1} />}
<Pagination.Previous disabled={queuePage === 1} />
<Pagination.Next disabled={queuePage === queuePageCount} />
{!isTiny && <Pagination.Last disabled={queuePage === queuePageCount} />}
</Group>
</Pagination.Root>
<Text size="xs">
@@ -113,3 +175,40 @@ export default function MediaTranscodingWidget({
</Stack>
);
}
export const getQueuePageSize = (height: number, isAdvanced: boolean): number => {
if (isAdvanced) return 25;
return Math.max(3, Math.min(15, Math.floor((height - 84) / 28)));
};
interface QueuePaginationState {
page: number;
pageSize: number;
isAdvanced: boolean;
}
export const resolveQueuePagination = (
state: QueuePaginationState,
pageSize: number,
isAdvanced: boolean,
totalPages: number | null,
): QueuePaginationState => ({
page:
state.pageSize !== pageSize || state.isAdvanced !== isAdvanced
? 1
: Math.max(1, totalPages === null ? state.page : Math.min(state.page, totalPages)),
pageSize,
isAdvanced,
});
const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => (
<Paper withBorder radius="sm" p="xs" mih={280} style={{ display: "flex", flexDirection: "column" }}>
<Group gap="xs" mb="xs">
<Icon size={16} />
<Text size="sm" fw={600}>
{title}
</Text>
</Group>
{children}
</Paper>
);
@@ -11,6 +11,8 @@ export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
queryKey: [["widget", "mediaTranscoding"]],
maxIntegrations: 1,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { getQueuePageSize, resolveQueuePagination } from "./component";
import { toRingProgressSections } from "./panels/statistics.panel";
describe("media transcoding layout", () => {
test("keeps compact queues bounded to the available height", () => {
expect(getQueuePageSize(140, false)).toBe(3);
expect(getQueuePageSize(500, false)).toBe(14);
expect(getQueuePageSize(2000, false)).toBe(15);
});
test("loads a bounded deeper queue in advanced mode", () => {
expect(getQueuePageSize(200, true)).toBe(25);
});
test("resets the queue when display mode or page size changes", () => {
const compact = { page: 7, pageSize: 10, isAdvanced: false };
expect(resolveQueuePagination(compact, 25, true, 8)).toEqual({ page: 1, pageSize: 25, isAdvanced: true });
expect(resolveQueuePagination(compact, 12, false, 8)).toEqual({ page: 1, pageSize: 12, isAdvanced: false });
});
test("clamps the queue page when the result count shrinks", () => {
expect(resolveQueuePagination({ page: 7, pageSize: 10, isAdvanced: false }, 10, false, 3)).toEqual({
page: 3,
pageSize: 10,
isAdvanced: false,
});
});
test("does not create NaN chart sections for empty statistics", () => {
expect(toRingProgressSections([{ name: "none", value: 0 }])).toEqual([]);
});
});
@@ -71,8 +71,9 @@ const StatisticRingProgress = ({ items, label }: StatisticRingProgressProps) =>
);
};
function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
export function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
const total = segments.reduce((prev, curr) => prev + curr.value, 0);
if (total <= 0) return [];
return segments.map((segment, index) => ({
value: (segment.value * 100) / total,
tooltip: `${segment.name}: ${segment.value}`,
@@ -1,23 +1,31 @@
"use client";
import { Box, Flex, Group, Text, Tooltip } from "@mantine/core";
import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { IconCube, IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
export default function MinecraftServerStatusWidget({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
const t = useI18n();
if (!result) return <WidgetEmptyState />;
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
const isAdvanced = displayMode === "advanced";
const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45));
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
return (
<Flex
@@ -25,24 +33,38 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
h="100%"
w="100%"
direction="column"
p="sm"
p={isAdvanced ? "lg" : "sm"}
justify="center"
align="center"
>
<Group gap="xs" wrap="nowrap" align="center">
<Tooltip label={data.online ? tStatus("online") : tStatus("offline")}>
<Tooltip
label={
data.online
? t("widget.minecraftServerStatus.status.online")
: t("widget.minecraftServerStatus.status.offline")
}
>
<Box miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
</Tooltip>
<Text size="md" fw="bold">
<Text size={isAdvanced ? "xl" : "md"} fw="bold" truncate="end">
{title}
</Text>
</Group>
{isAdvanced && (
<Group gap="xs" mt="xs" wrap="wrap" justify="center">
<Badge variant="light">{options.domain}</Badge>
{options.isBedrockServer && (
<Badge variant="outline">{t("widget.minecraftServerStatus.option.isBedrockServer.label")}</Badge>
)}
</Group>
)}
{data.online && (
<>
{!options.isBedrockServer &&
(data.icon ? (
<img
style={{ flex: 1, transform: "scale(0.8)", objectFit: "contain" }}
style={{ flex: 1, width: iconSize, maxHeight: iconSize, objectFit: "contain" }}
alt={`minecraft icon ${options.domain}`}
src={data.icon}
/>
@@ -55,17 +77,25 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
justifyContent: "center",
}}
>
<IconCube size="3rem" color="var(--mantine-color-gray-5)" />
<IconCube size={iconSize} color="var(--mantine-color-gray-5)" />
</Box>
))}
<Group gap={5} c="gray.6" align="center">
<IconUsersGroup size="1rem" />
<Text size="md">
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
</Text>
</Group>
<Stack gap={4} w={isAdvanced ? "min(100%, 420px)" : "auto"} align="stretch">
<Group gap={5} c="dimmed" align="center" justify="center">
<IconUsersGroup size={isAdvanced ? "1.25rem" : "1rem"} />
<Text size={isAdvanced ? "lg" : "md"}>
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
</Text>
</Group>
{isAdvanced && <Progress value={playerPercent} color={playerPercent >= 90 ? "orange" : "teal"} />}
</Stack>
</>
)}
{isAdvanced && !data.online && (
<Text mt="md" c="dimmed" size="sm">
{options.domain}
</Text>
)}
</Flex>
);
}
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -41,11 +42,13 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
integrationSupport: boolean;
settings: SettingsContextProps;
itemId?: string;
boardId?: string;
appId?: string;
}
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const board = useOptionalBoard();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
@@ -154,6 +157,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
);
})}
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Box } from "@mantine/core";
import { Box, Card, SimpleGrid } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import objectSupport from "dayjs/plugin/objectSupport";
@@ -20,26 +20,33 @@ dayjs.extend(duration);
export default function NetworkControllerNetworkStatusWidget({
options,
integrationIds,
displayMode,
}: WidgetComponentProps<"networkControllerStatus">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const isAdvanced = displayMode === "advanced";
const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0);
const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0);
return (
<Box p={"sm"}>
{options.content === "wifi" ? (
<WifiVariant
countGuests={data.reduce((sum, summary) => sum + summary.wifi.guests, 0)}
countUsers={data.reduce((sum, summary) => sum + summary.wifi.users, 0)}
/>
) : (
<WiredVariant
countGuests={data.reduce((sum, summary) => sum + summary.lan.guests, 0)}
countUsers={data.reduce((sum, summary) => sum + summary.lan.users, 0)}
/>
)}
<Box p={isAdvanced ? "md" : "sm"} h="100%">
<SimpleGrid cols={isAdvanced ? 2 : 1} h="100%" spacing="sm">
{(isAdvanced || options.content === "wifi") && (
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
<WifiVariant countGuests={countWifiGuests} countUsers={countWifiUsers} compact={!isAdvanced} />
</Card>
)}
{(isAdvanced || options.content === "wired") && (
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
<WiredVariant countGuests={countLanGuests} countUsers={countLanUsers} compact={!isAdvanced} />
</Card>
)}
</SimpleGrid>
</Box>
);
}
@@ -1,12 +1,20 @@
import { Stack, Text } from "@mantine/core";
export const StatRow = ({ label, value }: { label: string; value: string | number }) => {
export const StatRow = ({
label,
value,
compact = false,
}: {
label: string;
value: string | number;
compact?: boolean;
}) => {
return (
<Stack gap={0}>
<Text size={"2xl"} fw={900} lh={1}>
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1}>
{value}
</Text>
<Text size={"md"} c={"dimmed"}>
<Text size={compact ? "sm" : "md"} c="dimmed">
{label}
</Text>
</Stack>
@@ -5,19 +5,27 @@ import { useScopedI18n } from "@homarr/translation/client";
import { StatRow } from "./stat-row";
export const WifiVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
export const WifiVariant = ({
countGuests,
countUsers,
compact = false,
}: {
countUsers: number;
countGuests: number;
compact?: boolean;
}) => {
const t = useScopedI18n("widget.networkControllerStatus.card");
return (
<>
<Group gap={"xs"} wrap={"nowrap"} mb={"md"}>
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
<IconWifi size={24} />
<Text size={"md"} fw={"bold"}>
{t("variants.wifi.name")}
</Text>
</Group>
<Stack gap={"lg"}>
<StatRow label={t("users.label")} value={countUsers} />
<StatRow label={t("guests.label")} value={countGuests} />
<Stack gap={compact ? "sm" : "lg"}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</Stack>
</>
);
@@ -5,19 +5,27 @@ import { useScopedI18n } from "@homarr/translation/client";
import { StatRow } from "./stat-row";
export const WiredVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
export const WiredVariant = ({
countGuests,
countUsers,
compact = false,
}: {
countUsers: number;
countGuests: number;
compact?: boolean;
}) => {
const t = useScopedI18n("widget.networkControllerStatus.card");
return (
<>
<Group gap={"xs"} wrap={"nowrap"} mb={"md"}>
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
<IconNetwork size={24} />
<Text size={"md"} fw={"bold"}>
{t("variants.wired.name")}
</Text>
</Group>
<Stack gap={"lg"}>
<StatRow label={t("users.label")} value={countUsers} />
<StatRow label={t("guests.label")} value={countGuests} />
<Stack gap={compact ? "sm" : "lg"}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</Stack>
</>
);
@@ -1,7 +1,6 @@
"use client";
import { useMemo } from "react";
import { Box, Center, List, Text, useMantineTheme } from "@mantine/core";
import { Badge, Card, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@@ -19,6 +18,8 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"networkControllerSummary">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
@@ -26,33 +27,57 @@ export default function NetworkControllerSummaryWidget({
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const isAdvanced = displayMode === "advanced";
const columns = isAdvanced && width >= 720 ? 2 : 1;
return (
<Box h="100%" p="sm">
<Center h={"100%"}>
<List spacing={"xs"} center>
<List.Item icon={<StatusIcon status={data[0]?.wanStatus} />}>WAN</List.Item>
<List.Item icon={<StatusIcon status={data[0]?.www.status} />}>
<Text>
WWW
<Text c={"dimmed"} size={"md"} ms={"xs"} span>
{data[0]?.www.latency}ms
</Text>
</Text>
</List.Item>
<List.Item icon={<StatusIcon status={data[0]?.wifi.status} />}>Wi-Fi</List.Item>
<List.Item icon={<StatusIcon status={data[0]?.vpn.status} />}>
<Text>
VPN
<Text c={"dimmed"} size={"md"} ms={"xs"} span>
{t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${data[0]?.vpn.users}` })}
</Text>
</Text>
</List.Item>
</List>
</Center>
</Box>
<ScrollArea h="100%" p={isAdvanced ? "md" : "sm"}>
<SimpleGrid cols={columns} spacing="sm">
{summaries.map(({ integration, summary }) => (
<Card key={integration.id} p={isAdvanced ? "md" : "xs"} withBorder={summaries.length > 1 || isAdvanced}>
<Stack gap="xs">
{(isAdvanced || summaries.length > 1) && (
<Group justify="space-between" wrap="nowrap">
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
<Badge size="xs" variant="light">
{integration.kind}
</Badge>
</Group>
)}
<List spacing="xs" center>
<List.Item icon={<StatusIcon status={summary.wanStatus} />}>WAN</List.Item>
<List.Item icon={<StatusIcon status={summary.www.status} />}>
<Text>
WWW
<Text c="dimmed" size="sm" ms="xs" span>
{summary.www.latency}ms{isAdvanced ? ` · ${summary.www.ping}ms` : ""}
</Text>
</Text>
</List.Item>
<List.Item icon={<StatusIcon status={summary.wifi.status} />}>
Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
</List.Item>
{isAdvanced && (
<List.Item icon={<StatusIcon status={summary.lan.status} />}>
LAN · {summary.lan.users + summary.lan.guests}
</List.Item>
)}
<List.Item icon={<StatusIcon status={summary.vpn.status} />}>
<Text>
VPN
<Text c="dimmed" size="sm" ms="xs" span>
{t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${summary.vpn.users}` })}
</Text>
</Text>
</List.Item>
</List>
</Stack>
</Card>
))}
</SimpleGrid>
</ScrollArea>
);
}
@@ -61,5 +86,6 @@ const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => {
if (status === "enabled") {
return <IconCircleCheckFilled size={20} color={mantineTheme.colors.green[6]} />;
}
return <IconCircleXFilled size={20} color={mantineTheme.colors.red[6]} />;
if (status === "disabled") return <IconCircleXFilled size={20} color={mantineTheme.colors.red[6]} />;
return <IconCircleXFilled size={20} color={mantineTheme.colors.gray[6]} />;
};
+170 -59
View File
@@ -1,7 +1,7 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
ActionIcon,
Box,
@@ -13,7 +13,9 @@ import {
Popover,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
@@ -89,16 +91,63 @@ const controlIconProps = {
stroke: 1.5,
};
export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(content);
interface NotebookPersistedState {
itemId: string | undefined;
content: string;
previousContent: string;
isEditing: boolean;
saveError: string | null;
}
export function Notebook({
options,
setOptions,
isEditMode,
boardId,
itemId,
displayMode,
height,
widgetStateRef,
}: WidgetComponentProps<"notebook">) {
const persistedState = widgetStateRef?.current?.notebook as NotebookPersistedState | undefined;
const canHydratePersistedState = persistedState?.itemId === itemId;
const initialContent = canHydratePersistedState ? (persistedState?.content ?? options.content) : options.content;
const [content, setContent] = useState(initialContent);
const previousContentRef = useRef(
canHydratePersistedState ? (persistedState?.previousContent ?? initialContent) : initialContent,
);
const [saveError, setSaveError] = useState<string | null>(
canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
);
const board = useRequiredBoard();
const { data: session } = useSession();
const { hasChangeAccess } = constructBoardPermissions(board, session);
const canChange = !isEditMode && hasChangeAccess;
const [isEditing, setIsEditing] = useState(false);
const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
const canChangeRef = useRef(canChange);
const allowReadOnlyCheckRef = useRef(options.allowReadOnlyCheck);
const readOnlyCheckEventName = `homarr:notebook-read-only-check:${useId()}`;
useEffect(() => {
canChangeRef.current = canChange;
allowReadOnlyCheckRef.current = options.allowReadOnlyCheck;
}, [canChange, options.allowReadOnlyCheck]);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
notebook: {
itemId,
content,
previousContent: previousContentRef.current,
isEditing,
saveError,
},
};
}, [content, isEditing, itemId, saveError, widgetStateRef]);
const { primaryColor } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
@@ -108,6 +157,37 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
const tControls = useScopedI18n("widget.notebook.controls");
const t = useI18n();
const handleContentUpdate = useCallback(
async (contentUpdate: string) => {
setSaveError(null);
try {
if (boardId && itemId) {
await mutateAsync({ boardId, itemId, content: contentUpdate });
}
previousContentRef.current = contentUpdate;
if (widgetStateRef) {
widgetStateRef.current = {
...widgetStateRef.current,
notebook: {
itemId,
content: contentUpdate,
previousContent: contentUpdate,
isEditing,
saveError: null,
},
};
}
setOptions({ newOptions: { content: contentUpdate } });
return true;
} catch (error) {
setSaveError(error instanceof Error ? error.message : t("widget.notebook.saveFailed"));
return false;
}
},
[boardId, isEditing, itemId, mutateAsync, setOptions, t, widgetStateRef],
);
const editor = useEditor(
{
extensions: [
@@ -162,10 +242,10 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
TaskItem.configure({
nested: true,
onReadOnlyChecked: (node, checked) => {
if (!options.allowReadOnlyCheck) return false;
if (!canChange) return false;
if (!allowReadOnlyCheckRef.current) return false;
if (!canChangeRef.current) return false;
const event = new CustomEvent("onReadOnlyCheck", {
const event = new CustomEvent(readOnlyCheckEventName, {
detail: { node, checked },
});
dispatchEvent(event);
@@ -184,60 +264,51 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setContent(editor.getHTML());
},
onCreate: ({ editor }) => {
editor.setEditable(false);
editor.setEditable(isEditing);
},
},
[],
);
const handleOnReadOnlyCheck = (event: CustomEventInit<{ node: Node; checked: boolean }>) => {
if (!options.allowReadOnlyCheck) return;
if (!editor) return;
const handleOnReadOnlyCheck = useCallback(
(event: Event) => {
if (!allowReadOnlyCheckRef.current) return;
if (!editor) return;
const { detail } = event as CustomEvent<{ node: Node; checked: boolean }>;
editor.state.doc.descendants((subnode, pos) => {
if (!event.detail) return;
if (!subnode.eq(event.detail.node)) return;
editor.state.doc.descendants((subnode, pos) => {
if (!detail) return;
if (!subnode.eq(detail.node)) return;
const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, {
...event.detail.node.attrs,
checked: event.detail.checked,
const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, {
...detail.node.attrs,
checked: detail.checked,
});
editor.view.dispatch(tr);
const nextContent = editor.getHTML();
setContent(nextContent);
void handleContentUpdate(nextContent).then((wasSaved) => {
if (wasSaved) return;
setContent(previousContentRef.current);
editor.commands.setContent(previousContentRef.current);
});
});
editor.view.dispatch(tr);
setContent(editor.getHTML());
handleContentUpdate(editor.getHTML());
});
};
addEventListener("onReadOnlyCheck", handleOnReadOnlyCheck);
const handleContentUpdate = useCallback(
(contentUpdate: string) => {
previousContentRef.current = contentUpdate;
setOptions({ newOptions: { content: contentUpdate } });
// This is not available in preview mode
if (boardId && itemId) {
void mutateAsync({ boardId, itemId, content: contentUpdate });
}
},
[boardId, itemId, mutateAsync, setOptions],
[editor, handleContentUpdate],
);
const handleEditToggleCallback = useCallback(
(previous: boolean) => {
const current = !previous;
if (!editor) return current;
editor.setEditable(current);
useEffect(() => {
addEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
return () => removeEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
}, [handleOnReadOnlyCheck, readOnlyCheckEventName]);
if (previous) {
handleContentUpdate(content);
}
return current;
},
[content, editor, handleContentUpdate],
);
useEffect(() => {
if (isEditing || !editor || options.content === previousContentRef.current) return;
previousContentRef.current = options.content;
setContent(options.content);
editor.commands.setContent(options.content);
}, [editor, isEditing, options.content]);
const handleEditCancelCallback = useCallback(() => {
if (!editor) return false;
@@ -264,9 +335,20 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
});
}, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]);
const handleEditToggle = useCallback(() => {
setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]);
const handleEditToggle = useCallback(async () => {
if (!editor) return;
if (!isEditing) {
setSaveError(null);
editor.setEditable(true);
setIsEditing(true);
return;
}
const wasSaved = await handleContentUpdate(content);
if (!wasSaved) return;
editor.setEditable(false);
setIsEditing(false);
}, [content, editor, handleContentUpdate, isEditing]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
@@ -276,18 +358,26 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
if (event.target instanceof Element && event.target.closest("button, a")) {
return;
}
setIsEditing(handleEditToggleCallback);
setSaveError(null);
editor?.setEditable(true);
setIsEditing(true);
},
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
[canChange, editor, isEditing],
);
const documentText = editor?.getText().trim() ?? "";
const documentStats = {
characters: documentText.length,
words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length,
};
return (
<Box h="100%" onDoubleClick={handleDoubleClick}>
<RichTextEditor
p={0}
mt={0}
h="100%"
onKeyDown={isEditing ? getHotkeyHandler([[hotkeys.saveNotebook, handleEditToggle]]) : undefined}
onKeyDown={isEditing ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined}
editor={editor}
styles={(theme) => ({
root: {
@@ -304,7 +394,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
},
content: {
backgroundColor: "transparent",
padding: "0.5rem",
padding: height < 120 && displayMode !== "advanced" ? "0.25rem" : "0.5rem",
height: "100%",
},
typographyStylesProvider: {
@@ -314,7 +404,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
>
<RichTextEditor.Toolbar
style={{
display: isEditing && options.showToolbar === true ? "flex" : "none",
display: isEditing && options.showToolbar === true && displayMode === "advanced" ? "flex" : "none",
}}
>
<RichTextEditor.ControlsGroup>
@@ -442,6 +532,27 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
<RichTextEditor.Content />
</ScrollArea>
</RichTextEditor>
{displayMode === "advanced" && (
<Group pos="absolute" bottom={4} right={8} gap="xs">
{saveError && (
<Tooltip label={saveError} multiline>
<Text
size="xs"
c="red"
lineClamp={1}
maw={320}
tabIndex={0}
aria-label={`${t("widget.notebook.saveFailed")}. ${saveError}`}
>
{t("widget.notebook.saveFailed")}
</Text>
</Tooltip>
)}
<Text size="xs" c="dimmed">
{t("widget.notebook.documentStats", documentStats)}
</Text>
</Group>
)}
{canChange && (
<>
<ActionIcon
@@ -455,7 +566,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
color={primaryColor}
variant="light"
size={30}
onClick={handleEditToggle}
onClick={() => void handleEditToggle()}
>
{isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />}
</ActionIcon>
@@ -1,78 +1,110 @@
"use client";
import { useMemo } from "react";
import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
export default function NotificationsWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"notifications">) {
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
const t = useScopedI18n("widget.notifications");
const t = useI18n();
const board = useRequiredBoard();
const sortedNotifications = useMemo(
() =>
notificationIntegrations
.flatMap((integration) => integration.data)
.flatMap((integration) =>
integration.data.map((notification) => ({
...notification,
compositeKey: `${integration.integration.id}:${notification.id}`,
integrationName: integration.integration.name,
})),
)
.sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()),
[notificationIntegrations],
);
const failedIntegrations = notificationIntegrations.filter(
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
);
const isAdvanced = displayMode === "advanced";
const columns = isAdvanced && width >= 720 ? 2 : 1;
return (
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
<Stack w={"100%"} gap="sm">
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : "xs"}>
<Stack w="100%" gap="xs">
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
<Tooltip key={integration.integration.id} label={integration.error} multiline maw={360}>
<Badge color="red" variant="light" size="xs">
{integration.integration.name}: {t("common.error")}
</Badge>
</Tooltip>
))}
</Group>
)}
{sortedNotifications.length > 0 ? (
sortedNotifications.map((notification) => (
<Card
key={notification.id}
component={notification.href ? "a" : "div"}
href={notification.href}
target={notification.href ? "_blank" : undefined}
rel={notification.href ? "noopener noreferrer" : undefined}
radius={board.itemRadius}
w="100%"
p="sm"
style={{ color: "inherit", textDecoration: "none" }}
>
<Flex gap="sm" align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size="sm"
radius={board.itemRadius}
/>
)}
<Flex gap="sm" direction="column" w="100%">
{notification.title && (
<Text fz="sm" lh="sm" lineClamp={2}>
{notification.title}
</Text>
<SimpleGrid cols={columns} spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
{sortedNotifications.map((notification) => (
<Card
key={notification.compositeKey}
component={notification.href ? "a" : "div"}
href={notification.href}
target={notification.href ? "_blank" : undefined}
rel={notification.href ? "noopener noreferrer" : undefined}
radius={board.itemRadius}
w="100%"
p={isAdvanced ? "md" : "xs"}
style={{ color: "inherit", textDecoration: "none" }}
>
<Flex gap="sm" align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size="sm"
radius={board.itemRadius}
/>
)}
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
{notification.body}
</Text>
<InfoDisplay date={notification.time} />
<Flex gap={isAdvanced ? "sm" : 6} direction="column" w="100%" miw={0}>
{notification.title && (
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
{notification.title}
</Text>
)}
<Text c="dimmed" size="sm" lineClamp={isAdvanced ? 12 : 4} style={{ whiteSpace: "pre-line" }}>
{notification.body}
</Text>
<InfoDisplay
date={notification.time}
source={isAdvanced ? (notification.source?.name ?? notification.integrationName) : undefined}
/>
</Flex>
</Flex>
</Flex>
</Card>
))
</Card>
))}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
{t("noItems")}
{t("widget.notifications.noItems")}
</Text>
)}
</Stack>
@@ -80,15 +112,21 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
);
}
const InfoDisplay = ({ date }: { date: Date }) => {
const InfoDisplay = ({ date, source }: { date: Date; source?: string }) => {
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
return (
<Group gap={5} align={"center"}>
<Group gap={5} align="center" wrap="nowrap">
<IconClock size={"1rem"} color={"var(--mantine-color-dimmed)"} />
<Text size="sm" c="dimmed">
{timeAgo}
</Text>
{source && <Text c="dimmed">•</Text>}
{source && (
<Text size="xs" c="dimmed" truncate="end">
{source}
</Text>
)}
</Group>
);
};
+8 -1
View File
@@ -2,11 +2,18 @@ import { IconMessage } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
supportsAdvancedFocus: true,
icon: IconMessage,
queryKey: [["widget", "notifications", "getNotifications"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
hideLogos: scope.options.hideLogos,
integrationIds: scope.integrationIds,
}),
createOptions() {
return optionsBuilder.from((factory) => ({
hideLogos: factory.switch({ defaultValue: false }),
+2
View File
@@ -56,6 +56,8 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
query: string,
integrationIds: string[],
options: Record<string, unknown>,
itemId?: string,
boardId?: string,
) => {
isPending: boolean;
options: DynamicSelectOption[];
@@ -70,7 +70,13 @@ const heroPartVisibility = {
ring: "showInboxRing",
} as const;
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
export default function PaperlessNgxWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"paperlessNgx">) {
const t = useScopedI18n("widget.paperlessNgx");
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
@@ -95,18 +101,20 @@ export default function PaperlessNgxWidget({ integrationIds, options, width }: W
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
const gridCols = getGridCols(width);
const ringSize = getRingSize(width);
const advanced = displayMode === "advanced";
const gridCols = advanced ? Math.max(1, Math.min(5, visibleStatKeys.length)) : getGridCols(width);
const ringSize = advanced ? 112 : 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 showInboxRing = options.showInboxRing && (advanced || height >= 120);
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
([, optionKey]) => options[optionKey as keyof typeof options],
([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing),
);
const heroPartRenderers = {
@@ -140,7 +148,11 @@ export default function PaperlessNgxWidget({ integrationIds, options, width }: W
} as const;
return (
<div className={classes.root}>
<div
className={classes.root}
data-display-mode={displayMode}
style={advanced ? { padding: 16, gap: 16 } : undefined}
>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
{visibleHeroParts.map(([partKey]) => (
@@ -5,9 +5,11 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText,
queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showInboxRatio: factory.switch({
+18 -10
View File
@@ -75,7 +75,13 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
return "comfortable";
};
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
export default function PatchMonWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"patchmon">) {
const t = useScopedI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const { data: stats } = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
@@ -98,18 +104,20 @@ export default function PatchMonWidget({ integrationIds, options, width, height
.map(([, statKey]) => statKey);
const colorContext = { totalHosts: stats.totalHosts };
const layout = getLayoutMode(width, height);
const isLandscape = width / height > 2.5;
const advanced = displayMode === "advanced";
const layout = advanced ? "spacious" : getLayoutMode(width, height);
const isLandscape = !advanced && width / height > 2.5;
const isTinySquare = width < 160 && height < 160;
const isShortLandscape = width >= 160 && height < 160;
const isCompactSurface = isTinySquare || isShortLandscape;
const isCompactSurface = !advanced && (isTinySquare || isShortLandscape);
const isNarrow = width < 180;
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
const showIcons = layout !== "mini";
const showLabels = layout !== "mini";
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHero =
options.showComplianceHero && (advanced || !isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHeroText = showHero && shouldShowComplianceHeroText(width);
const showHeroRingOnly = showHero && !showHeroText;
const compactPrimaryContent = isCompactSurface
@@ -120,10 +128,10 @@ export default function PatchMonWidget({ integrationIds, options, width, height
})
: null;
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
const showOsSection = showOsData && !isCompactSurface && height > 200;
const showOsSection = showOsData && !isCompactSurface && (advanced || height > 200);
const showCompactFooter = isCompactSurface && height >= 110;
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
const showFooter = advanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
const showOsLegend = osDisplayMode === "donut" && (advanced || (width >= 380 && height >= 300));
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
@@ -272,10 +280,10 @@ export default function PatchMonWidget({ integrationIds, options, width, height
</div>
);
const needsScroll = layout === "mini" || layout === "compact";
const needsScroll = !advanced && (layout === "mini" || layout === "compact");
return (
<div className={classes.root}>
<div className={classes.root} data-display-mode={displayMode}>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
+38 -12
View File
@@ -8,8 +8,10 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
icon: IconShieldCheck,
queryKey: [["widget", "patchmon"]],
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -188,18 +190,42 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon"
useCustomThresholds: {
shouldHide: (options) => !options.enableThresholdColors,
},
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsNeedingUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsNeedingUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
totalOutdatedPackagesThresholdMode: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
@@ -9,16 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
const OS_COLORS = [
"blue.6",
"orange.6",
"green.6",
"grape.6",
"cyan.6",
"pink.6",
"yellow.6",
"teal.6",
] as const;
const OS_COLORS = ["blue.6", "orange.6", "green.6", "grape.6", "cyan.6", "pink.6", "yellow.6", "teal.6"] as const;
interface OsDistributionSectionProps {
entries: PatchMonOsDistributionEntry[];
@@ -73,13 +64,7 @@ export function OsDistributionSection({
)}
<div className={`${classes.osDonutContent} ${showLegend ? classes.osDonutContentWithLegend : ""}`}>
<div className={classes.osDonut}>
<DonutChart
data={chartData}
size={donutSize}
withTooltip
tooltipDataSource="segment"
paddingAngle={2}
/>
<DonutChart data={chartData} size={donutSize} withTooltip tooltipDataSource="segment" paddingAngle={2} />
</div>
{showLegend && (
<div className={classes.osLegend}>
@@ -115,7 +100,12 @@ export function OsDistributionSection({
<div className={classes.compactOsBars}>
{compactEntries.map((entry, i) => (
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.compactOsBar}>
<Text size="xs" className={classes.compactOsLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
<Text
size="xs"
className={classes.compactOsLabel}
truncate="end"
title={formatOsLabel(entry, showOsVersion)}
>
{formatOsLabel(entry, showOsVersion)}
</Text>
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
+1 -5
View File
@@ -13,11 +13,7 @@ export type PatchMonStatKey =
export type ColorablePatchMonStatKey = Extract<
PatchMonStatKey,
| "hostsNeedingUpdates"
| "securityUpdates"
| "hostsWithSecurityUpdates"
| "upToDateHosts"
| "totalOutdatedPackages"
"hostsNeedingUpdates" | "securityUpdates" | "hostsWithSecurityUpdates" | "upToDateHosts" | "totalOutdatedPackages"
>;
export type StatSeverity = "green" | "yellow" | "red" | "neutral";
@@ -117,11 +117,16 @@ describe("resolveStatColor options", () => {
test("handles zero total hosts for percent-based stats", () => {
expect(resolveStatColor("upToDateHosts", 0, { totalHosts: 0 }, defaultOptions)).toBe("green");
expect(
resolveStatColor("hostsNeedingUpdates", 1, { totalHosts: 0 }, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
}),
resolveStatColor(
"hostsNeedingUpdates",
1,
{ totalHosts: 0 },
{
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
},
),
).toBe("red");
});
@@ -23,25 +23,27 @@ const baseOptions = {
totalOutdatedPackagesCriticalAt: 50,
};
const optionsSchema = z.object({
hostsNeedingUpdatesWarningAt: z.number().int().min(0),
hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
securityUpdatesWarningAt: z.number().int().min(0),
securityUpdatesCriticalAt: z.number().int().min(0),
hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
upToDateHostsWarningAt: z.number().int().min(0),
upToDateHostsCriticalAt: z.number().int().min(0),
totalOutdatedPackagesWarningAt: z.number().int().min(0),
totalOutdatedPackagesCriticalAt: z.number().int().min(0),
enableThresholdColors: z.boolean(),
useCustomThresholds: z.boolean(),
hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
}).superRefine(patchmonOptionsSuperRefine);
const optionsSchema = z
.object({
hostsNeedingUpdatesWarningAt: z.number().int().min(0),
hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
securityUpdatesWarningAt: z.number().int().min(0),
securityUpdatesCriticalAt: z.number().int().min(0),
hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
upToDateHostsWarningAt: z.number().int().min(0),
upToDateHostsCriticalAt: z.number().int().min(0),
totalOutdatedPackagesWarningAt: z.number().int().min(0),
totalOutdatedPackagesCriticalAt: z.number().int().min(0),
enableThresholdColors: z.boolean(),
useCustomThresholds: z.boolean(),
hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
})
.superRefine(patchmonOptionsSuperRefine);
describe("patchmonOptionsSuperRefine", () => {
test("accepts percent thresholds within 0-100", () => {
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more