diff --git a/apps/docs/docs/widgets/beszel-system-grid/index.mdx b/apps/docs/docs/widgets/beszel-system-grid/index.mdx
index fd4232524..993974914 100644
--- a/apps/docs/docs/widgets/beszel-system-grid/index.mdx
+++ b/apps/docs/docs/widgets/beszel-system-grid/index.mdx
@@ -4,8 +4,6 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr
hide_title: true
---
-import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -36,8 +34,6 @@ Select a system card to open the complete statistics view, including system, fil
]}
/>
-
-
### Adding the widget
diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx
index dafd02004..b09edf530 100644
--- a/apps/docs/docs/widgets/beszel-system-table/index.mdx
+++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx
@@ -4,8 +4,6 @@ description: "Table view of all Beszel-monitored systems with sortable columns a
hide_title: true
---
-import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -17,14 +15,14 @@ import { beszelIntegration } from "@site/docs/integrations/beszel";
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
-In compact mode, select a system row to open the complete statistics view. In advanced mode, selecting a row focuses an inline one-hour view with system, filesystem, network, and Docker charts.
+Select a system row to open the complete statistics view.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
-- **Open or focus system statistics** by selecting a row, depending on the display mode
+- **Open system statistics** by selecting a row
### Supported Integrations
@@ -37,8 +35,6 @@ In compact mode, select a system row to open the complete statistics view. In ad
]}
/>
-
-
### Adding the widget
diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx
index 3749cd6ae..d0acb93b2 100644
--- a/apps/docs/docs/widgets/media-server/index.mdx
+++ b/apps/docs/docs/widgets/media-server/index.mdx
@@ -26,6 +26,7 @@ It shows playback progress, pause state, time remaining, and whether each sessio
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.
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.
### Screenshots
diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
index 60cc31954..c108d72c8 100644
--- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
+++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
@@ -35,7 +35,7 @@
.surfaceHostAdvanced {
position: fixed;
inset: 0;
- z-index: calc(var(--mantine-z-index-modal) - 1);
+ z-index: calc(var(--mantine-z-index-popover) - 1);
width: auto;
height: auto;
pointer-events: none;
diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx
index 3f38bdc44..4a5935efe 100644
--- a/apps/nextjs/src/components/board/advanced-focus/context.tsx
+++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx
@@ -201,7 +201,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
- zIndex="calc(var(--mantine-z-index-modal) - 2)"
+ zIndex="calc(var(--mantine-z-index-popover) - 2)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
diff --git a/apps/nextjs/src/components/board/advanced-focus/wheel.ts b/apps/nextjs/src/components/board/advanced-focus/wheel.ts
new file mode 100644
index 000000000..4cf5bc36d
--- /dev/null
+++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts
@@ -0,0 +1,30 @@
+const canScrollVertically = (element: HTMLElement, delta: number) => {
+ if (element.scrollHeight <= element.clientHeight) return false;
+
+ const overflowY = getComputedStyle(element).overflowY;
+ if (overflowY !== "auto" && overflowY !== "scroll") return false;
+
+ return delta < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight;
+};
+
+export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null, delta: number) => {
+ if (delta === 0) return false;
+
+ let current = target instanceof HTMLElement ? target : null;
+ while (current && root.contains(current)) {
+ if (canScrollVertically(current, delta)) {
+ current.scrollTop += delta;
+ return true;
+ }
+ if (current === root) break;
+ current = current.parentElement;
+ }
+
+ const viewport = [...root.querySelectorAll("[data-scrollbars='y'], [data-scrollbars='xy']")].find(
+ (element) => canScrollVertically(element, delta),
+ );
+ if (!viewport) return false;
+
+ viewport.scrollTop += delta;
+ return true;
+};
diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx
index a572cdbad..0eb52868d 100644
--- a/apps/nextjs/src/components/board/items/item-content.tsx
+++ b/apps/nextjs/src/components/board/items/item-content.tsx
@@ -1,4 +1,4 @@
-import type { CSSProperties, KeyboardEvent, MutableRefObject } from "react";
+import type { CSSProperties, KeyboardEvent, MutableRefObject, WheelEvent } from "react";
import { useEffect, useRef } from "react";
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
import { useElementSize, useViewportSize } from "@mantine/hooks";
@@ -12,13 +12,20 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
-import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
+import type { WidgetDefinition } from "@homarr/widgets";
+import {
+ loadWidgetDynamic,
+ reduceWidgetOptionsWithDefaultValues,
+ supportsAdvancedFocus as definitionSupportsAdvancedFocus,
+ widgetImports,
+} from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
+import { redirectShiftWheel } from "../advanced-focus/wheel";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
@@ -48,7 +55,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const widgetStateRef = useRef | null>(null);
const closeButtonRef = useRef(null);
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
- const supportsAdvancedFocus = item.kind !== "app";
+ const supportsAdvancedFocus = definitionSupportsAdvancedFocus(
+ widgetImports[item.kind].definition as WidgetDefinition,
+ );
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
const isAdvanced = activeFocus !== null;
@@ -81,6 +90,12 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
event.preventDefault();
openAdvanced(true);
};
+ const handleWheel = (event: WheelEvent) => {
+ if (!isAdvanced || !event.shiftKey) return;
+ const delta = event.deltaY || event.deltaX;
+ if (!redirectShiftWheel(event.currentTarget, event.target, delta)) return;
+ event.preventDefault();
+ };
return (
<>
@@ -128,6 +143,8 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
id={isAdvanced ? advancedViewId : undefined}
role={isAdvanced ? "region" : undefined}
aria-label={isAdvanced ? advancedViewLabel : undefined}
+ data-advanced-focus-surface={isAdvanced || undefined}
+ onWheel={handleWheel}
radius={board.itemRadius}
p={isAdvanced ? undefined : 0}
h={isAdvanced ? undefined : "100%"}
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index 24b6f0103..2619371d2 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -33,6 +33,7 @@ import {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
reduceWidgetOptionsWithDefaultValues,
+ supportsAdvancedFocus,
widgetImports,
} from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -70,6 +71,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
+ const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition as WidgetDefinition);
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const { open: openAdvancedFocus } = useAdvancedFocus();
@@ -250,7 +252,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
>
{children}
- {item.kind !== "app" && (
+ {canOpenAdvancedFocus && (
<>
{
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
0,
);
+ await advancedSurface.evaluate((element) => {
+ const probe = document.createElement("div");
+ probe.dataset.shiftScrollProbe = "true";
+ probe.style.cssText = "height:80px;overflow-y:auto";
+ probe.innerHTML = '';
+ element.append(probe);
+ });
+ const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe]");
+ await page.keyboard.down("Shift");
+ await scrollProbe.dispatchEvent("wheel", { shiftKey: true, deltaX: 120, deltaY: 0 });
+ await page.keyboard.up("Shift");
+ expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await page.keyboard.press("Escape");
await expect(advancedSurface).toBeHidden();
await expect(widget).toBeFocused();
diff --git a/packages/integrations/src/interfaces/media-server/media-server-types.ts b/packages/integrations/src/interfaces/media-server/media-server-types.ts
index c9a287155..42b6f0615 100644
--- a/packages/integrations/src/interfaces/media-server/media-server-types.ts
+++ b/packages/integrations/src/interfaces/media-server/media-server-types.ts
@@ -1,7 +1,7 @@
export interface StreamSession {
sessionId: string;
sessionName: string;
- user: {
+ user?: {
userId: string;
username: string;
profilePictureUrl: string | null;
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index e7e7e407a..f019eab5f 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -2891,6 +2891,7 @@
"items": {
"currentlyPlaying": "Currently playing",
"user": "User",
+ "unknownUser": "Unknown user",
"name": "Name",
"id": "Id",
"status": "Status",
diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts
index 2b03f0e99..7b8416ca9 100644
--- a/packages/widgets/src/app/index.ts
+++ b/packages/widgets/src/app/index.ts
@@ -15,6 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
+ supportsAdvancedFocus: false,
queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]],
createOptions(settings) {
return optionsBuilder.from(
diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx
index 08127b39e..1c6266b8e 100644
--- a/packages/widgets/src/audio-stats/component.tsx
+++ b/packages/widgets/src/audio-stats/component.tsx
@@ -72,7 +72,7 @@ export default function AudioStatsWidget({
{currentlyPlaying.name}
- {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
+ {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user?.username ?? "—"}
query.path.at(-1) === "getSystems"
diff --git a/packages/widgets/src/beszel/_shared/disk-usage.tsx b/packages/widgets/src/beszel/_shared/disk-usage.tsx
index 9aa2a14ad..826d5c3c8 100644
--- a/packages/widgets/src/beszel/_shared/disk-usage.tsx
+++ b/packages/widgets/src/beszel/_shared/disk-usage.tsx
@@ -1,4 +1,4 @@
-import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
+import { Box, getDefaultZIndex, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
@@ -28,6 +28,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
{formatPercent(system.disk)}
{
const mouseRef = useRef({ x: 0, y: 0 });
- const [pos, setPos] = useState({ x: 0, y: 0 });
+ const [pos, setPos] = useState({ x: 0, y: 0, isAdvanced: false });
const rafRef = useRef(0);
const tooltipRef = useRef(null);
@@ -72,9 +72,10 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
+ const isAdvanced = Boolean(document.elementFromPoint(mx, my)?.closest("[data-advanced-focus-surface]"));
if (!el) {
- setPos({ x: mx + MARGIN, y: my });
+ setPos({ x: mx + MARGIN, y: my, isAdvanced });
return;
}
@@ -89,7 +90,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
if (y < MARGIN) y = MARGIN;
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
- setPos({ x, y });
+ setPos({ x, y, isAdvanced });
});
}, []);
@@ -122,7 +123,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
position: "fixed",
left: pos.x,
top: pos.y,
- zIndex: TOOLTIP_Z_INDEX,
+ zIndex: pos.isAdvanced ? getDefaultZIndex("popover") : TOOLTIP_Z_INDEX,
pointerEvents: "none",
};
diff --git a/packages/widgets/src/definition.spec.ts b/packages/widgets/src/definition.spec.ts
index 59810c9c4..94089b161 100644
--- a/packages/widgets/src/definition.spec.ts
+++ b/packages/widgets/src/definition.spec.ts
@@ -3,7 +3,15 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
-import { getWidgetQueryKeys, getWidgetRuntimeQueries, setWidgetRuntimeQueries } from "./definition";
+import { definition as appDefinition } from "./app";
+import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
+import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
+import {
+ getWidgetQueryKeys,
+ getWidgetRuntimeQueries,
+ setWidgetRuntimeQueries,
+ supportsAdvancedFocus,
+} from "./definition";
const togglePolling = () => undefined;
@@ -36,3 +44,22 @@ describe("getWidgetQueryKeys", () => {
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual([{ path: ["widget", "calendar", "findAllEvents"], input }]);
});
});
+
+describe("supportsAdvancedFocus", () => {
+ const definition = { supportsAdvancedFocus: undefined } as Parameters[0];
+
+ test("defaults to enabled", () => {
+ expect(supportsAdvancedFocus(definition)).toBe(true);
+ });
+
+ test("allows widgets to opt out", () => {
+ expect(supportsAdvancedFocus({ ...definition, supportsAdvancedFocus: false })).toBe(false);
+ });
+
+ test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
+ "keeps non-enhanced widgets compact",
+ (widgetDefinition) => {
+ expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
+ },
+ );
+});
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index f2c432f29..2153efda5 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -86,6 +86,7 @@ export const createWidgetDefinition = , "kind">) => WidgetContextMenuAction[];
}
+export const supportsAdvancedFocus = (definition: WidgetDefinition) => definition.supportsAdvancedFocus !== false;
+
export const getWidgetQueryKeys = (definition: {
kind: string;
queryKey?: QueryKey;
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index dd870cf22..6b420f413 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -78,6 +78,7 @@ export {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
normalizeWidgetQuery,
+ supportsAdvancedFocus,
widgetQueryValueEquals,
} from "./definition";
export type { WidgetComponentProps };
diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx
index 862376d1b..2d9697aeb 100644
--- a/packages/widgets/src/media-server/component.tsx
+++ b/packages/widgets/src/media-server/component.tsx
@@ -81,10 +81,10 @@ export default function MediaServerWidget({
Cell: ({ row }) => (
-
+
- {row.original.user.username}
+ {row.original.user?.username ?? t("items.unknownUser")}
(({ innerProps }) => {
itemKey={t("user")}
value={
- {" "}
- {innerProps.item.user.username}
+ {" "}
+ {innerProps.item.user?.username ?? t("unknownUser")}
}
/>