Merge remote-tracking branch 'origin/feat/widget-modernization' into feat/stack-agent-widget-fixes-mobile

This commit is contained in:
Thomas Camlong committed 2026-08-10 09:40:29 +02:00
commit 4822c2fa12
7 files changed
+409 -184

No files matched your search

@@ -17,7 +17,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. Hover over or focus the widget controls to switch between the realtime Live view and historical periods ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -24,7 +24,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
Sessions without user metadata remain visible and are labeled as an unknown user.
@@ -1,28 +1,38 @@
.beszelStatsSystemToggle {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
.beszelStatsControls {
opacity: 0;
transition: opacity 0.15s ease;
transition: opacity 150ms ease-out;
pointer-events: none;
}
.beszelStatsContainer:hover .beszelStatsSystemToggle,
.beszelStatsContainer:focus-within .beszelStatsSystemToggle {
.beszelStatsContainer:hover .beszelStatsControls,
.beszelStatsContainer:focus-within .beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
.beszelStatsSystemToggle {
max-width: 50%;
flex-shrink: 0;
}
.beszelStatsPeriodToggle {
flex: 1;
min-width: 0;
}
.beszelStatsPeriodButton {
flex: 1;
}
@media (any-pointer: coarse) {
.beszelStatsSystemToggle {
.beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.beszelStatsSystemToggle {
.beszelStatsControls {
transition: none;
}
}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { Box, Button, Center, Group, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
@@ -185,54 +185,65 @@ export default function BeszelSystemStatsWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer size={14} />}
className={classes.beszelStatsSystemToggle}
{!isEditMode && (
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
{systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer size={14} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{width >= 560 ? (
<Button.Group className={classes.beszelStatsPeriodToggle}>
{periodOptions.map((period) => (
<Button
key={period.value}
size="compact-xs"
variant={period.value === options.timePeriod ? "filled" : "default"}
className={classes.beszelStatsPeriodButton}
aria-pressed={period.value === options.timePeriod}
disabled={isSelectionSavePending}
onClick={() => handleTimePeriod(period.value)}
>
{period.label}
</Button>
))}
</Button.Group>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
data={periodOptions}
className={classes.beszelStatsPeriodToggle}
/>
)}
</Group>
)}
{!isEditMode &&
(width >= 560 ? (
<SegmentedControl
size="xs"
fullWidth
value={options.timePeriod}
onChange={handleTimePeriod}
disabled={isSelectionSavePending}
data={periodOptions}
/>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
/>
))}
<BeszelStatsView
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
@@ -1,3 +1,23 @@
.root {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.tablePaper {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.tableContainer {
flex: 1;
min-height: 0;
}
.sessionRow {
cursor: pointer;
@@ -11,3 +31,16 @@
outline-offset: -2px;
}
}
.detailsTarget {
width: 100%;
min-width: 0;
color: inherit;
text-align: left;
border-radius: var(--mantine-radius-xs);
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
}
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { act, useState } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { StreamSession } from "@homarr/integrations";
import { SessionDetailsPopover } from "./component";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
}));
const session = {
sessionId: "session-1",
sessionName: "Living room",
user: { username: "Alex", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Example movie",
episodeName: null,
seasonName: null,
metadata: null,
},
} as unknown as StreamSession;
function ControlledPopover({ children }: { children: ReactNode }) {
const [opened, setOpened] = useState(false);
return (
<SessionDetailsPopover item={session} opened={opened} onChange={setOpened}>
{children}
</SessionDetailsPopover>
);
}
describe("SessionDetailsPopover", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
vi.stubGlobal("matchMedia", () => ({
matches: false,
media: "",
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
test("opens and closes anchored session details from the target", async () => {
await act(() =>
root.render(
<MantineProvider env="test">
<ControlledPopover>Open session</ControlledPopover>
</MantineProvider>,
),
);
const target = host.querySelector("button");
expect(target?.getAttribute("aria-expanded")).toBe("false");
await act(async () => {
target?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(target?.getAttribute("aria-expanded")).toBe("true");
expect(document.body.textContent).toContain("Example movie");
await act(async () => {
target?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(target?.getAttribute("aria-expanded")).toBe("false");
});
});
+195 -125
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,
@@ -19,7 +30,6 @@ import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
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";
@@ -74,6 +84,7 @@ export default function MediaServerWidget({
const t = useScopedI18n("widget.mediaServer");
const isAdvanced = displayMode === "advanced";
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
const result: MRT_ColumnDef<StreamSession>[] = [
...(width >= 300 || isAdvanced
@@ -108,55 +119,14 @@ export default function MediaServerWidget({
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: "var(--mantine-color-default-border)" }}
/>
)}
</Stack>
<SessionDetailsPopover
item={row.original}
opened={selectedRowId === row.id}
onChange={(opened) => setSelectedRowId(opened ? row.id : null)}
>
<CurrentlyPlaying item={row.original} />
</SessionDetailsPopover>
);
},
} satisfies MRT_ColumnDef<StreamSession>,
@@ -205,7 +175,7 @@ export default function MediaServerWidget({
: []),
];
return result;
}, [isAdvanced, options.showBitrate, options.showLocation, t, width]);
}, [isAdvanced, options.showBitrate, options.showLocation, selectedRowId, 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
@@ -223,7 +193,6 @@ export default function MediaServerWidget({
[currentStreams],
);
const { openModal } = useModalAction(ItemInfoModal);
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
@@ -240,12 +209,9 @@ export default function MediaServerWidget({
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
enableColumnPinning: true,
enableColumnPinning: false,
initialState: {
density: "xs",
columnPinning: {
right: ["currentlyPlaying", "status"],
},
},
mantineTableHeadProps: {
fz: "xs",
@@ -257,6 +223,7 @@ export default function MediaServerWidget({
flex: 1,
withBorder: false,
shadow: undefined,
className: classes.tablePaper,
},
mantineTableProps: {
className: "media-server-widget-table",
@@ -265,27 +232,28 @@ export default function MediaServerWidget({
},
},
mantineTableContainerProps: {
className: classes.tableContainer,
style: {
height: "100%",
pointerEvents: isEditMode ? "none" : undefined,
},
},
mantineTableBodyRowProps: ({ row }) => {
const openDetails = () => {
openModal({ item: row.original }, { title: row.original.sessionName });
};
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,
onClick: isEditMode ? undefined : openDetails,
"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();
openDetails();
toggleDetails();
},
};
},
@@ -321,7 +289,7 @@ export default function MediaServerWidget({
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>
@@ -384,89 +352,191 @@ export default function MediaServerWidget({
);
}
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 ?? t("unknownUser")}</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>
);
};