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")} } />