diff --git a/MANTINE.md b/MANTINE.md new file mode 100644 index 000000000..fa9c8a3e7 --- /dev/null +++ b/MANTINE.md @@ -0,0 +1,475 @@ +# Mantine UI - LLM Documentation + +This index lists Mantine documentation pages formatted for LLMs. +Each link points to a standalone Markdown file under the /llms path. + +For a single consolidated file with all content, use: + +- https://mantine.dev/llms-full.txt + +## FAQ + +- [Are Mantine components accessible?](https://mantine.dev/llms/q-are-mantine-components-accessible.md): Learn about Mantine components accessibility features +- [Browser zooms in when input is focused. What should I do?](https://mantine.dev/llms/q-browser-zooms-on-focus.md): Use meta tag to disable browser scaling or increase input size +- [Can I have color schemes other than light and dark?](https://mantine.dev/llms/q-light-dark-is-not-enough.md): Learn about the difference between color scheme and theme +- [Can I have different primary color for light and dark color schemes?](https://mantine.dev/llms/q-primary-virtual-color.md): Learn how to use virtual color with primary color in theme object +- [Can I remove MultiSelect placeholder when the component has selected values?](https://mantine.dev/llms/q-multiselect-value-placeholder.md): Learn why MultiSelect placeholder is not removed when values are selected and how to remove it with CSS +- [Can I use an array of strings as a list in use-form?](https://mantine.dev/llms/q-list-of-strings-in-use-form.md): Learn about use-form lists limitations +- [Can I use Mantine components as server components?](https://mantine.dev/llms/q-server-components.md): Learn about use client directive and server components usage +- [Can I use Mantine with Astro?](https://mantine.dev/llms/q-can-i-use-mantine-with-astro.md): No, Astro does not support React context +- [Can I use Mantine with Create React App (CRA)?](https://mantine.dev/llms/q-can-i-use-mantine-with-cra.md): Learn how to use Mantine without postcss-preset-mantine or how to eject CRA and customize webpack.config.js and use postcss-preset-mantine +- [Can I use Mantine with Emotion/styled-components/tailwindcss?](https://mantine.dev/llms/q-third-party-styles.md): Learn about limitations of third-party styles +- [Can I use Mantine with Vue/Svelte/Angular/etc.?](https://mantine.dev/llms/q-vue-svelte-angular.md): No, Mantine is a React library and does not support other frameworks/libraries +- [Can I use nested inline styles with Mantine components?](https://mantine.dev/llms/q-nested-inline-styles.md): Nested styles are supported only in CSS files +- [Can I use PostCSS function in inline styles?](https://mantine.dev/llms/q-postcss-fns-inline.md): Learn where PostCSS functions can be used in Mantine +- [Can I use private CSS variables to style components?](https://mantine.dev/llms/q-private-css-variables.md): No, it is not safe and will not work with future versions of Mantine. +- [Can I use SegmentedControl with empty value?](https://mantine.dev/llms/q-segmented-control-no-value.md): SegmentedControl cannot be used without a value +- [How can I add dynamic CSS styles?](https://mantine.dev/llms/q-dynamic-css-styles.md): Use data attributes, CSS variables or inline styles +- [How can I add fuzzy search to Select component?](https://mantine.dev/llms/q-select-fuzzy.md): Learn how to integrate third-party fuzzy search libraries with Mantine Select component +- [How can I add hover styles to an element?](https://mantine.dev/llms/q-how-to-add-hover-styles.md): Learn how to add hover classes to an element with CSS modules, &:hover or @mixin hover +- [How can I apply styles to all Mantine components?](https://mantine.dev/llms/q-apply-styles-to-all.md): Learn how to use attribute selector to apply styles to all Mantine components +- [How can I change body background color?](https://mantine.dev/llms/q-body-background.md): Use CSS to change body background color +- [How can I change component color prop value depending on the color scheme?](https://mantine.dev/llms/q-color-scheme-color.md): Learn how to use CSS variables resolver to change color value depending on the color scheme +- [How can I change input focus styles?](https://mantine.dev/llms/q-input-focus-styles.md): Learn how to use Styles API with Mantine inputs +- [How can I change Tabs border color?](https://mantine.dev/llms/q-tabs-border-color.md): Learn how to use Styles API with Tabs component +- [How can I contribute to the library?](https://mantine.dev/llms/q-how-can-i-contribute.md): There are multiple ways to contribute even without writing code +- [How can I disable all inputs/input groups inside a form?](https://mantine.dev/llms/q-disable-all-inputs-in-form.md): Learn how to disable all inputs/input groups inside a form with the unstyled Fieldset component +- [How can I display different elements in light and dark color schemes?](https://mantine.dev/llms/q-light-dark-elements.md): Learn how to hide/show elements based on color scheme +- [How can I focus the first input with error with use-form?](https://mantine.dev/llms/q-focus-first-input-with-error.md): Learn how to handle focus with use-form hook +- [How can I get current color scheme value in JavaScript?](https://mantine.dev/llms/q-how-to-get-color-scheme-value-in-js.md): How to use useMantineColorScheme and useComputedColorScheme hooks to get current color scheme value in JavaScript +- [How can I lint CSS files?](https://mantine.dev/llms/q-how-to-setup-stylelint.md): Learn how to set up Stylelint for Mantine postcss syntax +- [How can I load fonts in Next.js?](https://mantine.dev/llms/q-next-load-fonts.md): A guide to load custom fonts in Next.js with CSS and next/font package +- [How can I load fonts in React Router?](https://mantine.dev/llms/q-react-router-load-fonts.md): A guide to load custom fonts in React Router +- [How can I load fonts in Vite?](https://mantine.dev/llms/q-vite-load-fonts.md): A guide to load custom fonts in Vite +- [How can I lock scroll in my application?](https://mantine.dev/llms/q-how-to-lock-scroll.md): Use react-remove-scroll library to lock scroll in your application +- [How can I submit a template to Mantine documentation?](https://mantine.dev/llms/q-submit-template.md): Learn how to create and submit a template to Mantine documentation +- [How can I test Modal/Drawer/Popover components?](https://mantine.dev/llms/q-portals-testing.md): Learn how to use react-testing-library to test components that use portals and transitions. +- [How can I test Select/MultiSelect components?](https://mantine.dev/llms/q-combobox-testing.md): Learn how to use react-testing-library to test Select and MultiSelect components. +- [How can I upload files from Dropzone component?](https://mantine.dev/llms/q-dropzone-upload.md): Learn how to process files dropped into Dropzone component +- [How is that thing done on the mantine.dev website?](https://mantine.dev/llms/q-how-that-thing-is-done.md): Learn how various elements are implemented on the mantine.dev website +- [How to align input with a button in a flex container?](https://mantine.dev/llms/q-align-input-button.md): Learn how to align Mantine inputs with buttons using Group component +- [How to call a function when Modal/Drawer closes and animation completes?](https://mantine.dev/llms/q-how-to-call-function-when-modal-closes.md): How to use transitionProps in Modal/Drawer components +- [How to change background color for FiveM?](https://mantine.dev/llms/q-fivem-background-color.md): Unset body background color and color-scheme styles for FiveM +- [How to change input placeholder color?](https://mantine.dev/llms/q-inputs-placeholder-color.md): Learn how to change placeholder color with Styles API +- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs +- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key +- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors +- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/_, @mantinex/_ and postcss-preset-mantine dependencies manually or with ncu script +- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state +- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template +- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings +- [Is there a comparison with other libraries?](https://mantine.dev/llms/q-other-libs.md): Comparison with other libraries is not provided, you are trusted to make your own decision based on documentation examples +- [Is there a floating action button component?](https://mantine.dev/llms/q-floating-action-button.md): No, but you can build it with Affix and ActionIcon components +- [Is there DataGrid component that I can use with Mantine?](https://mantine.dev/llms/q-data-grid-i-need.md): A list of community packages that provide DataGrid component that can be used with Mantine. +- [It is not possible to pinch to zoom when a Modal is opened. What should I do?](https://mantine.dev/llms/q-pinch-to-zoom-modal.md): Use removeScrollProps to configure react-remove-scroll options +- [MantineProvider was not found in component tree. What should I do?](https://mantine.dev/llms/q-mantine-provider-missing.md): Learn how to resolve MantineProvider issues +- [My buttons are transparent and the background is visible only on hover, what is wrong?](https://mantine.dev/llms/q-transparent-buttons.md): You have installed a third-party library that overrides Mantine styles +- [My Popover dropdown closes when I click on the dropdown of a nested Popover](https://mantine.dev/llms/q-nested-popover-closes.md): Popover dropdown is closed when it detects click-outside events +- [My styles are broken with disabled JavaScript. What should I do?](https://mantine.dev/llms/q-disabled-js.md): Learn how to use Mantine without JavaScript enabled +- [My styles are overridden by Mantine components styles, what should I do?](https://mantine.dev/llms/q-styles-order.md): Learn how to use CSS layers to control styles order and prevent Mantine components from overriding your styles +- [Native browser validation does not work in some components, what should I do?](https://mantine.dev/llms/q-native-required.md): Learn why native browser validation does not work in some components +- [The use-local-storage hook returns the real value only after mounting. Is it a bug?](https://mantine.dev/llms/q-local-storage-effect.md): Learn how Mantine retrieves local storage value +- [What is the difference between searchable Select and Autocomplete?](https://mantine.dev/llms/q-select-autocomplete-difference.md): Searchable Select and Autocomplete are similar components, but they serve different purposes. +- [Where can I find the roadmap?](https://mantine.dev/llms/q-roadmap.md): The roadmap is private and only available to the team. +- [Why are my Carousel slides in vertical orientation?](https://mantine.dev/llms/q-carousel-missing-styles.md): You forgot to import carousel styles +- [Why are my notifications displayed at the wrong position?](https://mantine.dev/llms/q-notifications-missing-styles.md): Because you did not import styles +- [Why can I not use one polymorphic component in the component prop of another polymorphic component?](https://mantine.dev/llms/q-polymorphic-in-polymorphic.md): Learn how polymorphic components types work +- [Why can I not use value/label data structure with Autocomplete/TagsInput?](https://mantine.dev/llms/q-autocomplete-value-label.md): Learn the difference between Autocomplete and Select components +- [Why can VSCode not autoimport the Text component?](https://mantine.dev/llms/q-why-vscode-cannot-autoimport-text.md): It is confused by the native Text constructor +- [Why do I see color scheme flickering on page load?](https://mantine.dev/llms/q-color-scheme-flickering.md): Color scheme flickering is caused by incorrect usage of ColorSchemeScript +- [Why do my dates components look broken?](https://mantine.dev/llms/q-dates-missing-styles.md): Because you did not import styles +- [Why does FileButton not work in Menu?](https://mantine.dev/llms/q-file-button-in-menu.md): Learn how to use FileButton in Menu component +- [Why is it required to have 10 shades per color?](https://mantine.dev/llms/q-ten-shades-per-color.md): Learn how Mantine uses theme colors +- [Why is my screen completely empty after I](https://mantine.dev/llms/q-notifications-empty-screen.md): You have used Notifications component incorrectly + +## Core + +- [Accordion](https://mantine.dev/llms/core-accordion.md): Divide content into collapsible sections +- [ActionIcon](https://mantine.dev/llms/core-action-icon.md): Icon button +- [Affix](https://mantine.dev/llms/core-affix.md): Renders children inside portal at fixed position +- [Alert](https://mantine.dev/llms/core-alert.md): Attract user attention with important static message +- [AlphaSlider](https://mantine.dev/llms/core-alpha-slider.md): Slider component for selecting alpha channel in color pickers (0 – 1) +- [Anchor](https://mantine.dev/llms/core-anchor.md): Display link with theme styles +- [AngleSlider](https://mantine.dev/llms/core-angle-slider.md): Pick angle value between 0 and 360 +- [AppShell](https://mantine.dev/llms/core-app-shell.md): Responsive shell for your application with header, navbar, aside and footer +- [AspectRatio](https://mantine.dev/llms/core-aspect-ratio.md): Maintain responsive consistent width/height ratio +- [Autocomplete](https://mantine.dev/llms/core-autocomplete.md): Autocomplete user input with any list of options +- [Avatar](https://mantine.dev/llms/core-avatar.md): Display user profile image, initials or fallback icon +- [BackgroundImage](https://mantine.dev/llms/core-background-image.md): Displays image as background +- [Badge](https://mantine.dev/llms/core-badge.md): Display badge, pill or tag +- [Blockquote](https://mantine.dev/llms/core-blockquote.md): Blockquote with optional cite +- [Box](https://mantine.dev/llms/core-box.md): Base component for all Mantine components +- [Breadcrumbs](https://mantine.dev/llms/core-breadcrumbs.md): Separates list of react nodes with given separator +- [Burger](https://mantine.dev/llms/core-burger.md): Open/close navigation button +- [Button](https://mantine.dev/llms/core-button.md): Button component to render button or link +- [Card](https://mantine.dev/llms/core-card.md): Card with sections +- [Cascader](https://mantine.dev/llms/core-cascader.md): Select a path from hierarchical data with cascading columns +- [Center](https://mantine.dev/llms/core-center.md): Centers content vertically and horizontally +- [Checkbox](https://mantine.dev/llms/core-checkbox.md): Capture boolean input from user +- [Chip](https://mantine.dev/llms/core-chip.md): Pick one or multiple values with inline controls +- [CloseButton](https://mantine.dev/llms/core-close-button.md): Button with close icon +- [Code](https://mantine.dev/llms/core-code.md): Inline and block code +- [Collapse](https://mantine.dev/llms/core-collapse.md): Animate presence with slide down/up transition +- [ColorInput](https://mantine.dev/llms/core-color-input.md): Capture color from user +- [ColorPicker](https://mantine.dev/llms/core-color-picker.md): Pick colors in hex(a), rgb(a), hsl(a) and hsv(a) formats +- [ColorSwatch](https://mantine.dev/llms/core-color-swatch.md): Displays color +- [Combobox](https://mantine.dev/llms/core-combobox.md): Create custom select, autocomplete or multiselect inputs +- [ComboboxPopover](https://mantine.dev/llms/core-combobox-popover.md): Add combobox dropdown to any button element +- [Container](https://mantine.dev/llms/core-container.md): Center content with padding and max-width +- [CopyButton](https://mantine.dev/llms/core-copy-button.md): Copies given text to clipboard +- [CorePackage](https://mantine.dev/llms/core-package.md) +- [DataList](https://mantine.dev/llms/core-data-list.md): Display label-value pairs as a description list +- [Dialog](https://mantine.dev/llms/core-dialog.md): Display a fixed overlay dialog at any side of the screen +- [Divider](https://mantine.dev/llms/core-divider.md): Horizontal line with optional label or vertical divider +- [Drawer](https://mantine.dev/llms/core-drawer.md): Display overlay area at any side of the screen +- [EmptyState](https://mantine.dev/llms/core-empty-state.md): Display a placeholder for no data, empty results and first-run states +- [Fieldset](https://mantine.dev/llms/core-fieldset.md): Group related elements in a form +- [FileButton](https://mantine.dev/llms/core-file-button.md): Open file picker with a button click +- [FileInput](https://mantine.dev/llms/core-file-input.md): Capture files from user +- [Flex](https://mantine.dev/llms/core-flex.md): Compose elements in a flex container +- [FloatingIndicator](https://mantine.dev/llms/core-floating-indicator.md): Display a floating indicator over a group of elements +- [FloatingWindow](https://mantine.dev/llms/core-floating-window.md): Draggable floating area +- [FocusTrap](https://mantine.dev/llms/core-focus-trap.md): Trap focus at child node +- [Grid](https://mantine.dev/llms/core-grid.md): Responsive 12 columns grid system +- [Group](https://mantine.dev/llms/core-group.md): Compose elements and components in a horizontal flex container +- [Highlight](https://mantine.dev/llms/core-highlight.md): Highlight given part of a string with mark +- [HoverCard](https://mantine.dev/llms/core-hover-card.md): Display popover section when target element is hovered +- [HueSlider](https://mantine.dev/llms/core-hue-slider.md): Slider component for selecting hue channel in color pickers (0 – 360 degrees) +- [Image](https://mantine.dev/llms/core-image.md): Image with optional fallback +- [Indicator](https://mantine.dev/llms/core-indicator.md): Display element at the corner of another element +- [Input](https://mantine.dev/llms/core-input.md): Base component to create custom inputs +- [JsonInput](https://mantine.dev/llms/core-json-input.md): Capture json data from user +- [Kbd](https://mantine.dev/llms/core-kbd.md): Display keyboard key +- [List](https://mantine.dev/llms/core-list.md): Display ordered or unordered list +- [Loader](https://mantine.dev/llms/core-loader.md): Indicate loading state +- [LoadingOverlay](https://mantine.dev/llms/core-loading-overlay.md): An overlay with centered loader +- [Mark](https://mantine.dev/llms/core-mark.md): Highlight part of the text +- [Marquee](https://mantine.dev/llms/core-marquee.md): Create continuous scrolling animation for content +- [MaskInput](https://mantine.dev/llms/core-mask-input.md): Input with mask pattern for formatted text entry +- [Menu](https://mantine.dev/llms/core-menu.md): Combine a list of secondary actions into single interactive area +- [Menubar](https://mantine.dev/llms/core-menubar.md): Desktop application style menubar with a row of menu triggers +- [Modal](https://mantine.dev/llms/core-modal.md): An accessible overlay dialog +- [MultiSelect](https://mantine.dev/llms/core-multi-select.md): Custom searchable multi select +- [NativeSelect](https://mantine.dev/llms/core-native-select.md): Native select element based on Input +- [NavLink](https://mantine.dev/llms/core-nav-link.md): Navigation link +- [Notification](https://mantine.dev/llms/core-notification.md): Show dynamic notifications and alerts to user, part of notifications system +- [NumberFormatter](https://mantine.dev/llms/core-number-formatter.md): Format number with thousands/decimal separators and suffix/prefix +- [NumberInput](https://mantine.dev/llms/core-number-input.md): Capture number from user +- [OverflowList](https://mantine.dev/llms/core-overflow-list.md): Hide items that do not fit in a container and display them as a single collapsed item +- [Overlay](https://mantine.dev/llms/core-overlay.md): Overlays parent element with div element with any color and opacity +- [Pagination](https://mantine.dev/llms/core-pagination.md): Display active page and navigate between multiple pages +- [Paper](https://mantine.dev/llms/core-paper.md): Renders white or dark background depending on color scheme +- [PasswordInput](https://mantine.dev/llms/core-password-input.md): Capture password data from user +- [Pill](https://mantine.dev/llms/core-pill.md): Removable and non-removable tags +- [PillsInput](https://mantine.dev/llms/core-pills-input.md): Base component for custom tags inputs and multi selects +- [PinInput](https://mantine.dev/llms/core-pin-input.md): Capture pin code or one time password from the user +- [Popover](https://mantine.dev/llms/core-popover.md): Display popover section relative to given target element +- [Portal](https://mantine.dev/llms/core-portal.md): Renders component outside of parent element tree +- [Progress](https://mantine.dev/llms/core-progress.md): Give user feedback for status of the task +- [Radio](https://mantine.dev/llms/core-radio.md): Wrapper for input type radio +- [RangeSlider](https://mantine.dev/llms/core-range-slider.md): RangeSlider component +- [Rating](https://mantine.dev/llms/core-rating.md): Pick and display rating +- [RingProgress](https://mantine.dev/llms/core-ring-progress.md): Give user feedback for status of the task with circle diagram +- [RollingNumber](https://mantine.dev/llms/core-rolling-number.md): Animate value changes with rolling digits +- [ScrollArea](https://mantine.dev/llms/core-scroll-area.md): Area with custom scrollbars +- [Scroller](https://mantine.dev/llms/core-scroller.md): Horizontal scroll container with navigation controls +- [SegmentedControl](https://mantine.dev/llms/core-segmented-control.md): A linear set of two or more segments +- [Select](https://mantine.dev/llms/core-select.md): Custom searchable select +- [SemiCircleProgress](https://mantine.dev/llms/core-semi-circle-progress.md): Represent progress with semi circle diagram +- [SimpleGrid](https://mantine.dev/llms/core-simple-grid.md): Responsive grid in which each item takes equal amount of space +- [Skeleton](https://mantine.dev/llms/core-skeleton.md): Indicate content loading state +- [Slider](https://mantine.dev/llms/core-slider.md): Slider component +- [Space](https://mantine.dev/llms/core-space.md): Add horizontal or vertical spacing from theme +- [Splitter](https://mantine.dev/llms/core-splitter.md): Resizable split pane layout +- [Spoiler](https://mantine.dev/llms/core-spoiler.md): Hide long sections of content under a spoiler +- [Stack](https://mantine.dev/llms/core-stack.md): Compose elements and components in a vertical flex container +- [Stepper](https://mantine.dev/llms/core-stepper.md): Display content divided into a steps sequence +- [Switch](https://mantine.dev/llms/core-switch.md): Capture boolean input from user +- [Table](https://mantine.dev/llms/core-table.md): Render table with theme styles +- [TableOfContents](https://mantine.dev/llms/core-table-of-contents.md): Renders a list of headings on the page and tracks current heading visible in the viewport +- [Tabs](https://mantine.dev/llms/core-tabs.md): Switch between different views +- [TagsInput](https://mantine.dev/llms/core-tags-input.md): Capture a list of values from user with free input and suggestions +- [Text](https://mantine.dev/llms/core-text.md): Display text +- [Textarea](https://mantine.dev/llms/core-textarea.md): Autosize or regular textarea +- [TextInput](https://mantine.dev/llms/core-text-input.md): Capture string input from user +- [ThemeIcon](https://mantine.dev/llms/core-theme-icon.md): Render icon inside element with theme colors +- [Timeline](https://mantine.dev/llms/core-timeline.md): Display list of events in chronological order +- [Title](https://mantine.dev/llms/core-title.md): h1-h6 heading +- [Tooltip](https://mantine.dev/llms/core-tooltip.md): Renders tooltip at given element on mouse over or other event +- [Transition](https://mantine.dev/llms/core-transition.md): Animate presence of component with pre-made animations +- [Tree](https://mantine.dev/llms/core-tree.md): Display a Tree structure +- [TreeSelect](https://mantine.dev/llms/core-tree-select.md): Select with hierarchical tree data +- [Typography](https://mantine.dev/llms/core-typography.md): Styles provider for html content +- [UnstyledButton](https://mantine.dev/llms/core-unstyled-button.md): Unstyled polymorphic button +- [VisuallyHidden](https://mantine.dev/llms/core-visually-hidden.md): Hide element visually but keep it accessible for screen readers + +## Hooks + +- [HooksPackage](https://mantine.dev/llms/hooks-package.md) +- [use-element-size](https://mantine.dev/llms/hooks-use-element-size.md): Returns element width and height and observes changes with ResizeObserver +- [useClickOutside](https://mantine.dev/llms/hooks-use-click-outside.md) +- [useClipboard](https://mantine.dev/llms/hooks-use-clipboard.md) +- [useCollapse](https://mantine.dev/llms/hooks-use-collapse.md) +- [useColorScheme](https://mantine.dev/llms/hooks-use-color-scheme.md) +- [useCounter](https://mantine.dev/llms/hooks-use-counter.md) +- [useDebouncedCallback](https://mantine.dev/llms/hooks-use-debounced-callback.md) +- [useDebouncedState](https://mantine.dev/llms/hooks-use-debounced-state.md) +- [useDebouncedValue](https://mantine.dev/llms/hooks-use-debounced-value.md) +- [useDidUpdate](https://mantine.dev/llms/hooks-use-did-update.md) +- [useDisclosure](https://mantine.dev/llms/hooks-use-disclosure.md) +- [useDocumentTitle](https://mantine.dev/llms/hooks-use-document-title.md) +- [useDocumentVisibility](https://mantine.dev/llms/hooks-use-document-visibility.md) +- [useDrag](https://mantine.dev/llms/hooks-use-drag.md) +- [useEventListener](https://mantine.dev/llms/hooks-use-event-listener.md) +- [useEyeDropper](https://mantine.dev/llms/hooks-use-eye-dropper.md) +- [useFavicon](https://mantine.dev/llms/hooks-use-favicon.md) +- [useFetch](https://mantine.dev/llms/hooks-use-fetch.md) +- [useFileDialog](https://mantine.dev/llms/hooks-use-file-dialog.md) +- [useFloatingWindow](https://mantine.dev/llms/hooks-use-floating-window.md) +- [useFocusReturn](https://mantine.dev/llms/hooks-use-focus-return.md) +- [useFocusTrap](https://mantine.dev/llms/hooks-use-focus-trap.md) +- [useFocusWithin](https://mantine.dev/llms/hooks-use-focus-within.md) +- [useForceUpdate](https://mantine.dev/llms/hooks-use-force-update.md) +- [useFullscreen](https://mantine.dev/llms/hooks-use-fullscreen.md) +- [useHash](https://mantine.dev/llms/hooks-use-hash.md) +- [useHeadroom](https://mantine.dev/llms/hooks-use-headroom.md) +- [useHotkeys](https://mantine.dev/llms/hooks-use-hotkeys.md) +- [useHover](https://mantine.dev/llms/hooks-use-hover.md) +- [useId](https://mantine.dev/llms/hooks-use-id.md) +- [useIdle](https://mantine.dev/llms/hooks-use-idle.md) +- [useInputState](https://mantine.dev/llms/hooks-use-input-state.md) +- [useIntersection](https://mantine.dev/llms/hooks-use-intersection.md) +- [useInterval](https://mantine.dev/llms/hooks-use-interval.md) +- [useInViewport](https://mantine.dev/llms/hooks-use-in-viewport.md) +- [useIsFirstRender](https://mantine.dev/llms/hooks-use-is-first-render.md) +- [useIsomorphicEffect](https://mantine.dev/llms/hooks-use-isomorphic-effect.md) +- [useListState](https://mantine.dev/llms/hooks-use-list-state.md) +- [useLocalStorage](https://mantine.dev/llms/hooks-use-local-storage.md) +- [useLogger](https://mantine.dev/llms/hooks-use-logger.md) +- [useLongPress](https://mantine.dev/llms/hooks-use-long-press.md) +- [useMap](https://mantine.dev/llms/hooks-use-map.md) +- [useMask](https://mantine.dev/llms/hooks-use-mask.md) +- [useMediaQuery](https://mantine.dev/llms/hooks-use-media-query.md) +- [useMergedRef](https://mantine.dev/llms/hooks-use-merged-ref.md) +- [useMounted](https://mantine.dev/llms/hooks-use-mounted.md) +- [useMouse](https://mantine.dev/llms/hooks-use-mouse.md) +- [useMove](https://mantine.dev/llms/hooks-use-move.md) +- [useMutationObserver](https://mantine.dev/llms/hooks-use-mutation-observer.md) +- [useNetwork](https://mantine.dev/llms/hooks-use-network.md) +- [useOrientation](https://mantine.dev/llms/hooks-use-orientation.md) +- [useOs](https://mantine.dev/llms/hooks-use-os.md) +- [usePageLeave](https://mantine.dev/llms/hooks-use-page-leave.md) +- [usePagination](https://mantine.dev/llms/hooks-use-pagination.md) +- [usePrevious](https://mantine.dev/llms/hooks-use-previous.md) +- [useQueue](https://mantine.dev/llms/hooks-use-queue.md) +- [useRadialMove](https://mantine.dev/llms/hooks-use-radial-move.md) +- [useReducedMotion](https://mantine.dev/llms/hooks-use-reduced-motion.md) +- [useResizeObserver](https://mantine.dev/llms/hooks-use-resize-observer.md) +- [useRovingIndex](https://mantine.dev/llms/hooks-use-roving-index.md) +- [useScrollDirection](https://mantine.dev/llms/hooks-use-scroll-direction.md) +- [useScroller](https://mantine.dev/llms/hooks-use-scroller.md) +- [useScrollIntoView](https://mantine.dev/llms/hooks-use-scroll-into-view.md) +- [useScrollSpy](https://mantine.dev/llms/hooks-use-scroll-spy.md) +- [useSelection](https://mantine.dev/llms/hooks-use-selection.md) +- [useSet](https://mantine.dev/llms/hooks-use-set.md) +- [useSetState](https://mantine.dev/llms/hooks-use-set-state.md) +- [useShallowEffect](https://mantine.dev/llms/hooks-use-shallow-effect.md) +- [useSplitter](https://mantine.dev/llms/hooks-use-splitter.md) +- [useStateHistory](https://mantine.dev/llms/hooks-use-state-history.md) +- [useTextSelection](https://mantine.dev/llms/hooks-use-text-selection.md) +- [useThrottledCallback](https://mantine.dev/llms/hooks-use-throttled-callback.md) +- [useThrottledState](https://mantine.dev/llms/hooks-use-throttled-state.md) +- [useThrottledValue](https://mantine.dev/llms/hooks-use-throttled-value.md) +- [useTimeout](https://mantine.dev/llms/hooks-use-timeout.md) +- [useToggle](https://mantine.dev/llms/hooks-use-toggle.md) +- [useUncontrolled](https://mantine.dev/llms/hooks-use-uncontrolled.md) +- [useValidatedState](https://mantine.dev/llms/hooks-use-validated-state.md) +- [useViewportSize](https://mantine.dev/llms/hooks-use-viewport-size.md) +- [useWindowEvent](https://mantine.dev/llms/hooks-use-window-event.md) +- [useWindowScroll](https://mantine.dev/llms/hooks-use-window-scroll.md) + +## Form + +- [Form context](https://mantine.dev/llms/form-create-form-context.md): Add context support to use-form with createFormContext +- [formActions](https://mantine.dev/llms/form-actions.md) +- [formAllInputs](https://mantine.dev/llms/form-all-inputs.md) +- [formErrors](https://mantine.dev/llms/form-errors.md) +- [formGetInputProps](https://mantine.dev/llms/form-get-input-props.md) +- [formNested](https://mantine.dev/llms/form-nested.md) +- [FormPackage](https://mantine.dev/llms/form-package.md) +- [formRecipes](https://mantine.dev/llms/form-recipes.md) +- [formSchemaValidation](https://mantine.dev/llms/form-schema-validation.md) +- [formStatus](https://mantine.dev/llms/form-status.md) +- [formUncontrolled](https://mantine.dev/llms/form-uncontrolled.md) +- [formValidation](https://mantine.dev/llms/form-validation.md) +- [formValidators](https://mantine.dev/llms/form-validators.md) +- [formValues](https://mantine.dev/llms/form-values.md) +- [use-field](https://mantine.dev/llms/form-use-field.md): use-field hook – manage single field state +- [use-form](https://mantine.dev/llms/form-use-form.md): Manage form state + +## Dates + +- [Calendar](https://mantine.dev/llms/dates-calendar.md): Base component for custom date pickers +- [DateInput](https://mantine.dev/llms/dates-date-input.md): Free form date input +- [DatePicker](https://mantine.dev/llms/dates-date-picker.md): Inline date, multiple dates and dates range picker +- [DatePickerInput](https://mantine.dev/llms/dates-date-picker-input.md): Date, multiple dates and dates range picker input +- [DateTimePicker](https://mantine.dev/llms/dates-date-time-picker.md): Capture datetime from the user +- [GettingStartedDates](https://mantine.dev/llms/dates-getting-started.md) +- [InlineDateTimePicker](https://mantine.dev/llms/dates-inline-date-time-picker.md): Inline date and time picker with range support +- [MiniCalendar](https://mantine.dev/llms/dates-mini-calendar.md): Compact calendar to display a small number of days in a row +- [MonthPicker](https://mantine.dev/llms/dates-month-picker.md): Inline month, multiple months and months range picker +- [MonthPickerInput](https://mantine.dev/llms/dates-month-picker-input.md): Month, multiple months and months range picker input +- [TimeGrid](https://mantine.dev/llms/dates-time-grid.md): Captures time value from the user with a predefined set of options +- [TimeInput](https://mantine.dev/llms/dates-time-input.md): Capture time from the user +- [TimePicker](https://mantine.dev/llms/dates-time-picker.md): Captures time value from the user +- [TimeValue](https://mantine.dev/llms/dates-time-value.md): Display a formatted time value +- [YearPicker](https://mantine.dev/llms/dates-year-picker.md): Inline year, multiple years and years range picker +- [YearPickerInput](https://mantine.dev/llms/dates-year-picker-input.md): Inline year, multiple years and years range picker + +## Charts + +- [AreaChart](https://mantine.dev/llms/charts-area-chart.md): Area chart component with stacked, percent and split variants +- [BarChart](https://mantine.dev/llms/charts-bar-chart.md): Bar chart component with stacked and percent variants +- [BarsList](https://mantine.dev/llms/charts-bars-list.md): Display a list of bars with names and values +- [BubbleChart](https://mantine.dev/llms/charts-bubble-chart.md): Bubble chart component +- [BulletChart](https://mantine.dev/llms/charts-bullet-chart.md): Compact KPI chart with value, target, and qualitative ranges +- [CompositeChart](https://mantine.dev/llms/charts-composite-chart.md): Composed chart with support for Area, Bar and Line charts +- [DonutChart](https://mantine.dev/llms/charts-donut-chart.md): Donut chart component +- [FunnelChart](https://mantine.dev/llms/charts-funnel-chart.md): Funnel chart component +- [GettingStartedCharts](https://mantine.dev/llms/charts-getting-started.md) +- [Heatmap](https://mantine.dev/llms/charts-heatmap.md): Heatmap chart component +- [LineChart](https://mantine.dev/llms/charts-line-chart.md): Line chart component +- [PieChart](https://mantine.dev/llms/charts-pie-chart.md): Pie chart component +- [RadarChart](https://mantine.dev/llms/charts-radar-chart.md): Radar chart component +- [RadialBarChart](https://mantine.dev/llms/charts-radial-bar-chart.md): Radial bar chart component +- [SankeyChart](https://mantine.dev/llms/charts-sankey-chart.md): Sankey diagram component +- [ScatterChart](https://mantine.dev/llms/charts-scatter-chart.md): Scatter chart component +- [Sparkline](https://mantine.dev/llms/charts-sparkline.md): Simplified area chart to show trends +- [SunburstChart](https://mantine.dev/llms/charts-sunburst-chart.md): Sunburst chart component for hierarchical data +- [Treemap](https://mantine.dev/llms/charts-treemap.md): Treemap chart component + +## Schedule + +- [AgendaView](https://mantine.dev/llms/schedule-agenda-view.md): Vertical list view of events grouped by date +- [DayView](https://mantine.dev/llms/schedule-day-view.md): Standalone schedule day view component +- [GettingStartedSchedule](https://mantine.dev/llms/schedule-getting-started.md) +- [MobileMonthView](https://mantine.dev/llms/schedule-mobile-month-view.md): Mobile-optimized schedule month view component +- [MonthView](https://mantine.dev/llms/schedule-month-view.md): Standalone schedule month view component +- [RecurringEventsSchedule](https://mantine.dev/llms/schedule-recurring-events.md) +- [ResourcesDayView](https://mantine.dev/llms/schedule-resources-day-view.md): Day view with resource columns for scheduling across resources +- [ResourcesMonthView](https://mantine.dev/llms/schedule-resources-month-view.md): Month view with resource lanes for scheduling across resources +- [ResourcesSchedule](https://mantine.dev/llms/schedule-resources-schedule.md): Schedule wrapper component that combines resource day, week and month views +- [ResourcesWeekView](https://mantine.dev/llms/schedule-resources-week-view.md): Week view with resource rows for scheduling across resources +- [Schedule](https://mantine.dev/llms/schedule-schedule.md): Schedule component with day, week, month and year views +- [ScheduleEventsData](https://mantine.dev/llms/schedule-events-data.md) +- [WeekView](https://mantine.dev/llms/schedule-week-view.md): Standalone schedule week view component +- [YearView](https://mantine.dev/llms/schedule-year-view.md): Standalone schedule year view component + +## Guides + +- [ControlledVsUncontrolled](https://mantine.dev/llms/guides-controlled-vs-uncontrolled.md) +- [CustomComponents](https://mantine.dev/llms/guides-custom-components.md) +- [EightToNine](https://mantine.dev/llms/guides-8x-to-9x.md) +- [FunctionsReference](https://mantine.dev/llms/guides-functions-reference.md) +- [Gatsby](https://mantine.dev/llms/guides-gatsby.md) +- [Icons](https://mantine.dev/llms/guides-icons.md) +- [JavaScript](https://mantine.dev/llms/guides-javascript.md) +- [Jest](https://mantine.dev/llms/guides-jest.md) +- [LLMDocumentation](https://mantine.dev/llms/guides-llms.md) +- [NextJs](https://mantine.dev/llms/guides-next.md) +- [Polymorphic](https://mantine.dev/llms/guides-polymorphic.md) +- [ReactRouter](https://mantine.dev/llms/guides-react-router.md) +- [Redwood](https://mantine.dev/llms/guides-redwood.md) +- [SevenToEight](https://mantine.dev/llms/guides-7x-to-8x.md) +- [SixToSeven](https://mantine.dev/llms/guides-6x-to-7x.md) +- [Storybook](https://mantine.dev/llms/guides-storybook.md) +- [Tiptap3Migration](https://mantine.dev/llms/guides-tiptap-3-migration.md) +- [TypeScript](https://mantine.dev/llms/guides-typescript.md) +- [Vite](https://mantine.dev/llms/guides-vite.md) +- [Vitest](https://mantine.dev/llms/guides-vitest.md) + +## Theming + +- [Colors](https://mantine.dev/llms/theming-colors.md) +- [ColorSchemes](https://mantine.dev/llms/theming-color-schemes.md) +- [DefaultProps](https://mantine.dev/llms/theming-default-props.md) +- [MantineProvider](https://mantine.dev/llms/theming-mantine-provider.md) +- [ThemeObject](https://mantine.dev/llms/theming-theme-object.md) +- [Typography](https://mantine.dev/llms/theming-typography.md): Styles provider for html content + +## Styles + +- [ColorFunctions](https://mantine.dev/llms/styles-color-functions.md) +- [CSSFilesList](https://mantine.dev/llms/styles-css-files-list.md) +- [CSSModules](https://mantine.dev/llms/styles-css-modules.md) +- [CssVariables](https://mantine.dev/llms/styles-css-variables.md) +- [CSSVariablesList](https://mantine.dev/llms/styles-css-variables-list.md) +- [DataAttributes](https://mantine.dev/llms/styles-data-attributes.md) +- [Emotion](https://mantine.dev/llms/styles-emotion.md) +- [GlobalStyles](https://mantine.dev/llms/styles-global-styles.md) +- [MantineStyles](https://mantine.dev/llms/styles-mantine-styles.md) +- [PostCSSPreset](https://mantine.dev/llms/styles-postcss-preset.md) +- [Rem](https://mantine.dev/llms/styles-rem.md) +- [ResponsiveStyles](https://mantine.dev/llms/styles-responsive.md) +- [Rtl](https://mantine.dev/llms/styles-rtl.md) +- [StyleProp](https://mantine.dev/llms/styles-style.md) +- [StyleProps](https://mantine.dev/llms/styles-style-props.md) +- [StylesApi](https://mantine.dev/llms/styles-styles-api.md) +- [StylesOverview](https://mantine.dev/llms/styles-styles-overview.md) +- [StylesPerformance](https://mantine.dev/llms/styles-styles-performance.md) +- [UnstyledComponents](https://mantine.dev/llms/styles-unstyled.md) +- [UsageWithSass](https://mantine.dev/llms/styles-sass.md) +- [VanillaExtract](https://mantine.dev/llms/styles-vanilla-extract.md) +- [VariantsAndSizes](https://mantine.dev/llms/styles-variants-sizes.md) + +## X + +- [Carousel](https://mantine.dev/llms/x-carousel.md): Embla based carousel component +- [CodeHighlight](https://mantine.dev/llms/x-code-highlight.md): Highlight code with shiki or highlight.js +- [Dropzone](https://mantine.dev/llms/x-dropzone.md): Capture files from user with drag and drop +- [ExtensionsPackage](https://mantine.dev/llms/x-extensions.md) +- [Modals manager](https://mantine.dev/llms/x-modals.md): Centralized modals manager with option to handle state of multi-step modals +- [NavigationProgress](https://mantine.dev/llms/x-nprogress.md): Navigation progress bar +- [Notifications system](https://mantine.dev/llms/x-notifications.md): Mantine notifications system +- [Rich text editor](https://mantine.dev/llms/x-tiptap.md): Tiptap based rich text editor +- [Spotlight](https://mantine.dev/llms/x-spotlight.md): Command center for your application + +## Other + +- [About](https://mantine.dev/llms/about.md) +- [AllReleases](https://mantine.dev/llms/changelog-all-releases.md) +- [BrowserSupport](https://mantine.dev/llms/browser-support.md) +- [Changelog700](https://mantine.dev/llms/changelog-7-0-0.md) +- [Changelog710](https://mantine.dev/llms/changelog-7-1-0.md) +- [Changelog7100](https://mantine.dev/llms/changelog-7-10-0.md) +- [Changelog7110](https://mantine.dev/llms/changelog-7-11-0.md) +- [Changelog7120](https://mantine.dev/llms/changelog-7-12-0.md) +- [Changelog7130](https://mantine.dev/llms/changelog-7-13-0.md) +- [Changelog7140](https://mantine.dev/llms/changelog-7-14-0.md) +- [Changelog7150](https://mantine.dev/llms/changelog-7-15-0.md) +- [Changelog7160](https://mantine.dev/llms/changelog-7-16-0.md) +- [Changelog7170](https://mantine.dev/llms/changelog-7-17-0.md) +- [Changelog720](https://mantine.dev/llms/changelog-7-2-0.md) +- [Changelog730](https://mantine.dev/llms/changelog-7-3-0.md) +- [Changelog740](https://mantine.dev/llms/changelog-7-4-0.md) +- [Changelog750](https://mantine.dev/llms/changelog-7-5-0.md) +- [Changelog760](https://mantine.dev/llms/changelog-7-6-0.md) +- [Changelog770](https://mantine.dev/llms/changelog-7-7-0.md) +- [Changelog780](https://mantine.dev/llms/changelog-7-8-0.md) +- [Changelog790](https://mantine.dev/llms/changelog-7-9-0.md) +- [Changelog800](https://mantine.dev/llms/changelog-8-0-0.md) +- [Changelog810](https://mantine.dev/llms/changelog-8-1-0.md) +- [Changelog820](https://mantine.dev/llms/changelog-8-2-0.md) +- [Changelog830](https://mantine.dev/llms/changelog-8-3-0.md) +- [Changelog900](https://mantine.dev/llms/changelog-9-0-0.md) +- [Changelog910](https://mantine.dev/llms/changelog-9-1-0.md) +- [Changelog920](https://mantine.dev/llms/changelog-9-2-0.md) +- [Changelog930](https://mantine.dev/llms/changelog-9-3-0.md) +- [Changelog940](https://mantine.dev/llms/changelog-9-4-0.md) +- [Changelog950](https://mantine.dev/llms/changelog-9-5-0.md) +- [Contribute](https://mantine.dev/llms/contribute.md) +- [GettingStarted](https://mantine.dev/llms/getting-started.md) +- [NotFound](https://mantine.dev/llms/404.md) +- [OxcConfig](https://mantine.dev/llms/oxc-config-mantine.md) +- [PreviousChangelogs](https://mantine.dev/llms/changelog-previous-versions.md) +- [Support](https://mantine.dev/llms/support.md) diff --git a/PRODUCT.md b/PRODUCT.md index 8b4a6c01a..bac6f41bd 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,30 +6,34 @@ product ## Users -Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work. +Homarr is for self-hosters and administrators who assemble dashboards for services across home labs and private infrastructure. They need dense, reliable controls and status at a glance, while retaining enough guidance to configure advanced features without hiding how they work. ## Product Purpose -Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service. Boards must load immediately, stay visually stable, keep widgets legible across screen sizes, and make editing feel direct and forgiving. +Homarr provides one place to organize, observe, and operate self-hosted services. Success means users can build useful dashboards quickly, understand their state, recover from configuration errors, act on services, and reshape their workspace without repeatedly leaving the board. Boards must load immediately, stay visually stable, and keep widgets legible across screen sizes. ## Brand Personality -Direct, capable, and approachable. Copy should be concise and technical when precision matters, while the interface should remain familiar to users who are not developers. +Direct, capable, precise, and approachable. Homarr should feel calm under load, be truthful about system state, and reward users who refine details. ## Anti-references -Avoid decorative admin dashboards, wizard flows that fight ordinary scrolling, hidden configuration magic, inconsistent controls, excessive modal usage, and interfaces that expose implementation complexity before the user needs it. +- Decorative dashboards, oversized metric cards, and motion without state meaning. +- Wizard flows that fight ordinary scrolling or rigid layouts that force one workflow. +- Hidden configuration magic and implementation complexity exposed before it is needed. +- Clever custom controls where familiar platform or Mantine interactions already work. ## Design Principles -1. Keep the user in control by making configuration and consequences visible. -2. Prefer familiar product patterns and progressive disclosure over custom interaction concepts. -3. Make simple tasks short without limiting advanced use cases. -4. Isolate failures so one service or widget cannot disrupt the dashboard. -5. Use the existing Mantine design language consistently across management and board surfaces. -6. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback. -7. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible. +1. Keep configuration, consequences, and system state visible. +2. Make simple tasks short without limiting advanced workflows. +3. Preserve expert depth while prioritizing urgent information at small sizes. +4. Reveal dense workspaces progressively through a consistent advanced focus mode. +5. Isolate failures and show loading, stale, partial, empty, and failed states truthfully. +6. Use the existing Mantine design language consistently across management and board surfaces. +7. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback. +8. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible. ## Accessibility & Inclusion -Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations. +Target WCAG 2.2 AA. Every pointer interaction needs a keyboard and coarse-pointer path. Support screen readers, visible and restored focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 000000000..cb32a60d2 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,263 @@ +# PR #6502 widget-modernization ultra-review + +## Executive result + +**Merge recommendation: changes required.** No P0 was found, but the current branch still has five P1 issues: a pre-existing Custom API authorization/redirect weakness exposed by this subsystem review, an inaccessible manual advanced-focus interaction, a likely fixed-position/transform geometry defect, systemic false empty states for failed queries, and unvalidated integration-provided navigation URLs. Eight P2 issues cover raw error disclosure, unsafe runtime-ref interfaces, localization, responsive/theming drift, test coverage, and inaccurate PR copy. + +Audit health (Impeccable rubric): accessibility 1/4, performance 2/4, responsive design 2/4, theming 2/4, anti-patterns 3/4; **10/20 (Acceptable, significant work required)**. The UI does not look generically AI-generated; its problem is inconsistent technical behavior beneath an otherwise coherent Mantine vocabulary. + +## Reviewed revision and environment + +| Item | Value | +| ---------------------------- | ---------------------------------------------------------------------------------------------- | +| PR | #6502, `feat: modernize dashboard widgets` | +| Reviewed head | `96e92485a5fd45f3648cfac0453e614779c414ba` | +| Base branch / fetched base | `dev` / `d58a47b776a55d04dee2220f59d072b9af8bbed3` | +| Merge base | `d58a47b776a55d04dee2220f59d072b9af8bbed3` | +| Diff | 359 files, +15,721/-4,478 | +| PR state | open, non-draft, `BLOCKED`; no approval decision | +| Hosted checks at audit start | Fast gate, Container and E2E, CodeQL, JS/TS analysis, Actions analysis, title, auto-fix: green | +| Inline threads | 34 total, 7 unresolved; only 2 remain substantively valid | +| Mantine | repository lock: `@mantine/core`, hooks, charts and dates 9.4.1 | +| Local tools | Node 24.16.0 and pnpm 9.5.0; repository requires Node >=24.18.0 and pnpm >=11.15.1 | + +The former red baseline `7b7045202d289b7d83946befcee5d2845ea4a706` had a green Fast gate and a failed Container/E2E run (`30748255760`, job `91498308523`). The latest head has a green Container/E2E job (`30756067198`, job `91519039504`). This proves the current suite passed once; it does not satisfy the requested three-run flake characterization. + +## PR-description traceability + +| Claim | Result | Evidence / correction | +| --------------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| “all 54 widget renderers with compact and advanced modes” | **Inaccurate** | 54 `WidgetKind` values exist. `displayMode="advanced"` reaches 53; `app` is deliberately excluded in `item-content.tsx:51` and documented as compact-only. Change copy to “53 widget renderers; app tiles remain compact,” or implement app advanced mode. | +| advanced focus: hover, keyboard, touch, geometry | **Partial** | Entry paths, Escape ownership, reduced motion, close focus, touch button and zero-dimension guards exist. WID-002/003 remain. | +| richer widget context actions | **Partial** | `Menu.ContextMenu` is a good Mantine choice and query scope is item-aware. Dispatch still crosses `any`; Custom API action authorization is not represented. | +| standardized query identity/cache/status | **Partial** | Query keys, matchers, runtime capture, recovery and tests exist. Thirty-nine of 53 renderer query calls do not read errors; runtime ref registration is render-time mutable state. | +| safe polling | **Mostly confirmed** | defaults are limited to `widget` namespace; Custom API resume immediately refetches; per-widget intervals exist. State/ref typing still needs redesign. | +| partial-failure rendering | **Partial** | settlement helper and several routers are strong; many renderer-level total failures still appear empty/disconnected. | +| bounded integration responses | **Mostly confirmed** | common bounded reader, Custom API 2 MiB, timetable 512 KiB, Traefik bounds and Beszel queue bounds exist. Custom action execution remains outside the hardened fetch path. | +| timetable hardening | **Confirmed** | board/item permission checks, saved-value comparison, public-address resolution, private ranges, per-request DNS re-resolution, pinned dispatcher, redirect rejection, timeouts, response bounds and schemas are present. | +| precise placement | **Mostly confirmed** | mouse, touch tolerance, keyboard, occupied-cell validation, dynamic bounds, nearest fit and immediate persistence exist. Failure/race/live responsive coverage remains insufficient. | +| widget catalog documentation | **Partial** | all kinds have pages and shared interactions; wording overclaims focus behavior and validation counts are stale. | +| validation: 40 files/162 tests, 6/6 E2E | **Stale** | current diff contains 57 changed unit-spec files (including three E2E specs); repository has 42 widget spec files and six E2E specs. Update from the exact final run. | +| independent reviews “pass” | **Not supported** | unresolved valid findings, current P1s, and unavailable live QA contradict an unqualified pass. | + +## Mantine suitability ledger + +Legend: **U** already used appropriately; **C** consolidation/replacement candidate; **R** relevant but current implementation can stay; **N** not relevant to widget runtime; **D** dependency/version addition. Exact 9.4.1 package archives were inspected in `/tmp`; live docs/Context7 include 9.5.0. + +### Core catalog + +- **U:** ActionIcon, Alert, Anchor, Avatar, Badge, Box, Button, Card, Center, Code, Divider, Flex, Grid, Group, Image, Loader, Menu (`Menu.ContextMenu`), Overlay, Paper, Popover, Portal, Progress, RingProgress, ScrollArea, SimpleGrid, Skeleton, Stack, Table, Tabs, Text, ThemeIcon, Title, Tooltip, UnstyledButton. +- **C:** Accordion (dense integration sections), DataList (semantic label/value stats), EmptyState (replace bespoke empty state), FocusTrap and Modal compound API (WID-002), LoadingOverlay (background refetch without deleting content), NumberFormatter, OverflowList/Scroller (responsive action and chip rows), Table.ScrollContainer, Timeline (activity/feed histories), VisuallyHidden and Kbd, CloseButton, Dialog/HoverCard/Notification where persistent errors/actions need semantics. +- **R:** Affix, AppShell, AspectRatio, BackgroundImage, Breadcrumbs, Burger, Checkbox, Chip, Collapse, ColorInput/ColorPicker/ColorSwatch/AlphaSlider/AngleSlider/HueSlider, Combobox/ComboboxPopover, Container, CopyButton, Drawer, Fieldset, FileButton/FileInput, FloatingIndicator/FloatingWindow, Highlight, Indicator, Input/JsonInput, List, Mark, Marquee, MaskInput, Menubar, MultiSelect/NativeSelect/NumberInput/PasswordInput/PillsInput/PinInput/Radio/RangeSlider/Rating/SegmentedControl/Select/Slider/Switch/TagsInput/Textarea/TextInput, Pagination, Pill, SemiCircleProgress, Space, Splitter, Spoiler, Stepper, Transition, Tree/TreeSelect, Typography. Use only where an existing widget requirement matches; do not churn working layouts. +- **N:** Alpha/color/angle primitives outside configuration forms, AppShell/navigation primitives, TableOfContents, CorePackage documentation. +- **D (9.5, not 9.4.1):** Cascader. **RollingNumber and Splitter are present in 9.4.1.** + +### Hooks catalog + +- **C:** `useFocusReturn`, `useFocusTrap`, `useReducedMotion`, `useRovingIndex`, `useLongPress`, `useScroller`, `useElementSize`, `useViewportSize`, `useHotkeys`, `useMediaQuery`, `useResizeObserver`. +- **U/R:** focus-within, hover, disclosure, click-outside, merged-ref, interval/timeout, local-storage, event/window listeners, throttled/debounced values and list/set/map/state helpers are available in 9.4.1 and should replace local mechanics only when behavior and tests improve. +- **N:** document title/favicon/hash/headroom/page-leave/OS/eye-dropper/fullscreen/network/orientation and other page-level utilities have no direct widget-remediation need. Every hook listed under `## Hooks` in `MANTINE.md` is covered by this rule; no hook requires 9.5 for the recommendations above. + +### Form, dates, charts, schedule, extensions, theme and styles + +- **Form:** existing `@homarr/form` should remain the abstraction over all cataloged form APIs. No widget-runtime replacement; use current validation/status APIs for widget editors (**R**). +- **Dates:** all catalog entries are in installed 9.4.1 dates. `Calendar`, `MiniCalendar`, and `TimeValue` are candidates; other picker/input entries are editor-only (**R**). Importing dates directly into `@homarr/widgets` requires adding that workspace dependency (**D**). +- **Charts:** 9.4.1 exports Area, Bar, BarsList, Bubble, Composite, Donut, Funnel, Heatmap, Line, Pie, Radar, RadialBar, Sankey, Scatter, Sparkline and Treemap (**C/R**). Prefer Sparkline for tiny trends, BarsList for ranked values, Heatmap for calendar/activity density, Composite for mixed analytics. **BulletChart and SunburstChart are 9.5-only (D)**. Replacement requires accessibility and bundle comparison, not blanket Recharts removal. +- **Schedule:** all `AgendaView`, day/week/month/year/resource views and Schedule pages require new `@mantine/schedule` dependency (**D**). AgendaView is the only plausible calendar-widget candidate; its weight and semantics must beat the existing grouped list before adoption. +- **Guides/FAQ/Other:** server components, portal testing, scroll lock, accessibility, nested popovers, zoom, responsive styling and version changelogs are relevant references (**R**); framework/setup/contribution/old migration entries are **N**. The 9.5 changelog is informational only. +- **Theming/styles:** Colors, ColorSchemes, DefaultProps, MantineProvider, ThemeObject, CSS variables, data attributes, CSS Modules, responsive/RTL/style props/Styles API/performance are **U/R**. WID-009 requires shared layer/color tokens. Emotion, Sass, Vanilla Extract and unrelated global setup are **N**. +- **Extensions:** existing modals, notifications, spotlight and rich-text abstractions are **U**; Carousel could consolidate Immich only after behavior parity; Dropzone/CodeHighlight/NProgress are **N** for this audit. + +## Complete 54-widget coverage matrix + +“Test” means a focused widget-local spec exists, not that all required states are covered. Every row still requires the common contract suite in WID-013. + +| WidgetKind | Family | Advanced | Focused test | Principal audit gap | +| --------------------------- | ----------------------- | ----------- | ------------ | ---------------------------------------------------------- | +| clock | basic | yes | no | query error, locale-format options | +| weather | basic/feed | yes | yes | query error; dayjs locale/time patterns | +| app | basic/link | **no** | no | PR wording; URL contract | +| iframe | custom content | yes | yes | manual focus/iframe trapping; URL privacy thread now fixed | +| video | custom content | yes | no | media/focus/live behavior | +| notebook | notes | yes (inner) | yes | editor focus inside manual surface | +| anchorNote | notes | yes | no | query error, save/error regression | +| dnsHoleSummary | network | yes | no | total query error, hard-coded chart colors | +| dnsHoleControls | network/security | yes | no | total error and permission matrix | +| smartHome-entityState | smart home | yes | no | raw state in aria; action permissions | +| smartHome-executeAutomation | smart home | yes | no | mutation/permission/edit-mode E2E | +| stockPrice | analytics | yes | yes | total error | +| mediaServer | media | yes | no | total error and table a11y | +| calendar | calendar | yes | yes | total error; fixed HH:mm; schedule tradeoff | +| downloads | media | yes | yes | four-state/component coverage, arbitrary z-index | +| mediaRequests-requestList | media | yes | no | total error; external URLs/actions | +| mediaRequests-requestStats | media | yes | no | total error; links/colors | +| mediaTranscoding | media | yes | yes | total error and responsive table | +| mediaMissing | media | yes | yes | total error and external URL | +| minecraftServerStatus | infrastructure | yes | no | total error/malformed payload | +| networkControllerSummary | networking | yes | no | total error; WAN/unknown localization | +| networkControllerStatus | networking | yes | no | responsive rule duplication | +| rssFeed | feed | yes | yes | strong stale/partial state; locale relative time | +| bookmarks | basic/link | yes | yes | recomputation; URL contract | +| indexerManager | media | yes | no | total error; runtime action ref | +| healthMonitoring | infrastructure | yes | yes | integration query error | +| releases | infrastructure/feed | yes | yes | secret/item authorization needs E2E | +| mediaReleases | media | yes | no | total error; external URLs | +| dockerContainers | infrastructure | yes | yes | total error; z-index/table semantics | +| firewall | networking/security | yes | yes | partial-query matrix; loose router generics | +| notifications | infrastructure | yes | no | total error; untrusted href | +| systemResources | infrastructure | yes | yes | total error; previous network thread fixed | +| coolify | infrastructure | yes | yes | total error; URL validation | +| systemDisks | infrastructure | yes | yes | total error | +| timetable | calendar | yes | no | backend strong; component locale/live tests absent | +| immich-serverStats | media | yes | yes | two ignored query errors | +| immich-albumCarousel | media | yes | yes (query) | query error and nested overlay/focus E2E | +| paperlessNgx | analytics | yes | no | former error thread fixed, no regression spec | +| patchmon | infrastructure | yes | yes | total error | +| bazarr | media | yes | yes | total error | +| tracearr | analytics | yes | yes | total error | +| speedtestTracker | analytics | yes | yes | total error | +| uptimeKuma | infrastructure | yes | yes | total error | +| audioStats | media | yes | yes | good dual-error handling; partial-state tests needed | +| umami | analytics | yes | no | runtime query registration; chart a11y | +| vpn | networking/security | yes | no | total error; status/permission matrix | +| archiveTeamWarrior | infrastructure | yes | yes | suspense path; docs present | +| ups | infrastructure | yes | no | total error | +| beszelSystemTable | monitoring | yes | yes | nested chart/focus and live subscription | +| beszelSystemGrid | monitoring | yes | no | live/error UI; no focused renderer test | +| beszelAlerts | monitoring | yes | no | partial error and status semantics | +| beszelSystemStats | monitoring | yes | yes | runtime refs, locale tooltip | +| traefik | networking | yes | no | total error; bounded backend positive | +| customApi | custom content/security | yes | yes | WID-001/007/008; polling resume fixed | + +## Severity-ranked findings + +### WID-001 — P1 confirmed, pre-existing: Custom API actions lack resource authorization and hardened fetch policy + +- **Location/claim:** `packages/api/src/router/custom-widget/custom-widget-router.ts:57-102,392-451`; “harden Custom API” and action-permission semantics. +- **Evidence/reproduction:** any authenticated account can call `customWidget.all`, learn every definition ID and URL, then call `customWidget.execute({definitionId})`. The mutation verifies neither board nor item nor an action permission. It decrypts administrator-provided secrets, uses `new URL()` only, and `redirect: "follow"`. The PR hardened `widget.customApi.getData`, but not this sibling execution path. +- **Impact/rule:** unauthorized side effects against internal services and authenticated redirect chains; broken object-level authorization. A view-only account can invoke administrator-created controls outside any board context. +- **Correction:** require `{boardId,itemId,definitionId}`; load the item and verify it is `customApi`, belongs to the board, references the definition, and the caller has an explicit execute policy. Until that policy exists, restrict to admin. Share `validateCustomApiUrl`, timeout, redirect rejection, bounded disposal and redacted errors with getData. Compatibility: old direct callers must send item/board context. +- **Tests:** procedure tests for anonymous, unrelated authenticated, view-only, modifier, admin, mismatched item/definition, disabled definition, redirects, credential URL, timeout and secret non-disclosure; E2E action button across roles. + +### WID-002 — P1 confirmed, PR-introduced: manual advanced focus is not an accessible modal + +- **Location:** `advanced-focus/context.tsx:194-209`, `items/item-content.tsx:113-189`. +- **Evidence:** manual mode renders an Overlay plus a `role="region"` Card. There is no FocusTrap, `aria-modal`, background inertness, scroll lock or backdrop dismissal. Only the close button is initially focused; Tab can leave the surface and activate dimmed board controls. +- **Impact/rule:** keyboard/screen-reader users lose the interaction boundary; WCAG 2.1.2, 2.4.3 and ARIA dialog pattern. Iframes/editors make escape especially likely. +- **Correction/Mantine:** render manual mode through `Modal.Root` compound parts (or ModalBase), using its portal, FocusTrap, scroll lock, Escape/return-focus and semantic content. Preserve preview mode as non-modal. Configure nested overlays and `removeScrollProps`; keep a 44px `Modal.CloseButton` and explicit title via VisuallyHidden if needed. +- **Tests:** RTL portal/focus-cycle/return tests; Playwright Tab/Shift+Tab, nested Menu/Select/Popover/editor/iframe, backdrop and every close path; reduced motion and 200% zoom. + +### WID-003 — P1 probable, PR-introduced: fixed advanced surface remains under transformed Gridstack ancestry + +- **Location:** `advanced-focus.module.css:35-42`; Card remains inside the Gridstack item while only the backdrop is portaled. +- **Evidence:** CSS fixed descendants establish their containing block at a transformed ancestor. Gridstack uses transforms for positioned items, so viewport-derived `left/top` can be interpreted relative to the item on affected layouts. A Portal wrapper exists only for Overlay. +- **Impact:** off-screen, clipped or incorrectly scaled focus surface across breakpoints/remounts. +- **Correction:** portal the focused surface itself (naturally achieved by WID-002) and calculate geometry in the same viewport coordinate system. Do not compensate with transform-specific offsets. +- **Tests:** real Gridstack at every layout/viewport, scrolled page, header, dynamic section and resize/remount; assert bounding rect within viewport and finite scale. + +### WID-004 — P1 confirmed, regression-exposed/systemic: query failures still become valid empty/disconnected UI + +- **Location:** 39 of 53 renderer query calls do not read `error`, including bookmarks, weather, calendar, media server, system resources, Coolify, DNS Hole, Tracearr, notifications, Immich, VPN and others listed in the matrix. Query defaults do not set `throwOnError` (`trpc.tsx:69-93`). +- **Evidence:** TanStack Query returns `data=undefined` on total failure. Components default to `[]`, `null`, or empty branches, so the board ErrorBoundary is never reached. Network Controller and Paperless were fixed, proving the pattern; it was not completed. +- **Impact:** operators are told “empty” or “disconnected” when monitoring failed, violating the product’s truthful-state requirement. +- **Correction/Mantine:** introduce a shared `WidgetQueryState` contract distinguishing initial Skeleton, background LoadingOverlay, stale Alert, partial warning, true EmptyState and terminal error. Either explicitly throw only when no usable cached data or render a shared error Alert. Preserve stale data. +- **Tests:** parameterized contract tests for every queried widget: initial, success-empty, total failure, stale+failure, partial failure, refetch, remount/persisted cache and sibling isolation. + +### WID-005 — P1 confirmed, PR-introduced/systemic: integration-provided links bypass a shared URL policy + +- **Location:** examples include `media-missing/component.tsx:291`, `notifications/component.tsx:77`, media requests list/stats, Coolify resource/server links, releases, bookmarks and iframe. Only RSS and custom JSX visibly use explicit protocol sanitizers. +- **Evidence:** dynamic strings are assigned directly to `href`/new-tab controls. Integration APIs and remote feeds are trust boundaries; no renderer-wide `http/https` guard is enforced. +- **Impact:** unsafe schemes, credential-bearing URLs, internal detail disclosure and inconsistent opener behavior. +- **Correction:** add one typed application URL helper returning a sanitized URL/undefined, with allowed schemes per use case, credential stripping/rejection, consistent `noopener noreferrer`, and disabled non-link presentation. Validate again server-side for action endpoints. +- **Tests:** `javascript:`, `data:`, malformed, credential, relative, http/https and missing URLs for each shared link primitive. + +### WID-006 — P2 confirmed, PR-introduced: runtime query/action state mutates untyped refs during render + +- **Location:** `definition.ts:121-162`; runtime registration calls in media missing/transcoding, audio, calendar, Umami, Beszel and Immich; `widget-context-menu.tsx:157-174`. +- **Evidence:** `setWidgetRuntimeQueries` mutates a shared object during render; concurrent/aborted renders can publish uncommitted query identity. Action dispatch casts the definition to `Record` and `any`, then casts its result back. +- **Correction:** make definition generics preserve kind/options/action types end-to-end; replace stringly opaque ref slots with a typed per-item registration API updated in a committed effect (or use `useSyncExternalStore`). Separate query registration from imperative actions. +- **Tests:** StrictMode, interrupted/remount, options change and cleanup; compile-time `tsd`/type assertions rejecting wrong action props. + +### WID-007 — P2 confirmed, regression-exposed: raw backend errors are exposed to users and assistive text + +- **Location:** Custom API UI `component.tsx:561-576`; API `custom-api.ts:79-85`; several partial fallbacks use `error.message`. +- **Impact:** internal hostnames, request paths and implementation details can appear in tooltips/aria labels and logs. +- **Correction/Mantine:** return stable error codes plus redacted public messages; keep detailed causes server-side with correlation IDs. Render localized Alert/Notification details, never raw throwable text. +- **Tests:** errors containing credentials, query strings, private hosts and multiline backend bodies must not reach tRPC payload, DOM, accessibility tree or notifications. + +### WID-008 — P2 confirmed, PR-introduced/regression-exposed: locale handling remains incomplete + +- **Locations:** Beszel chart/tooltip dayjs formats, calendar event `HH:mm`, downloads `YYYY-MM-DD HH:mm`, timetable `HH:mm:ss`, weather day/month/time formats; `_inputs/widget-location-input.tsx:199` hardcodes `Intl.NumberFormat("en")`. +- **Also:** `beszel/_shared/disk-usage.tsx:41`, stats loading aria label, Network Controller `WAN`/`unknown` are English/raw accessible strings. +- **Correction:** centralize app-locale date/time/number/duration/status helpers; load canonical dayjs locale only at the boundary; use localized translation keys for aria/status. NumberFormatter is suitable for simple display but application helpers should own locale. +- **Tests:** at least `en-US`, `fr-FR`, `de-DE`, RTL locale, 12/24-hour preference and long translations, including accessibility names. + +### WID-009 — P2 confirmed: layer and color tokens are inconsistent + +- **Locations:** downloads z-index 1000 and rgba row colors; item title z-index 4; item menu/dynamic menu 10; media server white rgba; Indexer Manager hex colors; DNS Hole rgba palette; Umami hex tick colors. +- **Correction:** define shared semantic layer tokens relative to Mantine CSS z-index variables and semantic status/chart tokens respecting light/dark/forced colors. Do not replace intentional service branding. +- **Tests:** light/dark/forced-colors with nested overlays and tooltip ordering. + +### WID-010 — P2 confirmed: responsive decisions are duplicated and under-tested + +- **Location:** Network Controller Status `component.tsx:44,59-78` (open valid thread); Bookmark `component.tsx:94` recomputation (valid but low-impact thread); similar width/height literals across renderers. +- **Correction:** small typed breakpoint lookup helpers per family, memoized only for genuinely expensive layout. Prefer container-query Grid/SimpleGrid where structural. Do not cargo-cult `useMemo` for a cheap pure calculation; benchmark Bookmark before accepting the review suggestion. +- **Tests:** boundary tables for minimum/typical/wide/short/advanced sizes and 200% zoom. + +### WID-011 — P2 confirmed: PR/docs overstate behavior and validation + +- **Location:** PR body, board/getting-started copy “advanced preview in place,” validation numbers, and unqualified review pass. +- **Correction:** state 53 advanced renderers plus compact-only app; call manual focus an overlay only after WID-002/003; publish exact final-SHA check/test counts and disclose unsupported/live-untested states. + +### WID-012 — P2 confirmed: focused automated coverage is far below the 54-widget/data-state claim + +- **Evidence:** 42 widget-local spec files are helper-heavy; 20+ renderers have no focused spec. Six E2E files exist, but only board interactions and Custom JSX directly exercise this modernization. The earlier Custom JSX login wait failed once and current exact-route wait remains. +- **Correction:** add shared renderer contract harness plus family E2Es. Replace Custom JSX’s exact post-submit URL race with a shared login helper that waits for authenticated session/stable destination, then run it three times in CI. + +### WID-013 — P2 confirmed: open review-thread state no longer matches code + +| Thread | Classification | +| ------------------------------------------- | ------------------------------------------------------------------- | +| iframe complete URL | already fixed/false positive at current line: only host is rendered | +| system resources network gate | already fixed, outdated | +| RSS total error | already fixed: no-data error throws; stale data retained | +| bookmarks memoization | valid P3/P2-low; benchmark first | +| network-controller total error | already fixed, outdated | +| network-controller breakpoint consolidation | valid P2 maintainability | +| Paperless total error | already fixed, outdated | + +Resolve the five stale threads with evidence; address or explicitly disposition the two live suggestions. Re-run review because CodeRabbit was paused during active development. + +## Systemic remediation roadmap + +1. **Security/authorization:** WID-001 and WID-005; add negative-role/protocol tests before merge. +2. **Interaction accessibility:** replace manual focus with portaled Mantine modal semantics (WID-002/003), then test nested widget content. +3. **Truthful data state:** build the shared query-state contract and migrate all 39 ambiguous sites; keep valid stale data and partial results. +4. **Typed interfaces:** replace `widgetStateRef` slot mutation and `any` context dispatch with committed typed registrations. +5. **Widget families:** media/downloads, infrastructure, networking/security, smart-home, analytics/calendar/feed, then Beszel; apply URL, state, locale, responsive and a11y contracts in that order. +6. **Design-system cleanup:** semantic layers/colors and restrained Mantine consolidation. Do not add Schedule or upgrade to 9.5 without a separate dependency/bundle decision. +7. **Docs/PR:** correct renderer count, behavior and exact validation evidence. +8. **Final proof:** frozen install on supported Node/pnpm; format, lint, full typecheck, affected and full unit tests, production and docs builds; Custom JSX three times; complete production-container E2E; desktop/tablet/mobile keyboard/touch/light/dark/reduced-motion/zoom/a11y-tree QA. Push nothing until all refer to one unchanged SHA; close every review thread and obtain required approval. + +## Positive findings + +- Timetable is the strongest security work in the PR: saved resource authorization, private-range rejection, DNS rebinding resistance through pinned lookup, redirect rejection, bounds and schemas are coherently tested. +- Bounded response helpers, Beszel bounded queue/cancellation, and partial-integration settlement materially reduce failure blast radius. +- RSS preserves stale data and distinguishes partial feed failure; Network Controller and Paperless now correctly escalate total failures. +- Item-scoped query matchers and tests address sibling-widget refresh isolation; global polling defaults are restricted to the widget namespace. +- Touch long-press has movement tolerance; focus scale guards prevent Infinity/NaN; reduced-motion and remount cleanup are present. +- Existing Homarr theme, Mantine components and shared UI vocabulary are generally preserved. Most proposed Mantine work is consolidation, not redesign. + +## Validation evidence and uncertainty + +- `MANTINE.md` was fetched from `https://mantine.dev/llms.txt` and passed byte-for-byte `cmp`; `llms-full.txt` stayed in `/tmp`. +- Context7 current Mantine docs were queried for overlay/focus, responsive/data-state/accessibility, charts and Schedule. Static 9.4.1 archives verified exact exports; Cascader, BulletChart and SunburstChart are 9.5-only. +- Local frozen install could not start because host Node/pnpm are below repository engine requirements. No dependencies, source files or machine toolchain were changed. +- Docker API access is denied, so production-container E2E could not be rerun locally. Hosted exact-head Fast gate and Container/E2E are green. +- `agent-browser` is not installed. Temporary `npx` execution was rejected by the environment’s security reviewer, so live exploratory QA/a11y-tree inspection was not performed. +- External integrations were not available; malformed/partial/permission combinations are static-analysis conclusions unless a focused test already exists. +- This review intentionally changes no production code and makes no commit, push, thread resolution or PR edit. + +Finalization must re-query the PR head/checks/threads. If the head differs from the reviewed SHA, this report is a snapshot and the delta requires review before merge. + +**Final refresh (2026-08-03):** head remained `96e92485a5fd45f3648cfac0453e614779c414ba`; all reported hosted checks remained successful; merge state remained `BLOCKED` with no approval decision; unresolved threads remained 7 of 34. The branch did not change during the audit. diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index fa504f82b..66a0f2f35 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,16 +6,22 @@ tags: Homarr has the following keyboard shortcuts: -| Shortcut | Description | -| ----------- | -------------------- | -| `MOD` + `K` | Opens the search bar | -| `MOD` + `J` | Toggles Dark Mode | -| `MOD` + `E` | Toggles Edit Mode | +| Shortcut | Description | +| ----------------------------------------------- | ------------------------------------------ | +| `MOD` + `K` | Opens the search bar | +| `MOD` + `J` | Toggles Dark Mode | +| `MOD` + `E` | Toggles Edit Mode | +| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | +| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | +| `Esc` | Closes an open advanced view or board menu | :::tip Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. +Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal +dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open. + ::: :::note diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 97812b423..3785aac56 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -191,6 +191,11 @@ When entering the edit mode, you'll get some additional buttons to work with: - **Add item**: Click this button to add new elements to your page. - **Exit edit mode**: Click this button to save your changes and exit edit mode. +Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions. +Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board +stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to +keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place. + To continue, click the option "New Item": {"Screenshot diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 145e221ea..359d189e6 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -84,6 +84,13 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. +## Partial integration failures + +Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include +an optional `error` on a failed integration; media-request responses expose a `failedIntegrations` array, and the latest +requests endpoint returns its items in `requests`. When every selected integration fails, the query returns an error instead +of a partial response. + ### Saving dashboard layouts The dashboard editor saves item positions and sections as one board update. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fb9467ef..0f8003f85 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -38,7 +38,8 @@ Underscores and hyphen-minus are allowed. The column count defines how many fixed-width cells are available in each row. One cell contains a `200 × 200` logical-pixel tile. -Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small. +Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling. +The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge. ### Public @@ -100,7 +101,7 @@ A `2 × 1` tile therefore contains two fixed cell widths plus the gap between th Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller. When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size. On larger canvases, the complete board grows together like browser zoom. -The scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size. +The complete canvas always fits the available width and never adds horizontal board scrolling. The position and size of every item are independent in each responsive layout. Moving or resizing an item in one layout does not change any other layout. @@ -108,8 +109,8 @@ When adding a new layout, its initial positions and sizes are generated from the New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns. Both are placed in the first available position. -The current board edge is a hard drag boundary, so moving a tile outside the canvas cancels that placement instead of creating empty rows. -Adding content can still create the rows required for the new tile. +The canvas is at least one window tall, even when it is empty. +Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward. Nested Containers keep their own resizable bounds. #### Name @@ -133,8 +134,8 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f Enabling or widening a rail reflows canvas items into the remaining columns. Disabling a rail moves its contents back into the next available canvas positions. -Rails remain visible while the page scrolls. -Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode. +Rails fill the available window height and remain fixed while the page scrolls. +Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary. #### Breakpoint @@ -247,6 +248,20 @@ The styling will only be applied on the respective board page. This will disable the status check for apps. +#### Work directly from the board + +Widgets expose their routine controls without requiring edit mode: + +- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets + remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view + open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead. +- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run + supported actions. Available controls follow your board and integration permissions. +- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board + section and press `Shift` + `F10` for the keyboard equivalent. + +Widget and empty-space context menus follow the **Enable right click on widgets** preference in your user settings. + ### Access control With the access control, you can permit a single user or a group to your board. diff --git a/apps/docs/docs/widgets/anchor-note/index.mdx b/apps/docs/docs/widgets/anchor-note/index.mdx index db925e95a..522252a1a 100644 --- a/apps/docs/docs/widgets/anchor-note/index.mdx +++ b/apps/docs/docs/widgets/anchor-note/index.mdx @@ -4,6 +4,8 @@ description: "Display and edit a selected note from Anchor" 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"; @@ -33,10 +35,12 @@ import anchorNoteWidgetScreenshot from "./img/widget.png"; ### Interactions -- Click **Edit** to start editing the selected note. +- Hover the widget, focus within it, or use a touch device to reveal **Edit**, then select it to start editing the note. - Click **Save** to persist changes to Anchor. - Click **Cancel** to discard unsaved changes. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/app/index.mdx b/apps/docs/docs/widgets/app/index.mdx index 77d8ec878..dd5cef590 100644 --- a/apps/docs/docs/widgets/app/index.mdx +++ b/apps/docs/docs/widgets/app/index.mdx @@ -25,6 +25,11 @@ import piHoleApp from "./img/pi-hole-ping-icon.png"; app pi-hole with-status-check status-check-icon app jellyseerr with-description-tooltip + +### Interactions + +App tiles stay compact and open their configured destination directly. Right-click a tile to inspect its status or change its settings when the board and your preferences allow it. + ### Adding the widget diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.mdx b/apps/docs/docs/widgets/archive-team-warrior/index.mdx new file mode 100644 index 000000000..5eceed2c2 --- /dev/null +++ b/apps/docs/docs/widgets/archive-team-warrior/index.mdx @@ -0,0 +1,38 @@ +--- +title: "ArchiveTeam Warrior" +description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs." +hide_title: true +--- + +import { archiveTeamWarriorIntegration } from "@site/docs/integrations/archiveteam-warrior"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { archiveTeamWarriorWidget } from "."; + + + +Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list and exact update time. + +### Supported integration + + + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.ts b/apps/docs/docs/widgets/archive-team-warrior/index.ts new file mode 100644 index 000000000..5a66b9fbb --- /dev/null +++ b/apps/docs/docs/widgets/archive-team-warrior/index.ts @@ -0,0 +1,19 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconArchive } from "@tabler/icons-react"; + +export const archiveTeamWarriorWidget: WidgetDefinition = { + icon: IconArchive, + name: "ArchiveTeam Warrior", + description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs.", + path: "../../widgets/archive-team-warrior", + configuration: { + items: [ + { + name: "Show broadcast message", + description: "Show the message broadcast by the selected Warrior project when space allows.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/audio-stats/index.mdx b/apps/docs/docs/widgets/audio-stats/index.mdx index 88fa87f79..f79a588c3 100644 --- a/apps/docs/docs/widgets/audio-stats/index.mdx +++ b/apps/docs/docs/widgets/audio-stats/index.mdx @@ -4,6 +4,8 @@ description: "Displays media and listening statistics from Navidrome or Audioboo 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,6 +38,8 @@ Only options relevant to your linked integration are shown in the configuration ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/bazarr/index.mdx b/apps/docs/docs/widgets/bazarr/index.mdx index 6a87a4e0f..cb35f6c71 100644 --- a/apps/docs/docs/widgets/bazarr/index.mdx +++ b/apps/docs/docs/widgets/bazarr/index.mdx @@ -4,6 +4,8 @@ description: "Displays missing subtitle counts and health indicators from your B 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"; @@ -34,6 +36,8 @@ import widgetOverview from "./img/widget-overview.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/beszel-alerts/index.mdx b/apps/docs/docs/widgets/beszel-alerts/index.mdx index 4a669f3f4..d1abe2d2e 100644 --- a/apps/docs/docs/widgets/beszel-alerts/index.mdx +++ b/apps/docs/docs/widgets/beszel-alerts/index.mdx @@ -4,6 +4,8 @@ description: "View Beszel alert configurations and history with triggered/ok sta 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"; @@ -32,6 +34,8 @@ This widget displays your Beszel alert configurations separated by status (trigg ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.mdx b/apps/docs/docs/widgets/beszel-system-stats/index.mdx index e29d083ba..6ac158aac 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-stats/index.mdx @@ -4,6 +4,8 @@ description: "Time-series charts for CPU, memory, disk, network, and Docker cont 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"; @@ -32,6 +34,8 @@ This widget displays time-series charts for a selected Beszel system, including ]} /> + + ### 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 9063727fa..b09edf530 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -15,7 +15,7 @@ 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. -Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. +Select a system row to open the complete statistics view. ### Interactions diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index ed8be9bb6..b61f8f072 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -4,6 +4,8 @@ description: "Displays multiple app links" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -13,18 +15,19 @@ import { bookmarksWidget } from "."; The bookmarks widget allows you to display multiple app links in a single widget. You can add links to your favorite applications, websites, or any other resources you want quick access to. +Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards. ### Screenshots import gridIconsOnly from "./img/grid-icons-only.png"; import gridWithTitle from "./img/grid-with-title.png"; import verticalListAll from "./img/vertical-list-all.png"; -import vericalListSimple from "./img/vertical-list-simple.png"; +import verticalListSimple from "./img/vertical-list-simple.png";
bookmarks grid icons-only - bookmarks vertical-list no-domain + bookmarks vertical-list no-domain
bookmarks grid with-title @@ -32,6 +35,8 @@ import vericalListSimple from "./img/vertical-list-simple.png";
+ + ### Adding the widget diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx index f84a0daae..b3ff3ec87 100644 --- a/apps/docs/docs/widgets/calendar/index.mdx +++ b/apps/docs/docs/widgets/calendar/index.mdx @@ -4,6 +4,8 @@ description: "Display events from your integrations in a calendar view within 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"; @@ -70,8 +72,10 @@ import withoutIntegrations from "./img/without-integrations.png"; ### Interactions - Hover over a marked date to preview its events in a popover. -- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away). -- Click the date again or click outside to close the pinned popover. +- The advanced view replaces the month grid with a grouped event list. Use its previous and next controls to change months. +- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/clock/index.mdx b/apps/docs/docs/widgets/clock/index.mdx index 0c41d7ae7..157e79d0b 100644 --- a/apps/docs/docs/widgets/clock/index.mdx +++ b/apps/docs/docs/widgets/clock/index.mdx @@ -4,6 +4,8 @@ description: "Displays the current date and time." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -25,6 +27,8 @@ import tokyoClock from "./img/tokyo-12-hour.png"; clock format-12-hours timezone-tokyo + + ### Adding the widget diff --git a/apps/docs/docs/widgets/coolify/index.mdx b/apps/docs/docs/widgets/coolify/index.mdx index 807ad502d..2feec3a94 100644 --- a/apps/docs/docs/widgets/coolify/index.mdx +++ b/apps/docs/docs/widgets/coolify/index.mdx @@ -4,6 +4,8 @@ description: "Overview of your Coolify instance with servers, applications, and 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"; @@ -25,6 +27,8 @@ The Coolify widget displays a compact overview of your Coolify instance. It can ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/custom-api/index.mdx b/apps/docs/docs/widgets/custom-api/index.mdx index 62d557b9a..4217f2369 100644 --- a/apps/docs/docs/widgets/custom-api/index.mdx +++ b/apps/docs/docs/widgets/custom-api/index.mdx @@ -1,24 +1,26 @@ --- title: "Custom API" -description: "The Custom API widget shows a Custom JSX v2 definition with server-side API requests, widget options, and actions." +description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates." 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 { customApiWidget } from "."; -The Custom API widget shows a Custom JSX v2 definition. Create the definition in [Management → Custom Widgets](/docs/management/custom-widgets). - A definition contains API sources, named requests, a widget options schema, and one safe JSX template. Temporary inputs exist only while the tile is mounted. Homarr encrypts source credentials separately. Homarr does not send these credentials to the browser. Homarr does not include them in an export. -### Add the widget + + +### Adding the widget @@ -29,9 +31,8 @@ Select a refresh interval. The board item stores the definition ID and the valid Credentials remain on the server. :::note - -Enable the definition before you use it. The definition must also be available to the board. - +Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker. +Action buttons can only be executed by administrators. ::: ### Permissions and actions diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx index 74c8cc88b..9b9d1f26f 100644 --- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx @@ -1,9 +1,11 @@ --- title: "DNS Hole Controls" -description: "Control PiHole or AdGuard from your dashboard." +description: "Control Pi-hole, AdGuard Home, or Technitium DNS from your dashboard." 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"; @@ -18,7 +20,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns -This widget allows you to enable or disable your DNS Hole. +This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height. ### Screenshots @@ -46,9 +48,12 @@ import smallSize from "./img/small.png"; ### Interactions -- Enable / Disable one dns-hole by clicking on the badge. -- Disable one dns-hole in x seconds by clicking on the clock icon. -- Do all of the above for all dns-holes by using the top button row. +- Enable or disable one DNS service by clicking its status badge. +- Temporarily disable one service with the clock action. +- Apply an action to all available services from the top action row. Homarr keeps successful results visible and reports how many sources failed. +- Interaction controls only appear when the current user has permission for that integration. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/dns-hole-summary/index.mdx b/apps/docs/docs/widgets/dns-hole-summary/index.mdx index 99fd69dbd..1e7b966b9 100644 --- a/apps/docs/docs/widgets/dns-hole-summary/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-summary/index.mdx @@ -4,6 +4,8 @@ description: "Displays the summary of your DNS Hole." 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"; @@ -15,7 +17,7 @@ import { dnsHoleSummaryWidget } from "."; -This widget allows you to display statistics about your DNS Hole like total queries, blocked queries, and more. +This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source. ### Screenshots @@ -50,6 +52,8 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index cb1aee0a2..5d4cca498 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -4,6 +4,8 @@ description: "Allows you to view and manage your Downloads from both Torrent and hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { aria2Integration } from "@site/docs/integrations/aria2"; import { delugeIntegration } from "@site/docs/integrations/deluge"; import { nzbgetIntegration } from "@site/docs/integrations/nzbget"; @@ -95,6 +97,8 @@ import contextMenuImg from "./img/context-menu.png"; - **Filter** by integration or status using the filter button in the footer - **Sort** by clicking column headers (with persistent sort direction) + + ### Adding the widget diff --git a/apps/docs/docs/widgets/firewall/index.mdx b/apps/docs/docs/widgets/firewall/index.mdx index 425ab8854..aec2d89d9 100644 --- a/apps/docs/docs/widgets/firewall/index.mdx +++ b/apps/docs/docs/widgets/firewall/index.mdx @@ -4,6 +4,8 @@ description: "Displays a summary of firewalls." 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 { WidgetIntegrations } from "@site/src/components/widgets/integrations"; @@ -28,6 +30,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/health-monitoring/index.mdx b/apps/docs/docs/widgets/health-monitoring/index.mdx index 603207451..bc50cf2c7 100644 --- a/apps/docs/docs/widgets/health-monitoring/index.mdx +++ b/apps/docs/docs/widgets/health-monitoring/index.mdx @@ -4,6 +4,8 @@ description: "Displays information showing the health and status of your system( 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"; @@ -62,6 +64,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 2c183368f..5f410a7f3 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -4,6 +4,8 @@ description: "Embed any content from the internet. Some websites may restrict ac hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -26,6 +28,8 @@ import youtubeStream from "./img/nasa-iss-stream.png"; iframe youtube-iss-stream + + ### Adding the widget @@ -35,13 +39,14 @@ import youtubeStream from "./img/nasa-iss-stream.png"; Although you can integrate many websites, some may not work. This is because the administrators of those sites disallow iframes for security reasons, as users could easily be tricked -by embeding the original site on a malicious page. +by embedding the original site on a malicious page. Some proxies disallow iframes by default for this reason. -You can use https://iframetest.com to check, if the site is allowing iframes. +You can use https://iframetest.com to check whether the site allows iframes. If you manage all of your own applications, you can usually change that by configuring -the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes. +the `Content-Security-Policy` header and its `frame-ancestors` directive. The obsolete +`X-Frame-Options: ALLOW-FROM` directive is not supported by modern browsers. For example, to allow your site to be embedded in iframes from specific domains: @@ -49,14 +54,12 @@ For example, to allow your site to be embedded in iframes from specific domains: ```apache Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com" -Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com" ``` **Nginx:** ```nginx add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"; -add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com"; ``` ### Example URLs @@ -82,11 +85,11 @@ To create a transparent iframe like below you can use the following html code an ![transparent iframe](./img/transparent-iframe.png) -### Third party content +### Third-party content :::warning -The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository. +The content below is an overview of third-party projects. All support requests/problems must be redirected to the appropriate repository. We cannot guarantee the functionality nor security of these tools. ::: diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.mdx b/apps/docs/docs/widgets/immich-album-carousel/index.mdx index 89675ca24..4a0cc6eef 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.mdx +++ b/apps/docs/docs/widgets/immich-album-carousel/index.mdx @@ -4,6 +4,8 @@ description: "Shows a slideshow of pictures from an Immich album or your full li hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { immichIntegration } from "../../integrations/immich"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -29,6 +31,10 @@ import screen from "./img/screen.png"; ]} /> + + +Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view groups previous, pause or resume, and next together and adds a thumbnail strip for quick navigation. + ### Adding the widget diff --git a/apps/docs/docs/widgets/immich-server-stats/index.mdx b/apps/docs/docs/widgets/immich-server-stats/index.mdx index 4fcb5d4cc..e2c967365 100644 --- a/apps/docs/docs/widgets/immich-server-stats/index.mdx +++ b/apps/docs/docs/widgets/immich-server-stats/index.mdx @@ -4,6 +4,8 @@ description: "Information about your Immich instance" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { immichIntegration } from "../../integrations/immich"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -29,6 +31,10 @@ import screen from "./img/screen.png"; ]} /> +In advanced mode, the widget also ranks the largest albums by asset count. To keep the dashboard response lightweight, it shows up to 50 albums. + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/indexer-manager/index.mdx b/apps/docs/docs/widgets/indexer-manager/index.mdx index aed26828b..2deeecd66 100644 --- a/apps/docs/docs/widgets/indexer-manager/index.mdx +++ b/apps/docs/docs/widgets/indexer-manager/index.mdx @@ -4,6 +4,8 @@ description: "View the status of your indexers." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; @@ -37,6 +39,8 @@ import { prowlarrIntegration } from "@site/docs/integrations/prowlarr"; - Test all indexers by clicking on the test button. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-missing/index.mdx b/apps/docs/docs/widgets/media-missing/index.mdx index 06799ae2e..643da6a3c 100644 --- a/apps/docs/docs/widgets/media-missing/index.mdx +++ b/apps/docs/docs/widgets/media-missing/index.mdx @@ -4,6 +4,8 @@ description: "Track missing media and active downloads from Radarr and Sonarr" 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"; @@ -58,6 +60,8 @@ The widget adapts to its container size, so it looks good at any grid size: Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-releases/index.mdx b/apps/docs/docs/widgets/media-releases/index.mdx index 71af2b04a..14695f0fe 100644 --- a/apps/docs/docs/widgets/media-releases/index.mdx +++ b/apps/docs/docs/widgets/media-releases/index.mdx @@ -1,9 +1,11 @@ --- title: "Media releases" -description: "Display newly added medias or upcoming releases from different integrations" +description: "Display newly added media or upcoming releases from different integrations" 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"; @@ -43,6 +45,8 @@ import poster from "./img/poster.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 1c491a051..840315831 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -4,6 +4,8 @@ description: "See a list of all media requests from your integration" 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"; @@ -44,6 +46,9 @@ import mediaRequestList from "./img/list.png"; - Approve / decline a request by clicking on the thumbs up or down icon. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. +- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx index fc7d7f465..3adfcbffb 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -4,6 +4,8 @@ description: "Statistics about your media requests" 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,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr"; -Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections. +Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated. ### Supported Integrations @@ -41,6 +43,9 @@ import mediaRequestStats from "./img/stats.png"; ### Interactions - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. +- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index d759ff6f7..080164a92 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -4,6 +4,8 @@ description: "Show the current streams on your media servers" 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"; @@ -19,8 +21,12 @@ import { plexIntegration } from "@site/docs/integrations/plex"; -The media server widget enables you to watch and view current active sessions over all your media servers. -It also displays useful information, that you can use when providing support to users or debugging a problem. +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. +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 @@ -48,6 +54,7 @@ import mediaTable from "./img/table.png"; }, ]} /> + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-transcoding/index.mdx b/apps/docs/docs/widgets/media-transcoding/index.mdx index 62dd00af3..256b8edd6 100644 --- a/apps/docs/docs/widgets/media-transcoding/index.mdx +++ b/apps/docs/docs/widgets/media-transcoding/index.mdx @@ -4,6 +4,8 @@ description: "Statistics, current queue and worker status of your media transcod 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"; @@ -41,6 +43,8 @@ import statisticsCard from "./img/statistics.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx index d96a2f190..4df364116 100644 --- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx +++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx @@ -4,6 +4,8 @@ description: "Displays the status of a Minecraft server." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -24,9 +26,11 @@ import bedrockItem from "./img/bedrock.png";
minecraft-server-status java-edition - minecraft-server-status bedrock-editon + minecraft-server-status bedrock-edition
+ + ### Adding the widget @@ -38,4 +42,4 @@ import bedrockItem from "./img/bedrock.png"; ### Data source We use the Minecraft Server Status API to fetch the server status. -If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us` +If you need to whitelist the API, use the following URL: `https://api.mcsrvstat.us`. diff --git a/apps/docs/docs/widgets/network-controller-status/index.mdx b/apps/docs/docs/widgets/network-controller-status/index.mdx index 29a5bbb8e..ad0feaf9d 100644 --- a/apps/docs/docs/widgets/network-controller-status/index.mdx +++ b/apps/docs/docs/widgets/network-controller-status/index.mdx @@ -4,6 +4,8 @@ description: "Display connected devices on a network" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -36,6 +38,8 @@ import wiredCard from "./img/wired.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/network-controller-summary/index.mdx b/apps/docs/docs/widgets/network-controller-summary/index.mdx index c7c2f4d4d..a71c9233a 100644 --- a/apps/docs/docs/widgets/network-controller-summary/index.mdx +++ b/apps/docs/docs/widgets/network-controller-summary/index.mdx @@ -4,6 +4,8 @@ description: "Displays the summary of a Network Controller" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -16,7 +18,7 @@ import { networkControllerSummaryWidget } from "."; -Shows information whether your WAN is connected, Wi-Fi is active and several other information. +Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view. ### Screenshots @@ -34,6 +36,8 @@ import summaryCard from "./img/summary.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx index 393137ed2..887908372 100644 --- a/apps/docs/docs/widgets/notebook/index.mdx +++ b/apps/docs/docs/widgets/notebook/index.mdx @@ -4,6 +4,8 @@ description: "A simple notebook widget that supports markdown." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -24,6 +26,8 @@ import editOn from "./img/edit.png"; notebook edit-on + + ### Adding the widget @@ -34,7 +38,7 @@ import editOn from "./img/edit.png"; ### Editing -When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right. +When your permissions allow it, hover the widget, focus within it, or use a touch device to reveal the edit button in the top right. You can also double-click the document to edit it directly. ![](./img/notebook-enter-editing.webp) diff --git a/apps/docs/docs/widgets/notifications/index.mdx b/apps/docs/docs/widgets/notifications/index.mdx index 3ca096e3c..89771e35b 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -4,6 +4,8 @@ description: "Display notification history from an integration" 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"; @@ -32,6 +34,8 @@ import notificationsScreenshot from "./img/notifications.png"; items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/paperless-ngx/index.mdx b/apps/docs/docs/widgets/paperless-ngx/index.mdx index 140d667c0..a2ef00ebe 100644 --- a/apps/docs/docs/widgets/paperless-ngx/index.mdx +++ b/apps/docs/docs/widgets/paperless-ngx/index.mdx @@ -4,6 +4,8 @@ description: "Displays document management statistics including inbox ratio, doc 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"; @@ -26,6 +28,8 @@ This widget displays statistics from your Paperless-ngx instance, including an i ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx index cde2a0da5..90bb6754c 100644 --- a/apps/docs/docs/widgets/patchmon/index.mdx +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -4,6 +4,8 @@ description: "Displays host patch statistics from PatchMon including update and 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"; @@ -81,6 +83,8 @@ When the widget has enough space, a "last updated" timestamp appears at the bott ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index faf386a13..38134344a 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -4,6 +4,8 @@ description: "Displays a list of the current version of the given repositories w hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -33,6 +35,8 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`. Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/rss-feed/index.mdx b/apps/docs/docs/widgets/rss-feed/index.mdx index d4e0b2d74..6fe5325de 100644 --- a/apps/docs/docs/widgets/rss-feed/index.mdx +++ b/apps/docs/docs/widgets/rss-feed/index.mdx @@ -4,6 +4,8 @@ description: "Monitor and display one or more generic RSS, ATOM or JSON feeds." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -19,6 +21,8 @@ import screenshot from "./img/screenshot.png"; rss-feed noted-lol + + ### Adding the widget @@ -27,6 +31,8 @@ import screenshot from "./img/screenshot.png"; +If one configured feed fails, the widget keeps entries from healthy feeds visible and shows a warning. If a refresh fails after entries have loaded, the previous entries stay visible with a stale-data warning. + ### Examples Here's a small collection of examples, that you can use for testing or actual use (in no particular order): diff --git a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx index f01e4f0df..9cd6d19a8 100644 --- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx +++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx @@ -4,6 +4,8 @@ description: "Display the state of an entity and toggle it optionally" 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"; @@ -43,6 +45,9 @@ import temperature from "./img/temperature.png"; ### Interactions - Toggle entity state by clicking on the entity (only when clickable enabled). +- If Homarr cannot refresh an entity, the last known state stays visible but toggling is disabled until the connection recovers. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx index f31a79b93..f0118799a 100644 --- a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx +++ b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx @@ -4,6 +4,8 @@ description: "Trigger an automation with one click" 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"; @@ -47,6 +49,8 @@ import withDisplayname from "./img/with-displayname.png"; - Trigger the automation by clicking on the widget. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx index 4b0760e0a..c6c0bd992 100644 --- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx +++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx @@ -4,6 +4,8 @@ description: "Displays speed test results from your Speedtest Tracker instance." 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"; @@ -32,6 +34,8 @@ import screenshot from "./img/screenshot.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/stock-price/index.mdx b/apps/docs/docs/widgets/stock-price/index.mdx index dcd091be7..a95ed13bf 100644 --- a/apps/docs/docs/widgets/stock-price/index.mdx +++ b/apps/docs/docs/widgets/stock-price/index.mdx @@ -4,6 +4,8 @@ description: "Displays the current stock price of a company." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -12,6 +14,7 @@ import { stockPriceWidget } from "."; Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard. +The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them. ### Screenshots @@ -31,6 +34,8 @@ import nvidia from "./img/nvidia.png"; stock-price nvidia + + ### Adding the widget diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx index b3be87e1d..d533fa025 100644 --- a/apps/docs/docs/widgets/system-disks/index.mdx +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -4,6 +4,8 @@ description: "Disk usage of your system" 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"; @@ -42,6 +44,8 @@ This widget displays filesystem and SMART disk usage for supported server and NA ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/system-resources/index.mdx b/apps/docs/docs/widgets/system-resources/index.mdx index 16ad19750..cdc258f4c 100644 --- a/apps/docs/docs/widgets/system-resources/index.mdx +++ b/apps/docs/docs/widgets/system-resources/index.mdx @@ -4,6 +4,8 @@ description: "Displays CPU, RAM and network of your host in real time" 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"; @@ -65,6 +67,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx index bea69cfb4..1aede99cd 100644 --- a/apps/docs/docs/widgets/timetable/index.mdx +++ b/apps/docs/docs/widgets/timetable/index.mdx @@ -4,6 +4,8 @@ description: "Displays departure times for a station." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -23,6 +25,8 @@ import streetDepartures from "./img/street-departures.png"; timetable-widget-bus-and-tram-departures + + ### Adding the widget @@ -30,3 +34,7 @@ import streetDepartures from "./img/street-departures.png"; ### Configuration + +For security, all custom base URLs must resolve to a public IP address whenever Homarr uses them. Private and local hosts are rejected. + +If a timetable refresh fails after departures have loaded, the widget keeps the previous departures visible and marks them as stale. The advanced view also shows their last update time. diff --git a/apps/docs/docs/widgets/timetable/index.ts b/apps/docs/docs/widgets/timetable/index.ts index 1cefb6bcd..32b9e2bcf 100644 --- a/apps/docs/docs/widgets/timetable/index.ts +++ b/apps/docs/docs/widgets/timetable/index.ts @@ -11,7 +11,7 @@ export const timetableWidget: WidgetDefinition = { { name: "Search.ch URL", description: "Base URL for the Search.ch timetable API.", - values: "Any valid URL.", + values: "An HTTP(S) URL that resolves to a public IP address.", defaultValue: "https://search.ch", }, { diff --git a/apps/docs/docs/widgets/tracearr/index.mdx b/apps/docs/docs/widgets/tracearr/index.mdx index 797f83a3e..ff02699ca 100644 --- a/apps/docs/docs/widgets/tracearr/index.mdx +++ b/apps/docs/docs/widgets/tracearr/index.mdx @@ -4,6 +4,8 @@ description: "Monitor media server streams, user activity, and policy violations hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -34,6 +36,8 @@ import streamsAndStats from "./img/streams-and-stats.png"; tracearr-streams-and-stats + + ### Adding the widget diff --git a/apps/docs/docs/widgets/traefik/index.mdx b/apps/docs/docs/widgets/traefik/index.mdx index dc9f6a6c4..dfc2f9247 100644 --- a/apps/docs/docs/widgets/traefik/index.mdx +++ b/apps/docs/docs/widgets/traefik/index.mdx @@ -4,6 +4,8 @@ description: "Displays Traefik router, service, middleware, and entry point coun 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"; @@ -26,6 +28,8 @@ This widget displays a compact overview of your Traefik instance, including tota ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/umami/index.mdx b/apps/docs/docs/widgets/umami/index.mdx index b208a3311..6f2c2bb7f 100644 --- a/apps/docs/docs/widgets/umami/index.mdx +++ b/apps/docs/docs/widgets/umami/index.mdx @@ -4,6 +4,8 @@ description: "Display visitor stats from your Umami analytics instance" 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"; @@ -25,6 +27,8 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/ups/index.mdx b/apps/docs/docs/widgets/ups/index.mdx index 3e57ee57b..8a3d7037e 100644 --- a/apps/docs/docs/widgets/ups/index.mdx +++ b/apps/docs/docs/widgets/ups/index.mdx @@ -4,6 +4,8 @@ description: "Monitor the status of your UPS devices through a NUT server." 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"; @@ -41,6 +43,8 @@ The widget adapts to the size of the tile, so it stays readable from a full dash ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/uptime-kuma/index.mdx b/apps/docs/docs/widgets/uptime-kuma/index.mdx index 0a7e649e8..6c5eefa16 100644 --- a/apps/docs/docs/widgets/uptime-kuma/index.mdx +++ b/apps/docs/docs/widgets/uptime-kuma/index.mdx @@ -4,6 +4,8 @@ description: "Displays monitor uptime statistics, average uptime percentage, and 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"; @@ -26,6 +28,8 @@ This widget displays uptime statistics from your Uptime Kuma status page, includ ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/video/index.mdx b/apps/docs/docs/widgets/video/index.mdx index e5225a022..fbf41c4e3 100644 --- a/apps/docs/docs/widgets/video/index.mdx +++ b/apps/docs/docs/widgets/video/index.mdx @@ -4,6 +4,8 @@ description: "Embed a video stream or video from a camera or a website." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -25,6 +27,8 @@ http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp ![video big-buck-bunny](./img/bunny.png) + + ### Adding the widget diff --git a/apps/docs/docs/widgets/video/index.tsx b/apps/docs/docs/widgets/video/index.tsx index e0df87ce0..7d57a27ad 100644 --- a/apps/docs/docs/widgets/video/index.tsx +++ b/apps/docs/docs/widgets/video/index.tsx @@ -5,7 +5,7 @@ export const videoWidget: WidgetDefinition = { icon: IconDeviceCctv, name: "Video Stream", description: "Embed a video stream or video from a camera or a website.", - path: "../../widgets/stock-price", + path: "../../widgets/video", configuration: { items: [ { diff --git a/apps/docs/docs/widgets/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx index 0c774a7b0..0e23619e5 100644 --- a/apps/docs/docs/widgets/vpn/index.mdx +++ b/apps/docs/docs/widgets/vpn/index.mdx @@ -4,6 +4,8 @@ description: "Monitor the status of your VPN integrations." 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 { WidgetIntegrations } from "@site/src/components/widgets/integrations"; @@ -36,6 +38,8 @@ import singleIntegrationImage from "./img/single.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index 05b918785..f36dc68ea 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -4,6 +4,8 @@ description: "Displays the current weather information of a set location." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -12,6 +14,9 @@ import { weatherWidget } from "."; The Weather Widget uses your location to display the current, highest and lowest temperature. +In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch. +The advanced view always combines current conditions with the full seven-day forecast, including humidity, wind, +gusts, sunrise and sunset. Both views reuse the same cached Open Meteo response. ### Screenshots @@ -23,6 +28,8 @@ import currentWeather from "./img/current.png"; weather current + + ### Adding the widget diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx new file mode 100644 index 000000000..c75102f92 --- /dev/null +++ b/apps/docs/src/components/widgets/interactions.tsx @@ -0,0 +1,62 @@ +import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react"; + +const advancedInteraction = { + icon: IconArrowsMaximize, + title: "Advanced view", + description: + "Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.", +} as const; + +const standardInteractions = [ + { + icon: IconMouse2, + title: "Widget actions", + description: + "Right-click for live query status, refresh, settings, quick options, and actions supported by the widget.", + }, + { + icon: IconPlus, + title: "Place it precisely", + description: + "With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.", + }, + { + icon: IconLayoutGrid, + title: "Adaptive detail", + description: + "Resize a widget to tune its density. Compact views keep primary status visible, then reveal details and controls as space or intent allows.", + }, +] as const; + +export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => { + const interactions = advancedView ? [advancedInteraction, ...standardInteractions] : standardInteractions; + + return ( +
+ + Power-user controls + Show shortcuts + + Hide shortcuts + + +
+ {interactions.map(({ icon: Icon, title, description }) => ( +
+ +
{title}
+

{description}

+
+ ))} +
+ {advancedView && ( +

+ Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. On + touch devices, use the widget menu; compact-only widgets adapt in place instead. +

+ )} +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 158ad16ae..8514e86d9 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -9,14 +9,48 @@ } .gutter { - position: relative; - top: 0; + --board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1)); + --board-gutter-viewport-height: calc( + (100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1) + ); + + position: sticky; + top: var(--board-gutter-sticky-top); align-self: start; + box-sizing: border-box; + height: var(--board-gutter-viewport-height); padding-top: 24px; margin-top: -24px; - max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md)); + overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; - transform: translateY(var(--board-gutter-sticky-offset, 0px)); +} + +.gutter[data-board-editing="true"] { + outline: 1px dashed var(--mantine-color-default-border); + outline-offset: -1px; + border-radius: var(--mantine-radius-md); +} + +.gutter[data-board-editing="true"]::before { + position: sticky; + top: 0; + inset-inline-start: 0; + z-index: 2; + display: block; + width: fit-content; + height: 24px; + padding-inline: var(--mantine-spacing-xs); + margin-top: -24px; + overflow: hidden; + font-size: var(--mantine-font-size-xs); + line-height: 24px; + color: var(--mantine-color-dimmed); + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + content: attr(aria-label); + background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); + border-radius: var(--mantine-radius-sm); } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 689cb95ee..026c6d5c7 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,11 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; import { Box } from "@mantine/core"; import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; import { useScopedI18n } from "@homarr/translation/client"; +import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, @@ -23,66 +24,12 @@ import classes from "./_client.module.css"; const APP_SHELL_INLINE_PADDING = 32; -const useFixedBoardGutters = () => { - const [columns, setColumns] = useState(null); - - useEffect(() => { - if (!columns) return; - - let frame = 0; - const update = () => { - frame = 0; - const columnsRect = columns.getBoundingClientRect(); - const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0; - const stickyTop = Math.max(0, headerBottom + 16); - - for (const gutter of columns.querySelectorAll("[data-board-gutter]")) { - const rect = gutter.getBoundingClientRect(); - if (gutter.offsetWidth <= 0) continue; - - const effectiveScale = rect.width / gutter.offsetWidth; - if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue; - - const previousOffset = Number(gutter.dataset.stickyOffset ?? 0); - const uncorrectedTop = rect.top - previousOffset * effectiveScale; - const maximumTop = columnsRect.bottom - rect.height; - const targetTop = Math.min(stickyTop, maximumTop); - const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale; - - gutter.dataset.stickyOffset = String(nextOffset); - gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`); - } - }; - const scheduleUpdate = () => { - if (frame !== 0) return; - frame = window.requestAnimationFrame(update); - }; - - const resizeObserver = new ResizeObserver(scheduleUpdate); - resizeObserver.observe(columns); - document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true }); - window.addEventListener("scroll", scheduleUpdate, { passive: true }); - window.addEventListener("resize", scheduleUpdate, { passive: true }); - scheduleUpdate(); - - return () => { - window.cancelAnimationFrame(frame); - resizeObserver.disconnect(); - document.removeEventListener("scroll", scheduleUpdate, { capture: true }); - window.removeEventListener("scroll", scheduleUpdate); - window.removeEventListener("resize", scheduleUpdate); - }; - }, [columns]); - - return setColumns; -}; - export const ClientBoard = () => { const board = useRequiredBoard(); const t = useScopedI18n("board.landmark"); + const [isEditMode] = useEditMode(); const currentLayoutId = useCurrentLayout(); const initialViewportWidth = useInitialViewportWidth(); - const columnsRef = useFixedBoardGutters(); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0); if (!currentLayout) throw new Error("Expected the board to contain a layout"); @@ -110,68 +57,68 @@ export const ClientBoard = () => { .join(" "); return ( - - - - - - - -
- {leftColumnCount > 0 && leftSection && ( - - )} + + + + + + + + +
+ {leftColumnCount > 0 && leftSection && ( + + )} -
- -
- - {rightColumnCount > 0 && rightSection && ( - - )} -
-
-
-
-
-
+ + + {rightColumnCount > 0 && rightSection && ( + + )} +
+
+
+
+
+
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index ec98cf5f0..6914d69f0 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -108,7 +108,7 @@ export const createBoardLayout = ({ actions={headerActions} hasNavigation={false} /> - {children} + {children} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index 65269f673..86e5a2231 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -47,7 +47,6 @@ interface CustomWidgetFormProps { initialValues?: Partial; definitionId?: string; } - const sectionLinks = [ ["general", "section.general", IconSettings], ["sources", "section.sources", IconApi], 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 new file mode 100644 index 000000000..87cd59414 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -0,0 +1,212 @@ +.backdrop { + pointer-events: none; + animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.manualBackdrop { + pointer-events: auto; +} + +.surface { + position: fixed; + z-index: 1; + pointer-events: auto; + overflow: hidden; + background: var(--mantine-color-body); + box-shadow: var(--mantine-shadow-md); + overscroll-behavior: contain; + animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1); + animation-fill-mode: backwards; + animation-play-state: paused; + transform-origin: top left; +} + +.surfaceReady { + animation-play-state: running; +} + +.manualSurface { + position: fixed; + max-width: none; + max-height: none; + overflow: visible; + background: transparent; + box-shadow: none; + overscroll-behavior: contain; + animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} + +.manualSurface > div { + width: 100%; + height: 100%; + max-height: none !important; + overflow: visible; +} + +.backdropClosing { + animation: focus-backdrop-out 180ms ease-in forwards; +} + +.surfaceClosing { + animation: focus-surface-out 180ms ease-in forwards; +} + +.sourcePlaceholder { + pointer-events: none; +} + +.surfaceHost { + width: 100%; + height: 100%; +} + +.surfacePortalTarget, +.manualSurfaceMount { + width: 100%; + height: 100%; + min-height: 0; +} + +.manualSurfaceMount { + overflow: hidden; + border-radius: inherit; +} + +.surfaceHostAdvanced { + position: fixed; + inset: 0; + z-index: var(--homarr-z-index-widget-focus); + width: auto; + height: auto; + pointer-events: none; +} + +.closeButton { + position: absolute; + z-index: 2; + pointer-events: auto; +} + +.holdIndicator { + position: fixed; + top: 0; + left: 0; + z-index: var(--mantine-z-index-modal); + display: grid; + width: 36px; + height: 36px; + color: var(--mantine-color-text); + pointer-events: none; + transform: translate3d(var(--advanced-focus-pointer-x), var(--advanced-focus-pointer-y), 0) translate(-50%, -50%); + place-items: center; + will-change: transform; +} + +.holdIndicatorRing { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + filter: drop-shadow(0 1px 3px rgb(0 0 0 / 35%)); + transform: rotate(-90deg); +} + +.holdIndicatorTrack, +.holdIndicatorProgress { + stroke-width: 3; +} + +.holdIndicatorTrack { + fill: color-mix(in srgb, var(--mantine-color-body) 82%, transparent); + stroke: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent); +} + +.holdIndicatorProgress { + fill: none; + stroke: var(--mantine-primary-color-filled); + stroke-linecap: round; + stroke-dasharray: 1; + stroke-dashoffset: 1; + animation: advanced-focus-hold-progress 500ms linear forwards; +} + +.holdIndicatorLabel { + position: relative; + font-size: 15px; + font-weight: 700; + line-height: 1; + text-shadow: 0 1px 2px rgb(0 0 0 / 25%); +} + +@keyframes focus-backdrop-in { + from { + opacity: 0; + } +} + +@keyframes focus-surface-in { + from { + transform: translate(var(--focus-translate-x), var(--focus-translate-y)) + scale(var(--focus-scale-x), var(--focus-scale-y)); + opacity: 0.72; + } +} + +@keyframes focus-backdrop-out { + to { + opacity: 0; + } +} + +@keyframes focus-surface-out { + to { + transform: translate(var(--focus-translate-x), var(--focus-translate-y)) + scale(var(--focus-scale-x), var(--focus-scale-y)); + opacity: 0; + } +} + +@keyframes advanced-focus-hold-progress { + to { + stroke-dashoffset: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .backdrop, + .surface, + .manualSurface { + animation: none; + } + + .holdIndicatorProgress { + stroke-dashoffset: 0; + animation: none; + } +} + +@media (forced-colors: active) { + .backdrop { + background: Canvas; + opacity: 0.4; + } + + .holdIndicatorTrack { + fill: Canvas; + } + + .holdIndicatorTrack { + stroke: GrayText; + } + + .holdIndicatorProgress { + stroke: Highlight; + } + + .holdIndicatorLabel { + color: CanvasText; + text-shadow: none; + } +} diff --git a/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx new file mode 100644 index 000000000..d3fad372a --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx @@ -0,0 +1,195 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { EditModeProvider } from "@homarr/boards/edit-mode"; + +import { BoardAdvancedFocusProvider, useAdvancedFocus } from "./context"; + +const sourceRect = { + left: 20, + top: 30, + right: 220, + bottom: 150, + width: 200, + height: 120, + x: 20, + y: 30, + toJSON: () => undefined, +}; + +describe("BoardAdvancedFocusProvider preview interaction", () => { + let host: HTMLDivElement; + let root: ReturnType; + let focus: ReturnType; + + const Harness = () => { + focus = useAdvancedFocus(); + return createElement( + "div", + null, + createElement("div", { "data-testid": "source" }), + createElement("div", { "data-advanced-focus-surface": true, "data-testid": "surface" }), + createElement( + "div", + { "data-portal": true }, + createElement("button", { role: "menuitem", "data-testid": "portalled-menu-item" }), + ), + createElement("div", { "data-testid": "outside" }), + ); + }; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + Object.defineProperty(document, "elementsFromPoint", { + configurable: true, + value: vi.fn(() => []), + }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + await act(async () => + root.render( + createElement( + MantineProvider, + null, + createElement( + EditModeProvider, + null, + createElement(BoardAdvancedFocusProvider, null, createElement(Harness)), + ), + ), + ), + ); + + const source = host.querySelector("[data-testid='source']"); + if (!source) throw new Error("Expected source fixture"); + vi.spyOn(source, "getBoundingClientRect").mockReturnValue(sourceRect); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove()); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const startPreview = async () => { + const source = host.querySelector("[data-testid='source']"); + if (!source) throw new Error("Expected source fixture"); + await act(async () => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 48, clientY: 52 })); + focus.hover("widget-1", source); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })); + }); + }; + + test("shows pointer-following hold feedback for the full 500 ms delay", async () => { + await startPreview(); + + const indicator = document.querySelector("[data-advanced-focus-hold-indicator]"); + expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-x")).toBe("48px"); + expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-y")).toBe("52px"); + expect(focus.active).toBeNull(); + + await act(async () => vi.advanceTimersByTime(499)); + expect(focus.active).toBeNull(); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).not.toBeNull(); + + await act(async () => vi.advanceTimersByTime(1)); + expect(focus.active?.itemId).toBe("widget-1"); + expect(focus.active?.activation).toBe("preview"); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + }); + + test("does not show hold feedback when Shift is pressed without a hovered source", async () => { + await act(async () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }))); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + + await act(async () => vi.advanceTimersByTime(500)); + expect(focus.active).toBeNull(); + }); + + test("cancels the hold feedback and activation when Shift is released early", async () => { + await startPreview(); + + await act(async () => vi.advanceTimersByTime(250)); + await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" }))); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + + await act(async () => vi.advanceTimersByTime(250)); + expect(focus.active).toBeNull(); + }); + + test("cancels the hold feedback and activation when the source is left early", async () => { + await startPreview(); + + await act(async () => vi.advanceTimersByTime(250)); + await act(async () => focus.leave("widget-1")); + expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull(); + + await act(async () => vi.advanceTimersByTime(250)); + expect(focus.active).toBeNull(); + }); + + test("keeps the preview open across the source-to-portal gap and closes after leaving both", async () => { + await startPreview(); + await act(async () => vi.advanceTimersByTime(500)); + + await act(async () => focus.leave("widget-1")); + await act(async () => vi.advanceTimersByTime(40)); + const surface = host.querySelector("[data-testid='surface']"); + if (!surface) throw new Error("Expected surface fixture"); + await act(async () => + surface.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 300, clientY: 200 })), + ); + await act(async () => vi.advanceTimersByTime(80)); + expect(focus.active?.phase).toBe("visible"); + + const outside = host.querySelector("[data-testid='outside']"); + if (!outside) throw new Error("Expected outside fixture"); + await act(async () => + outside.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 500, clientY: 500 })), + ); + await act(async () => vi.advanceTimersByTime(79)); + expect(focus.active?.phase).toBe("visible"); + await act(async () => vi.advanceTimersByTime(1)); + expect(focus.active?.phase).toBe("closing"); + await act(async () => vi.advanceTimersByTime(180)); + expect(focus.active).toBeNull(); + }); + + test("keeps the preview open while a widget-owned portalled popup is hovered", async () => { + await startPreview(); + await act(async () => vi.advanceTimersByTime(500)); + + await act(async () => focus.leave("widget-1")); + await act(async () => vi.advanceTimersByTime(40)); + const menuItem = host.querySelector("[data-testid='portalled-menu-item']"); + if (!menuItem) throw new Error("Expected portalled menu fixture"); + await act(async () => + menuItem.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 320, clientY: 210 })), + ); + await act(async () => vi.advanceTimersByTime(80)); + + expect(focus.active?.phase).toBe("visible"); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx new file mode 100644 index 000000000..00fffb084 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -0,0 +1,363 @@ +"use client"; + +import type { CSSProperties, PropsWithChildren } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Overlay, Portal } from "@mantine/core"; +import { useIsomorphicEffect } from "@mantine/hooks"; + +import { useEditMode } from "@homarr/boards/edit-mode"; + +import classes from "./advanced-focus.module.css"; +import { isEscapeOwnedByNestedOverlay } from "./escape"; +import type { FocusRect } from "./geometry"; + +interface ActiveFocus { + itemId: string; + source: HTMLElement; + sourceRect: FocusRect; + activation: "preview" | "manual"; + autofocusClose: boolean; + restorePreviewFocus: boolean; + phase: "visible" | "closing"; +} + +interface OpenAdvancedFocusOptions { + activation?: ActiveFocus["activation"]; + autofocusClose?: boolean; +} + +interface AdvancedFocusContextValue { + active: ActiveFocus | null; + open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void; + close: (restoreFocus?: boolean) => void; + dismiss: (itemId: string) => void; + hover: (itemId: string, source: HTMLElement) => void; + leave: (itemId: string) => void; +} + +const AdvancedFocusContext = createContext(null); +const HOVER_DELAY_MS = 500; +const POINTER_TRANSITION_GRACE_MS = 80; +const CLOSE_DURATION_MS = 180; + +const isEditableTarget = (target: EventTarget | null) => + target instanceof HTMLElement && + (target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable); + +export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { + const [isEditMode] = useEditMode(); + const [active, setActive] = useState(null); + const [isHoldPending, setIsHoldPending] = useState(false); + const activeRef = useRef(null); + const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null); + const shiftHeldRef = useRef(false); + const hoverTimerRef = useRef | null>(null); + const previewLeaveTimerRef = useRef | null>(null); + const closeTimerRef = useRef | null>(null); + const holdIndicatorRef = useRef(null); + const pointerPositionRef = useRef({ x: -100, y: -100 }); + const pointerFrameRef = useRef(null); + + const cancelHoverTimer = useCallback(() => { + if (hoverTimerRef.current === null) return; + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + setIsHoldPending(false); + }, []); + + const cancelPreviewLeaveTimer = useCallback(() => { + if (previewLeaveTimerRef.current === null) return; + clearTimeout(previewLeaveTimerRef.current); + previewLeaveTimerRef.current = null; + }, []); + + const updateActive = useCallback((next: ActiveFocus | null) => { + activeRef.current = next; + setActive(next); + }, []); + + const cancelCloseTimer = useCallback(() => { + if (closeTimerRef.current === null) return; + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + }, []); + + const open = useCallback( + (itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => { + if (isEditMode) return; + cancelHoverTimer(); + cancelPreviewLeaveTimer(); + cancelCloseTimer(); + const rect = source.getBoundingClientRect(); + updateActive({ + itemId, + source, + sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + activation: options.activation ?? "manual", + autofocusClose: options.autofocusClose ?? false, + restorePreviewFocus: options.activation === "preview" && document.activeElement === source, + phase: "visible", + }); + }, + [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, isEditMode, updateActive], + ); + + const close = useCallback( + (restoreFocus = true) => { + cancelHoverTimer(); + cancelPreviewLeaveTimer(); + const current = activeRef.current; + if (!current || current.phase === "closing") return; + updateActive({ ...current, phase: "closing" }); + const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS; + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + updateActive(null); + if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus()); + }, closeDuration); + }, + [cancelHoverTimer, cancelPreviewLeaveTimer, updateActive], + ); + + const schedulePreviewClose = useCallback( + (itemId: string) => { + if (previewLeaveTimerRef.current !== null) return; + previewLeaveTimerRef.current = setTimeout(() => { + previewLeaveTimerRef.current = null; + const current = activeRef.current; + if (current?.activation !== "preview" || current.itemId !== itemId) return; + if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null; + close(current.restorePreviewFocus); + }, POINTER_TRANSITION_GRACE_MS); + }, + [close], + ); + + const startHoverTimer = useCallback(() => { + cancelHoverTimer(); + const hovered = hoveredRef.current; + if (!hovered || !shiftHeldRef.current || activeRef.current) return; + if (pointerPositionRef.current.x < 0 || pointerPositionRef.current.y < 0) { + const rect = hovered.source.getBoundingClientRect(); + pointerPositionRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } + setIsHoldPending(true); + hoverTimerRef.current = setTimeout(() => { + hoverTimerRef.current = null; + setIsHoldPending(false); + open(hovered.itemId, hovered.source, { activation: "preview" }); + }, HOVER_DELAY_MS); + }, [cancelHoverTimer, open]); + + const hover = useCallback( + (itemId: string, source: HTMLElement) => { + hoveredRef.current = { itemId, source }; + cancelPreviewLeaveTimer(); + const current = activeRef.current; + if (current?.activation === "preview" && current.itemId !== itemId) { + cancelCloseTimer(); + updateActive(null); + if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true }); + } + startHoverTimer(); + }, + [cancelCloseTimer, cancelPreviewLeaveTimer, startHoverTimer, updateActive], + ); + + useEffect(() => { + const handlePointerMove = (event: PointerEvent) => { + pointerPositionRef.current = { x: event.clientX, y: event.clientY }; + if (holdIndicatorRef.current && pointerFrameRef.current === null) { + pointerFrameRef.current = requestAnimationFrame(() => { + pointerFrameRef.current = null; + const indicator = holdIndicatorRef.current; + if (!indicator) return; + indicator.style.setProperty("--advanced-focus-pointer-x", `${pointerPositionRef.current.x}px`); + indicator.style.setProperty("--advanced-focus-pointer-y", `${pointerPositionRef.current.y}px`); + }); + } + + const current = activeRef.current; + if (!shiftHeldRef.current || current?.activation !== "preview") return; + if (event.target instanceof Element && event.target.closest("[data-advanced-focus-surface], [data-portal]")) { + cancelPreviewLeaveTimer(); + return; + } + + const isInsideSourceRect = + event.clientX >= current.sourceRect.left && + event.clientX <= current.sourceRect.left + current.sourceRect.width && + event.clientY >= current.sourceRect.top && + event.clientY <= current.sourceRect.top + current.sourceRect.height; + if (isInsideSourceRect) { + cancelPreviewLeaveTimer(); + return; + } + + const underlyingSource = document + .elementsFromPoint(event.clientX, event.clientY) + .map((element) => + element instanceof HTMLElement + ? element.matches("[data-advanced-focus-source]") + ? element + : element.closest("[data-advanced-focus-source]") + : null, + ) + .find((source) => source !== null && source !== current.source); + const itemId = underlyingSource?.dataset.itemId; + if (!underlyingSource || !itemId) { + schedulePreviewClose(current.itemId); + return; + } + + if (underlyingSource.hasAttribute("aria-keyshortcuts")) { + hover(itemId, underlyingSource); + return; + } + + hoveredRef.current = null; + cancelHoverTimer(); + schedulePreviewClose(current.itemId); + }; + + window.addEventListener("pointermove", handlePointerMove, { capture: true }); + window.addEventListener("pointerover", handlePointerMove, { capture: true }); + return () => { + window.removeEventListener("pointermove", handlePointerMove, { capture: true }); + window.removeEventListener("pointerover", handlePointerMove, { capture: true }); + if (pointerFrameRef.current !== null) cancelAnimationFrame(pointerFrameRef.current); + }; + }, [cancelHoverTimer, cancelPreviewLeaveTimer, hover, schedulePreviewClose]); + + const leave = useCallback( + (itemId: string) => { + if (hoveredRef.current?.itemId !== itemId) return; + hoveredRef.current = null; + cancelHoverTimer(); + if (activeRef.current?.activation === "preview" && activeRef.current.itemId === itemId) + schedulePreviewClose(itemId); + }, + [cancelHoverTimer, schedulePreviewClose], + ); + + const dismiss = useCallback( + (itemId: string) => { + if (activeRef.current?.itemId !== itemId) return; + cancelHoverTimer(); + cancelPreviewLeaveTimer(); + cancelCloseTimer(); + updateActive(null); + }, + [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive], + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && activeRef.current) { + const advancedSurface = document.querySelector("[data-advanced-focus-surface]"); + if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface)) + return; + event.preventDefault(); + close(); + return; + } + if (isEditMode) return; + if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return; + if (activeRef.current?.activation === "manual") return; + shiftHeldRef.current = true; + startHoverTimer(); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key !== "Shift") return; + shiftHeldRef.current = false; + cancelHoverTimer(); + if (activeRef.current?.activation === "preview") close(false); + }; + const handleBlur = () => { + shiftHeldRef.current = false; + cancelHoverTimer(); + if (activeRef.current?.activation === "preview") close(false); + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + cancelHoverTimer(); + cancelPreviewLeaveTimer(); + cancelCloseTimer(); + }; + }, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]); + + useEffect(() => { + if (isEditMode && activeRef.current) close(false); + }, [close, isEditMode]); + + useIsomorphicEffect(() => { + if (active?.activation !== "manual") return; + const background = document.querySelectorAll("[data-advanced-focus-background]"); + background.forEach((element) => { + element.inert = true; + }); + return () => { + background.forEach((element) => { + element.inert = false; + }); + }; + }, [active?.activation]); + + const value = useMemo( + () => ({ active, open, close, dismiss, hover, leave }), + [active, close, dismiss, hover, leave, open], + ); + + return ( + + {children} + {isHoldPending && ( + +
+ + + + + ⇧ +
+
+ )} + {active?.activation === "preview" && ( + + + + )} +
+ ); +}; + +export const useAdvancedFocus = () => { + const context = useContext(AdvancedFocusContext); + if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider"); + return context; +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/entrance.spec.ts b/apps/nextjs/src/components/board/advanced-focus/entrance.spec.ts new file mode 100644 index 000000000..fb952e07e --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/entrance.spec.ts @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { startAdvancedFocusEntrance } from "./entrance"; + +describe("startAdvancedFocusEntrance", () => { + afterEach(() => { + document.body.replaceChildren(); + vi.unstubAllGlobals(); + }); + + test("moves the persistent portal before releasing its source-frame animation", () => { + let releaseFrame: FrameRequestCallback | undefined; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + releaseFrame = callback; + return 7; + }), + ); + + const compactHost = document.createElement("div"); + const previewHost = document.createElement("div"); + const portalTarget = document.createElement("div"); + const surface = document.createElement("div"); + surface.classList.add("surface-ready"); + portalTarget.append(surface); + compactHost.append(portalTarget); + document.body.append(compactHost, previewHost); + + expect(startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready")).toBe(7); + expect(portalTarget.parentElement).toBe(previewHost); + expect(surface.classList.contains("surface-ready")).toBe(false); + + releaseFrame?.(0); + expect(surface.classList.contains("surface-ready")).toBe(true); + }); + + test("does not release a detached surface", () => { + let releaseFrame: FrameRequestCallback | undefined; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + releaseFrame = callback; + return 8; + }), + ); + + const previewHost = document.createElement("div"); + const portalTarget = document.createElement("div"); + const surface = document.createElement("div"); + portalTarget.append(surface); + document.body.append(previewHost); + + startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready"); + portalTarget.remove(); + releaseFrame?.(0); + + expect(surface.classList.contains("surface-ready")).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/entrance.ts b/apps/nextjs/src/components/board/advanced-focus/entrance.ts new file mode 100644 index 000000000..998d01248 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/entrance.ts @@ -0,0 +1,13 @@ +export const startAdvancedFocusEntrance = ( + portalTarget: HTMLElement, + host: HTMLElement, + surface: HTMLElement, + readyClass: string, +) => { + host.append(portalTarget); + surface.classList.remove(readyClass); + + return requestAnimationFrame(() => { + if (surface.isConnected) surface.classList.add(readyClass); + }); +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts new file mode 100644 index 000000000..e3c8a23ec --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; + +import { isEscapeOwnedByNestedOverlay } from "./escape"; + +describe("isEscapeOwnedByNestedOverlay", () => { + test("allows Escape from the advanced surface itself", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const button = document.createElement("button"); + surface.append(button); + + expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false); + }); + + test("leaves Escape to a nested Mantine modal", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "region"); + const modal = document.createElement("section"); + modal.setAttribute("role", "dialog"); + const input = document.createElement("input"); + modal.append(input); + + expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true); + }); + + test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => { + const surface = document.createElement("section"); + surface.setAttribute("role", "region"); + const overlay = document.createElement("div"); + overlay.setAttribute("role", role); + const target = document.createElement("button"); + overlay.append(target); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + }); + + test("honors Mantine's stop-propagation marker for menus and comboboxes", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "region"); + const target = document.createElement("button"); + target.setAttribute("data-mantine-stop-propagation", "true"); + surface.append(target); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + }); + + test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const target = document.createElement("button"); + surface.append(target); + document.body.append(surface); + + const menu = document.createElement("div"); + menu.setAttribute("role", "menu"); + menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList; + document.body.append(menu); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + + menu.remove(); + surface.remove(); + }); + + test("does not mistake the compact widget source for a nested overlay", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "region"); + const source = document.createElement("button"); + + expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.ts b/apps/nextjs/src/components/board/advanced-focus/escape.ts new file mode 100644 index 000000000..8e58947a2 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/escape.ts @@ -0,0 +1,19 @@ +const escapeOwnerSelector = [ + '[data-mantine-stop-propagation="true"]', + '[role="dialog"]', + '[role="menu"]', + '[role="listbox"]', +].join(", "); + +export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => { + const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null; + if (escapeOwner !== null && escapeOwner !== advancedSurface) return true; + + return Array.from(document.querySelectorAll(escapeOwnerSelector)).some( + (owner) => + owner !== advancedSurface && + owner.getAttribute("aria-hidden") !== "true" && + owner.closest('[aria-hidden="true"]') === null && + owner.getClientRects().length > 0, + ); +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts new file mode 100644 index 000000000..1617a4858 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "./geometry"; + +describe("getAdvancedFocusRect", () => { + it("expands around the widget instead of centering a modal", () => { + const rect = getAdvancedFocusRect({ left: 450, top: 300, width: 300, height: 200 }, { width: 1200, height: 800 }); + + expect(rect).toEqual({ left: 200, top: 120, width: 800, height: 560 }); + expect(rect.left + rect.width / 2).toBe(600); + expect(rect.top + rect.height / 2).toBe(400); + }); + + it("uses the available viewport on small screens", () => { + expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({ + left: 8, + top: 68, + width: 374, + height: 624, + }); + }); + + it("keeps controls below the fixed board header on short screens", () => { + const rect = getAdvancedFocusRect({ left: 1200, top: 700, width: 500, height: 500 }, { width: 1366, height: 768 }); + + expect(rect.left).toBeGreaterThanOrEqual(16); + expect(rect.left + rect.width).toBeLessThanOrEqual(1350); + expect(rect.top).toBeGreaterThanOrEqual(76); + expect(rect.top + rect.height).toBeLessThanOrEqual(752); + }); +}); + +describe("getAdvancedFocusClosePosition", () => { + it("keeps the close control outside the surface when the right side has room", () => { + expect(getAdvancedFocusClosePosition({ left: 16, top: 76, width: 800, height: 560 }, 1280)).toEqual({ + left: 824, + top: 76, + }); + }); + + it("uses the left side when the surface is anchored to the right edge", () => { + expect(getAdvancedFocusClosePosition({ left: 464, top: 76, width: 800, height: 560 }, 1280)).toEqual({ + left: 412, + top: 76, + }); + }); + + it("falls back to the inside corner on narrow screens", () => { + expect(getAdvancedFocusClosePosition({ left: 8, top: 68, width: 374, height: 624 }, 390)).toEqual({ + left: 330, + top: 76, + }); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.ts new file mode 100644 index 000000000..e8859a5a8 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.ts @@ -0,0 +1,54 @@ +import { appShellHeaderHeight } from "../../layout/constants"; + +export interface FocusRect { + left: number; + top: number; + width: number; + height: number; +} + +export interface FocusClosePosition { + left: number; + top: number; +} + +const closeButtonSize = 44; +const closeButtonGap = 8; + +export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => { + const margin = viewport.width < 640 ? 8 : 16; + const availableWidth = Math.max(0, viewport.width - margin * 2); + const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2); + const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(800, availableWidth); + const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(560, availableHeight); + const width = Math.min(availableWidth, Math.max(source.width, preferredWidth)); + const height = Math.min(availableHeight, Math.max(source.height, preferredHeight)); + const minimumTop = appShellHeaderHeight + margin; + const maximumLeft = Math.max(margin, viewport.width - margin - width); + const maximumTop = Math.max(minimumTop, viewport.height - margin - height); + + return { + left: Math.min(maximumLeft, Math.max(margin, source.left - (width - source.width) / 2)), + top: Math.min(maximumTop, Math.max(minimumTop, source.top - (height - source.height) / 2)), + width, + height, + }; +}; + +export const getAdvancedFocusClosePosition = (surface: FocusRect, viewportWidth: number): FocusClosePosition => { + const outsideOffset = closeButtonSize + closeButtonGap; + const surfaceRight = surface.left + surface.width; + + if (viewportWidth - surfaceRight >= outsideOffset) { + return { left: surfaceRight + closeButtonGap, top: surface.top }; + } + + if (surface.left >= outsideOffset) { + return { left: surface.left - outsideOffset, top: surface.top }; + } + + return { + left: Math.max(0, Math.min(viewportWidth - closeButtonSize, surfaceRight - outsideOffset)), + top: surface.top + closeButtonGap, + }; +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts new file mode 100644 index 000000000..b659efc9c --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import { act, createElement, Fragment, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { AdvancedFocusManualSurface } from "./manual-surface"; + +const sourceRect = { left: 100, top: 120, width: 240, height: 160 }; +const rect = { left: 40, top: 80, width: 640, height: 480 }; +const closePosition = { left: 688, top: 80 }; + +describe("AdvancedFocusManualSurface", () => { + let host: HTMLDivElement | undefined; + let root: ReturnType | undefined; + + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + host?.remove(); + document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove()); + vi.unstubAllGlobals(); + }); + + test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + const Harness = () => { + const [opened, setOpened] = useState(false); + const contentRef = useRef(null); + + return createElement( + Fragment, + null, + createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"), + createElement( + AdvancedFocusManualSurface, + { + opened, + phase: "visible", + id: "advanced-focus-test", + label: "Test widget advanced view", + closeLabel: "Close advanced view", + rect, + closePosition, + sourceRect, + radius: "md", + contentRef, + onClose: () => setOpened(false), + }, + createElement("button", { type: "button" }, "First widget action"), + createElement("button", { type: "button" }, "Last widget action"), + ), + ); + }; + + await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness)))); + const trigger = document.querySelector("button"); + expect(trigger).not.toBeNull(); + trigger?.focus(); + await act(async () => trigger?.click()); + await act(async () => await new Promise((resolve) => setTimeout(resolve, 20))); + + const dialog = document.querySelector("[role='dialog']"); + const closeButton = dialog?.querySelector("[aria-label='Close advanced view']"); + const actions = dialog?.querySelectorAll("button"); + expect(dialog?.id).toBe("advanced-focus-test"); + expect(dialog?.getAttribute("aria-modal")).toBe("true"); + expect(dialog?.closest("[data-portal]")).not.toBeNull(); + expect(dialog?.closest("[data-grid-item-id]")).toBeNull(); + expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe( + "Test widget advanced view", + ); + expect(document.activeElement).toBe(closeButton); + + const lastAction = actions?.item((actions?.length ?? 1) - 1); + lastAction?.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(closeButton); + closeButton?.focus(); + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }), + ); + expect(document.activeElement).toBe(lastAction); + + const backdrop = document.querySelector("[data-advanced-focus-overlay]"); + await act(async () => backdrop?.click()); + await act(async () => await new Promise((resolve) => setTimeout(resolve, 20))); + expect(document.activeElement).toBe(trigger); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx new file mode 100644 index 000000000..9cd01848b --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { CSSProperties, PropsWithChildren, Ref } from "react"; +import { Modal, VisuallyHidden } from "@mantine/core"; +import { useReducedMotion } from "@mantine/hooks"; + +import classes from "./advanced-focus.module.css"; +import type { FocusClosePosition, FocusRect } from "./geometry"; + +interface AdvancedFocusManualSurfaceProps extends PropsWithChildren { + opened: boolean; + phase: "visible" | "closing"; + id: string; + label: string; + closeLabel: string; + rect: FocusRect; + closePosition: FocusClosePosition; + sourceRect: FocusRect; + radius: string | number; + contentRef: Ref; + onClose: () => void; +} + +export const AdvancedFocusManualSurface = ({ + opened, + phase, + id, + label, + closeLabel, + rect, + closePosition, + sourceRect, + radius, + contentRef, + onClose, + children, +}: AdvancedFocusManualSurfaceProps) => { + const reduceMotion = useReducedMotion(); + const transitionDuration = reduceMotion ? 0 : 180; + const geometryStyle = { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + "--focus-translate-x": `${sourceRect.left - rect.left}px`, + "--focus-translate-y": `${sourceRect.top - rect.top}px`, + "--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1, + "--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1, + } as CSSProperties; + const relativeClosePosition = { + left: closePosition.left - rect.left, + top: closePosition.top - rect.top, + }; + + return ( + + + + + {label} + + + {children} + + + ); +}; 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..6f5d598de --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts @@ -0,0 +1,26 @@ +const isVerticalScrollContainer = (element: Element) => { + if (element.scrollHeight <= element.clientHeight) return false; + + const overflowY = getComputedStyle(element).overflowY; + return overflowY === "auto" || overflowY === "scroll"; +}; + +const canScrollVertically = (element: Element, delta: number) => { + 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 Element ? target : null; + while (current && root.contains(current)) { + const isCurrentScrollContainer = isVerticalScrollContainer(current); + if (isCurrentScrollContainer && canScrollVertically(current, delta)) { + current.scrollTop += delta; + return true; + } + if (current === root) break; + current = current.parentElement; + } + return false; +}; diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts index fcc194618..848ea9ac2 100644 --- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -107,7 +107,6 @@ describe("get first empty position", () => { expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined); }); }); - const createElementsFromLayout = (layout: string[][]) => { const elements: (Pick & { char: string })[] = []; for (let yOffset = 0; yOffset < layout.length; yOffset++) { diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index 29b5615e2..cc7b637b4 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -32,12 +32,7 @@ interface UpdateItemIntegrations { export const useItemActions = () => { const { updateBoard } = useUpdateBoard(); - const createItem = useCallback( - (input: CreateItemInput) => { - updateBoard(createItemCallback(input)); - }, - [updateBoard], - ); + const createItem = useCallback((input: CreateItemInput) => updateBoard(createItemCallback(input)), [updateBoard]); const duplicateItem = useCallback( ({ itemId }: DuplicateItemInput) => { diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 08c4b93b1..614f9017e 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,9 +1,10 @@ -import type { ComponentType, MutableRefObject } from "react"; -import { Suspense, use, useRef } from "react"; +import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react"; +import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import dynamic from "next/dynamic"; import type { CardProps } from "@mantine/core"; -import { Badge, Card, Center, Loader } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core"; +import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; @@ -13,12 +14,23 @@ import { ErrorBoundary } from "react-error-boundary"; 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 { WidgetError } from "@homarr/widgets/errors"; -import { getWidgetQueryKeys } from "@homarr/widgets/definition"; -import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; +import { + createWidgetRuntimeState, + getWidgetQueryKeys, + supportsAdvancedFocus as definitionSupportsAdvancedFocus, +} from "@homarr/widgets/definition"; +import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { startAdvancedFocusEntrance } from "../advanced-focus/entrance"; +import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry"; +import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface"; +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"; @@ -92,38 +104,305 @@ const WidgetDefinitionLoadError = ({ export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); - const board = useRequiredBoard(); const widgetStateRef = useRef | null>(null); + const widgetRuntimeRef = useRef(createWidgetRuntimeState()); return ( - <> - } + } + > + +
+ +
+ + } > - -
- -
- - } + +
+
+ ); +}; + +interface LoadedBoardItemContentProps { + item: SectionItem; + width: number; + height: number; + widgetStateRef: MutableRefObject | null>; + widgetRuntimeRef: WidgetRuntimeRef; + contentRef: (element: HTMLDivElement | null) => void; +} + +const LoadedBoardItemContent = ({ + item, + width, + height, + widgetStateRef, + widgetRuntimeRef, + contentRef, +}: LoadedBoardItemContentProps) => { + const { definition, Component } = use(loadWidgetResources(item.kind)); + const sourceRef = useRef(null); + const cardRef = useRef(null); + const previewEntranceFrameRef = useRef(null); + const { width: viewportWidth, height: viewportHeight } = useViewportSize(); + const board = useRequiredBoard(); + const t = useI18n(); + const [isEditMode] = useEditMode(); + const [manualSurface, setManualSurface] = useState(null); + const [surfacePortalTarget, setSurfacePortalTarget] = useState(null); + const { active, open, close, dismiss, hover, leave } = useAdvancedFocus(); + const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition); + const widgetName = t(`widget.${item.kind}.name`); + const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName }); + const advancedViewId = `advanced-focus-${item.id}`; + const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; + const isAdvanced = activeFocus !== null; + const advancedRect = activeFocus + ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight }) + : null; + const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null; + const isPreview = activeFocus?.activation === "preview"; + const isManual = activeFocus?.activation === "manual"; + + useIsomorphicEffect(() => { + if (!supportsAdvancedFocus) return; + const target = document.createElement("div"); + target.className = advancedFocusClasses.surfacePortalTarget ?? ""; + setSurfacePortalTarget(target); + return () => target.remove(); + }, [supportsAdvancedFocus]); + + useEffect(() => () => dismiss(item.id), [dismiss, item.id]); + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.currentTarget !== event.target || + event.key !== "Enter" || + !event.shiftKey || + isEditMode || + !supportsAdvancedFocus + ) + return; + event.preventDefault(); + if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true }); + }; + + useEffect(() => { + const surface = isManual ? manualSurface : cardRef.current; + if (!isAdvanced || !surface) return; + + const handleWheel = (event: WheelEvent) => { + if (!event.shiftKey) return; + const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1; + const delta = (event.deltaY || event.deltaX) * multiplier; + if (!redirectShiftWheel(surface, event.target, delta)) return; + event.preventDefault(); + event.stopPropagation(); + }; + + surface.addEventListener("wheel", handleWheel, { capture: true, passive: false }); + return () => surface.removeEventListener("wheel", handleWheel, { capture: true }); + }, [isAdvanced, isManual, manualSurface]); + + const cancelPreviewEntrance = useCallback(() => { + if (previewEntranceFrameRef.current === null) return; + cancelAnimationFrame(previewEntranceFrameRef.current); + previewEntranceFrameRef.current = null; + }, []); + + const mountCompactHost = useCallback( + (host: HTMLDivElement | null) => { + if (!host || !surfacePortalTarget || isAdvanced) return; + cancelPreviewEntrance(); + host.append(surfacePortalTarget); + }, + [cancelPreviewEntrance, isAdvanced, surfacePortalTarget], + ); + + const mountPreviewHost = useCallback( + (host: HTMLDivElement | null) => { + if (!host || !surfacePortalTarget || !isPreview) { + if (!host) cancelPreviewEntrance(); + return; + } + + if (activeFocus?.phase !== "visible") { + host.append(surfacePortalTarget); + return; + } + const card = cardRef.current; + const readyClass = advancedFocusClasses.surfaceReady; + if (!card || !readyClass) return; + + cancelPreviewEntrance(); + previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass); + }, + [activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget], + ); + + const mountManualHost = useCallback( + (host: HTMLDivElement | null) => { + if (!host || !surfacePortalTarget || !isManual) return; + cancelPreviewEntrance(); + host.append(surfacePortalTarget); + }, + [cancelPreviewEntrance, isManual, surfacePortalTarget], + ); + + useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]); + + const previewStyle = + isPreview && advancedRect && activeFocus + ? ({ + left: advancedRect.left, + top: advancedRect.top, + width: advancedRect.width, + height: advancedRect.height, + "--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`, + "--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`, + "--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1, + "--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1, + } as CSSProperties) + : undefined; + + const widgetCard = ( + 0), + "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, + }, + }} + style={previewStyle} + > + + ( + <> + {isEditMode && ( + + )} +
+ +
+ + )} > - - -
- {item.advancedOptions.title?.trim() && ( +
+ + + ); + + return ( + <> + + { + if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current); + }} + onPointerLeave={() => { + if (supportsAdvancedFocus) leave(item.id); + }} + className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)} + > +
+ {!supportsAdvancedFocus && widgetCard} +
+ {surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)} + {isPreview && ( + +
+ + )} + {isManual && activeFocus && advancedRect && closePosition && ( + close()} + > +
+ + )} + + + {!isAdvanced && item.advancedOptions.title?.trim() && ( { ); }; -interface LoadedBoardItemContentProps extends Omit { - innerRef: (element: HTMLDivElement | null) => void; -} - -const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => { - const { definition, Component } = use(loadWidgetResources(item.kind)); - const [isEditMode] = useEditMode(); - - return ( - - - ( - <> - -
- -
- - )} - > - -
-
-
- ); -}; - interface InnerContentProps { item: SectionItem; width: number; height: number; widgetStateRef: MutableRefObject | null>; + widgetRuntimeRef: WidgetRuntimeRef; + displayMode: "compact" | "advanced"; definition: WidgetDefinition; Component: ComponentType>; } -const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => { +const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); @@ -260,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte )} ); -}; +}); const Throw = ({ when, error }: { when: boolean; error: Error }) => { if (when) throw error; diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 64537d151..c373e3c66 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -28,7 +28,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => { return ; }; - const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const { data: session } = useSession(); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css index ef8c56d8d..1a53dd49b 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.module.css +++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css @@ -2,7 +2,6 @@ opacity: 0; transition: opacity 150ms ease; } - .card:hover .action, .card:focus-within .action { opacity: 1; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index b9fb82670..0bc6c296a 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -290,7 +290,6 @@ const ItemSelectModalContent = ({ ); }; - const ItemSelectModalFrame = ({ actions, innerProps, 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 570f1964e..e4f1f8cec 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,149 +1,154 @@ "use client"; -import type { MutableRefObject, ReactNode } from "react"; +import type { MutableRefObject, ReactNode, RefObject } from "react"; import { useCallback, useMemo, useState } from "react"; -import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; -import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react"; +import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; +import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; +import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; +import { usePersistBoard } from "@homarr/boards/updater"; import { useTimeAgo } from "@homarr/common"; import { useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; -import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; -import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; +import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { useBoardPermissions } from "../permissions/client"; import { useItemActions } from "./item-actions"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; +import { matchesWidgetItemQuery } from "./widget-query-scope"; interface WidgetContextMenuProps { item: SectionItem; definition: WidgetDefinition; widgetStateRef: MutableRefObject | null>; + widgetRuntimeRef: WidgetRuntimeRef; + sourceRef: RefObject; children: ReactNode; } -export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { - const { data: session } = useSession(); - const [isEditMode] = useEditMode(); - const settings = useSettings(); - - if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}; - - return ( - - {children} - - ); -}; - -const WidgetContextMenuInner = ({ +export const WidgetContextMenu = ({ item, definition, - widgetStateRef, + widgetRuntimeRef, + sourceRef, children, - settings, -}: WidgetContextMenuProps & { settings: ReturnType }) => { - const [isMenuOpen, setIsMenuOpen] = useState(false); - - return ( - - {children} - - {isMenuOpen && ( - - )} - - - ); -}; - -type WidgetContextMenuDropdownProps = Omit & { - settings: ReturnType; -}; - -const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => { +}: WidgetContextMenuProps) => { + const { data: session } = useSession(); const [isEditMode] = useEditMode(); const board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + const { updateAndPersistBoard } = usePersistBoard(board); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); - const { data: session } = useSession(); - const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); - const hasSupportedIntegrations = - "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; + const settings = useSettings(); const { openModal } = useModalAction(LazyWidgetEditModal); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); + const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0; const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, { enabled: hasSupportedIntegrations, }); - const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); + const canConfigureWidget = + hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false)); + const canOpenAdvancedFocus = supportsAdvancedFocus(definition); const queryClient = useQueryClient(); + const { open: openAdvancedFocus } = useAdvancedFocus(); + const integrationsWithInteractAccess = useIntegrationsWithInteractAccess(); + const [menuOpened, setMenuOpened] = useState(false); - const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); - const isWidgetFetching = - useIsFetching({ - predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), - }) > 0; - const handleRefetch = useCallback(() => { - void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); - }, [queryClient, widgetQueryKeys]); + const persistBoard = useCallback( + (updater: (previous: typeof board) => typeof board) => { + updateAndPersistBoard(updater, { + onError: () => { + showErrorNotification({ + title: t("item.menu.notification.saveError.title"), + message: t("item.menu.notification.saveError.message"), + }); + }, + }); + }, + [t, updateAndPersistBoard], + ); const options = useMemo( () => reduceWidgetOptionsWithDefinition(definition, settings, item.options), [definition, settings, item.options], ); + const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); + const matchesWidgetQuery = useCallback( + (queryKey: QueryKey) => + matchesWidgetItemQuery( + queryKey, + widgetQueryKeys, + { + itemId: item.id, + boardId: board.id, + integrationIds: item.integrationIds, + options, + runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef), + }, + definition.queryMatcher, + ), + [board.id, definition.queryMatcher, item.id, item.integrationIds, options, widgetQueryKeys, widgetRuntimeRef], + ); + const isWidgetFetching = + useIsFetching({ + type: "active", + predicate: (query) => matchesWidgetQuery(query.queryKey), + }) > 0; + const handleRefetch = useCallback(() => { + void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) }); + }, [matchesWidgetQuery, queryClient]); + + const canInteractWithSelectedIntegrations = useMemo(() => { + const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id)); + return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id)); + }, [integrationsWithInteractAccess, item.integrationIds]); + + const setItemOptions = useCallback( + (partial: Record) => { + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => + boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem, + ), + })); + }, + [item.id, persistBoard], + ); type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { if (!canConfigureWidget) return []; const rawOptions = definition.createOptions(settings) as unknown as Record; - return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); + return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu); }, [canConfigureWidget, definition, settings]); - const widgetContextActions = useMemo(() => { - const def = definition as unknown as Record; - if (typeof def.contextActions !== "function") return []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actions = (def.contextActions as any)({ + const widgetContextActions = + definition.contextActions?.({ options, - setOptions: (partial: Record) => { - updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } }); - }, + setOptions: setItemOptions, integrationIds: item.integrationIds, - context: { isEditMode, boardId: board.id, itemId: item.id }, - widgetStateRef, - }); - return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); - - const persistBoard = useCallback( - (updatedItems: typeof board.items) => { - saveBoard({ ...board, items: updatedItems }); - }, - [board, saveBoard], - ); + context: { + isEditMode, + boardId: board.id, + itemId: item.id, + canInteractWithSelectedIntegrations, + }, + widgetRuntimeRef, + }) ?? []; const openEditModal = useCallback(() => { openModal( @@ -159,27 +164,24 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings updateItemOptions({ itemId: item.id, newOptions: editResult.options }); updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); - if (!isEditMode) { - persistBoard( - board.items.map((boardItem) => - boardItem.id !== item.id - ? boardItem - : { - ...boardItem, - options: editResult.options, - advancedOptions: editResult.advancedOptions, - integrationIds: editResult.integrationIds, - }, - ), - ); - } + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => + boardItem.id !== item.id + ? boardItem + : { + ...boardItem, + options: editResult.options, + advancedOptions: editResult.advancedOptions, + integrationIds: editResult.integrationIds, + }, + ), + })); }, - integrationData: integrationData.filter( - (integration) => - "supportedIntegrations" in definition && - (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + integrationData: integrationData.filter((integration) => + definition.supportedIntegrations?.some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in definition, + integrationSupport: definition.supportedIntegrations !== undefined, settings, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, @@ -187,72 +189,87 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings { title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` }, ); }, [ - openModal, + definition, + integrationData, item, - updateItemOptions, + openModal, + persistBoard, + settings, updateItemAdvancedOptions, updateItemIntegrations, - integrationData, - definition, - settings, - isEditMode, - persistBoard, - board, + updateItemOptions, ]); const handleToggle = useCallback( - (key: string) => (checked: boolean) => { - const newOptions = { ...options, [key]: checked }; - updateItemOptions({ itemId: item.id, newOptions }); - persistBoard( - board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })), - ); - }, - [options, item.id, updateItemOptions, persistBoard, board], + (key: string) => (checked: boolean) => setItemOptions({ [key]: checked }), + [setItemOptions], ); - const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); + if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}; + + const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden); return ( - <> - {toggleOptions.length > 0 && ( - <> - {tMenu("options")} - {toggleOptions.map(([key]) => ( - handleToggle(key)(!options[key])}> - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} - - + + {children} + + {canOpenAdvancedFocus && ( + <> + } + onClick={() => { + if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current); + }} + > + {t("item.advancedFocus.open")} - ))} - - )} + + + )} - {visibleWidgetActions.length > 0 && ( - <> - {toggleOptions.length > 0 && } - {tMenu("actions")} - {visibleWidgetActions.map((action) => { - const Icon = action.icon; - return ( - : undefined} - onClick={action.onClick} - disabled={action.disabled} - color={action.color} - > - {String(translateIfNecessary(t, action.label))} - - ); - })} - - )} + {toggleOptions.length > 0 && ( + <> + {tMenu("options")} + {toggleOptions.map(([key]) => ( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} + + ))} + + )} + + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {translateIfNecessary(t, action.label)} + + ); + })} + + )} - <> {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } : } @@ -263,7 +280,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings {tMenu("refresh")} @@ -279,23 +296,27 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings > {tMenu("settings")} - - + + ); }; interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKeys: QueryKey[]; + matchesQuery: (queryKey: QueryKey) => boolean; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { - const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; +const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => { + const queries = queryClient.getQueryCache().findAll({ + type: "active", + predicate: (query) => matchesQuery(query.queryKey), + }); const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; - const ageLabel = useTimeAgo(new Date(latest)); + const ageLabel = useTimeAgo(new Date(latest || Date.now())); + const ageSuffix = latest > 0 ? ` · ${ageLabel}` : ""; if (isFetching) { return ( @@ -316,22 +337,15 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer ); } - const hasError = queries.some((q) => q.state.status === "error"); - + const hasError = queries.some((query) => query.state.status === "error"); if (hasError) { - const errorQuery = queries.find((q) => q.state.status === "error"); - const errorMessage = - errorQuery?.state.error instanceof Error - ? errorQuery.state.error.message - : String(errorQuery?.state.error ?? "Unknown error"); - return ( - + {t("item.menu.status.error")} - {latest > 0 && ` · ${ageLabel}`} + {ageSuffix} @@ -343,7 +357,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer {t("item.menu.status.success")} - {latest > 0 && ` · ${ageLabel}`} + {ageSuffix} ); diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css index c90d332b1..f8de2cf43 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css @@ -105,6 +105,15 @@ flex-shrink: 0; } +@media (forced-colors: active) { + .panel { + border-color: CanvasText; + background: Canvas; + box-shadow: none; + backdrop-filter: none; + } +} + .metadataValue { overflow: hidden; text-overflow: ellipsis; diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.ts index 9bb0b81c5..ba80f3ee2 100644 --- a/apps/nextjs/src/components/board/items/widget-query-recovery.ts +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.ts @@ -1,5 +1,5 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; -export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => { +export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: readonly QueryKey[]) => { queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey })); }; diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts new file mode 100644 index 000000000..ec667102e --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts @@ -0,0 +1,442 @@ +import type { QueryKey } from "@tanstack/react-query"; +import { describe, expect, test } from "vitest"; + +import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets"; +import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets"; + +import { matchesWidgetItemQuery } from "./widget-query-scope"; + +type WidgetKind = keyof typeof widgetImports; +const allPhotosAlbumId = "all"; + +const createScope = (overrides: Partial = {}): WidgetQueryMatcherScope => ({ + itemId: "item-1", + boardId: "board-1", + integrationIds: ["integration-1"], + options: {}, + runtimeQueries: [], + ...overrides, +}); + +const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => { + const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string }; + const queryKey: QueryKey = [path, { input, type: "query" }]; + return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher); +}; + +describe("matchesWidgetItemQuery", () => { + test("matches only the current Custom API definition", () => { + const scope = createScope({ options: { definitionId: "definition-1" } }); + const input = { boardId: "board-1", itemId: "item-1", definitionId: "definition-1" }; + + expect(matches("customApi", ["widget", "customApi", "getData"], input, scope)).toBe(true); + expect( + matches("customApi", ["widget", "customApi", "getData"], { ...input, definitionId: "definition-2" }, scope), + ).toBe(false); + expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, itemId: "item-2" }, scope)).toBe(false); + expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, boardId: "board-2" }, scope)).toBe( + false, + ); + }); + + test("owns only the active compact or advanced Media Missing page size", () => { + const path = ["widget", "mediaOrganizer", "getData"]; + const compactInput = { integrationIds: ["radarr-1"], pageSize: 20 }; + const advancedInput = { integrationIds: ["radarr-1"], pageSize: 50 }; + const compactScope = createScope({ + integrationIds: ["radarr-1"], + runtimeQueries: [{ path, input: compactInput }], + }); + const advancedScope = createScope({ + integrationIds: ["radarr-1"], + runtimeQueries: [{ path, input: advancedInput }], + }); + + expect(matches("mediaMissing", path, compactInput, compactScope)).toBe(true); + expect(matches("mediaMissing", path, advancedInput, compactScope)).toBe(false); + expect(matches("mediaMissing", path, advancedInput, advancedScope)).toBe(true); + expect(matches("mediaMissing", path, compactInput, advancedScope)).toBe(false); + }); + + test("owns every Releases batch by item id", () => { + const scope = createScope({ itemId: "release-item" }); + const path = ["widget", "releases", "getLatest"]; + + expect(matches("releases", path, { itemId: "release-item", repositories: [{ id: "one" }] }, scope)).toBe(true); + expect(matches("releases", path, { itemId: "sibling-item", repositories: [{ id: "one" }] }, scope)).toBe(false); + }); + + test("normalizes Immich's all-photos sentinel and separates Immich widget kinds", () => { + const carouselScope = createScope({ + integrationIds: ["immich-1"], + options: { albumId: allPhotosAlbumId }, + }); + const serverStatsQuery: NormalizedWidgetQuery = { + path: ["widget", "immich", "getServerStats"], + input: { integrationId: "immich-1" }, + }; + const albumsQuery: NormalizedWidgetQuery = { + path: ["widget", "immich", "getAlbums"], + input: { integrationId: "immich-1" }, + }; + const compactServerScope = createScope({ + integrationIds: ["immich-1"], + runtimeQueries: [serverStatsQuery], + }); + const advancedServerScope = createScope({ + integrationIds: ["immich-1"], + runtimeQueries: [serverStatsQuery, albumsQuery], + }); + + expect( + matches("immich-albumCarousel", ["widget", "immich", "getAlbum"], { integrationId: "immich-1" }, carouselScope), + ).toBe(true); + expect( + matches( + "immich-albumCarousel", + ["widget", "immich", "getAlbum"], + { integrationId: "immich-1", albumId: "album-2" }, + carouselScope, + ), + ).toBe(false); + expect( + matches( + "immich-albumCarousel", + ["widget", "immich", "getServerStats"], + { integrationId: "immich-1" }, + carouselScope, + ), + ).toBe(false); + expect(matches("immich-serverStats", serverStatsQuery.path, serverStatsQuery.input, compactServerScope)).toBe(true); + expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, compactServerScope)).toBe(false); + expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, advancedServerScope)).toBe(true); + expect( + matches("immich-serverStats", ["widget", "immich", "getAlbum"], albumsQuery.input, advancedServerScope), + ).toBe(false); + }); + + test("uses the resolved Beszel integration, system, and includeDocker input", () => { + const runtimeQuery: NormalizedWidgetQuery = { + path: ["widget", "beszel", "getSystemStats"], + input: { + integrationIds: ["beszel-2"], + systemId: "system-2", + timePeriod: "1h", + includeDocker: true, + }, + }; + const scope = createScope({ + integrationIds: ["beszel-1", "beszel-2"], + options: { systemId: "beszel-2:system-2", timePeriod: "1h", showDockerCpu: true }, + runtimeQueries: [runtimeQuery], + }); + + expect(matches("beszelSystemStats", runtimeQuery.path, runtimeQuery.input, scope)).toBe(true); + expect( + matches( + "beszelSystemStats", + runtimeQuery.path, + { ...(runtimeQuery.input as Record), includeDocker: false }, + scope, + ), + ).toBe(false); + expect( + matches( + "beszelSystemStats", + runtimeQuery.path, + { ...(runtimeQuery.input as Record), systemId: "system-1" }, + scope, + ), + ).toBe(false); + }); + + test("separates Beszel procedure families while retaining shared getSystems ownership", () => { + const scope = createScope({ + integrationIds: ["beszel-1"], + options: { showHistory: true, maxHistoryItems: 10 }, + }); + const systemsInput = { integrationIds: ["beszel-1"] }; + const alertsInput = { integrationIds: ["beszel-1"], includeHistory: true, maxHistoryItems: 10 }; + + expect(matches("beszelSystemGrid", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true); + expect(matches("beszelSystemTable", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true); + expect(matches("beszelAlerts", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(false); + expect(matches("beszelAlerts", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(true); + expect(matches("beszelSystemGrid", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(false); + }); + + test("owns only the active Beszel table detail query", () => { + const activeQuery: NormalizedWidgetQuery = { + path: ["widget", "beszel", "getSystemStats"], + input: { + integrationIds: ["beszel-1"], + systemId: "system-1", + timePeriod: "1h", + includeDocker: true, + }, + }; + const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] }); + + expect(matches("beszelSystemTable", activeQuery.path, activeQuery.input, scope)).toBe(true); + expect( + matches( + "beszelSystemTable", + activeQuery.path, + { ...(activeQuery.input as Record), systemId: "system-2" }, + scope, + ), + ).toBe(false); + expect(matches("beszelSystemGrid", activeQuery.path, activeQuery.input, scope)).toBe(false); + }); + + test("owns only Umami queries registered for the active view and layout", () => { + const eventQuery: NormalizedWidgetQuery = { + path: ["widget", "umami", "getMultiEventTimeSeries"], + input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["signup", "view"] }, + }; + const topPagesQuery: NormalizedWidgetQuery = { + path: ["widget", "umami", "getTopPages"], + input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", limit: 5 }, + }; + const eventsScope = createScope({ runtimeQueries: [eventQuery] }); + const advancedScope = createScope({ runtimeQueries: [eventQuery, topPagesQuery] }); + + expect(matches("umami", eventQuery.path, eventQuery.input, eventsScope)).toBe(true); + expect( + matches( + "umami", + eventQuery.path, + { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["view", "signup"] }, + eventsScope, + ), + ).toBe(false); + expect(matches("umami", topPagesQuery.path, topPagesQuery.input, eventsScope)).toBe(false); + expect(matches("umami", topPagesQuery.path, topPagesQuery.input, advancedScope)).toBe(true); + }); + + test("maps Media Request status filters and separates list from stats", () => { + const listScope = createScope({ + integrationIds: ["request-1"], + options: { statusFilter: [], recentDays: 30, linksTargetNewTab: true }, + }); + const allStatuses = ["pending", "approved", "declined", "failed", "completed"]; + + expect( + matches( + "mediaRequests-requestList", + ["widget", "mediaRequests", "getLatestRequests"], + { integrationIds: ["request-1"], statuses: allStatuses, recentDays: 30 }, + listScope, + ), + ).toBe(true); + expect( + matches( + "mediaRequests-requestList", + ["widget", "mediaRequests", "getLatestRequests"], + { integrationIds: ["request-1"], statuses: ["pending"], recentDays: 30 }, + listScope, + ), + ).toBe(false); + expect( + matches( + "mediaRequests-requestList", + ["widget", "mediaRequests", "getStats"], + { integrationIds: ["request-1"] }, + listScope, + ), + ).toBe(false); + expect( + matches( + "mediaRequests-requestStats", + ["widget", "mediaRequests", "getStats"], + { integrationIds: ["request-1"] }, + listScope, + ), + ).toBe(true); + }); + + test("separates Audio Stats stream details from Media Server filters", () => { + const statsQuery: NormalizedWidgetQuery = { + path: ["widget", "audioStats", "getStats"], + input: { integrationId: "audio-1" }, + }; + const streamsQuery: NormalizedWidgetQuery = { + path: ["widget", "mediaServer", "getCurrentStreams"], + input: { integrationIds: ["audio-1"], showOnlyPlaying: false }, + }; + const compactAudioScope = createScope({ integrationIds: ["audio-1"], runtimeQueries: [statsQuery] }); + const advancedAudioScope = createScope({ + integrationIds: ["audio-1"], + runtimeQueries: [statsQuery, streamsQuery], + }); + const mediaScope = createScope({ integrationIds: ["audio-1"], options: { showOnlyPlaying: true } }); + + expect(matches("audioStats", statsQuery.path, statsQuery.input, compactAudioScope)).toBe(true); + expect(matches("audioStats", streamsQuery.path, streamsQuery.input, compactAudioScope)).toBe(false); + expect(matches("audioStats", streamsQuery.path, streamsQuery.input, advancedAudioScope)).toBe(true); + expect( + matches( + "audioStats", + streamsQuery.path, + { integrationIds: ["audio-1"], showOnlyPlaying: true }, + advancedAudioScope, + ), + ).toBe(false); + expect( + matches("mediaServer", streamsQuery.path, { integrationIds: ["audio-1"], showOnlyPlaying: true }, mediaScope), + ).toBe(true); + expect(matches("mediaServer", streamsQuery.path, streamsQuery.input, mediaScope)).toBe(false); + }); + + test("uses each Calendar instance's registered month", () => { + const path = ["widget", "calendar", "findAllEvents"]; + const januaryInput = { + integrationIds: ["calendar-1"], + month: 0, + year: 2026, + releaseType: [], + showUnmonitored: false, + }; + const februaryInput = { ...januaryInput, month: 1 }; + const januaryScope = createScope({ runtimeQueries: [{ path, input: januaryInput }] }); + const februaryScope = createScope({ runtimeQueries: [{ path, input: februaryInput }] }); + + expect(matches("calendar", path, januaryInput, januaryScope)).toBe(true); + expect(matches("calendar", path, februaryInput, januaryScope)).toBe(false); + expect(matches("calendar", path, februaryInput, februaryScope)).toBe(true); + }); + + test("uses each Media Transcoding instance's registered page and geometry-derived page size", () => { + const path = ["widget", "mediaTranscoding", "getDataAsync"]; + const firstPage = { integrationId: "transcode-1", pageSize: 8, page: 1 }; + const secondPage = { integrationId: "transcode-1", pageSize: 12, page: 2 }; + const firstScope = createScope({ runtimeQueries: [{ path, input: firstPage }] }); + const secondScope = createScope({ runtimeQueries: [{ path, input: secondPage }] }); + + expect(matches("mediaTranscoding", path, firstPage, firstScope)).toBe(true); + expect(matches("mediaTranscoding", path, secondPage, firstScope)).toBe(false); + expect(matches("mediaTranscoding", path, secondPage, secondScope)).toBe(true); + }); + + test("matches only the Downloads query with the current integrations and limit", () => { + const scope = createScope({ + integrationIds: ["sabnzbd-1", "qbittorrent-1"], + options: { limitPerIntegration: 25 }, + }); + const path = ["widget", "downloads", "getJobsAndStatuses"]; + + expect( + matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 25 }, scope), + ).toBe(true); + expect(matches("downloads", path, { integrationIds: ["sabnzbd-1"], limitPerIntegration: 25 }, scope)).toBe(false); + expect( + matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 50 }, scope), + ).toBe(false); + expect( + matches("downloads", ["widget", "downloads", "pause"], { integrationIds: ["sabnzbd-1", "qbittorrent-1"] }, scope), + ).toBe(false); + }); + + test("matches only the active Stock Price symbol and time frame", () => { + const scope = createScope({ options: { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" } }); + const path = ["widget", "stockPrice", "getPriceHistory"]; + + expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(true); + expect(matches("stockPrice", path, { stock: "MSFT", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(false); + expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "5d", timeInterval: "1d" }, scope)).toBe(false); + }); + + test("matches Weather by the coordinates sent to the location procedure", () => { + const scope = createScope({ + options: { location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 } }, + }); + const path = ["widget", "weather", "atLocation"]; + + expect(matches("weather", path, { latitude: 48.85341, longitude: 2.3488 }, scope)).toBe(true); + expect(matches("weather", path, { latitude: 51.5072, longitude: -0.1276 }, scope)).toBe(false); + expect( + matches("weather", ["widget", "weather", "forecast"], { latitude: 48.85341, longitude: 2.3488 }, scope), + ).toBe(false); + }); + + test("matches Clock weather only when enabled and at its configured coordinates", () => { + const path = ["widget", "weather", "atLocation"]; + const location = { name: "Brisbane", latitude: -27.4698, longitude: 153.0251 }; + const enabledScope = createScope({ options: { showWeather: true, weatherLocation: location } }); + const disabledScope = createScope({ options: { showWeather: false, weatherLocation: location } }); + + expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, enabledScope)).toBe(true); + expect(matches("clock", path, { latitude: 48.85341, longitude: 2.3488 }, enabledScope)).toBe(false); + expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, disabledScope)).toBe(false); + }); + + test("matches Minecraft Server Status by the full input passed to useQuery", () => { + const scope = createScope({ + options: { title: "Survival", domain: "play.example.com", isBedrockServer: false }, + }); + const path = ["widget", "minecraft", "getServerStatus"]; + + expect( + matches( + "minecraftServerStatus", + path, + { title: "Survival", domain: "play.example.com", isBedrockServer: false }, + scope, + ), + ).toBe(true); + expect( + matches( + "minecraftServerStatus", + path, + { title: "Creative", domain: "play.example.com", isBedrockServer: false }, + scope, + ), + ).toBe(false); + expect( + matches( + "minecraftServerStatus", + path, + { title: "Survival", domain: "bedrock.example.com", isBedrockServer: true }, + scope, + ), + ).toBe(false); + }); + + test("matches Notifications by integrations and the full input passed to useQuery", () => { + const scope = createScope({ integrationIds: ["ntfy-1"], options: { hideLogos: true } }); + const path = ["widget", "notifications", "getNotifications"]; + + expect(matches("notifications", path, { hideLogos: true, integrationIds: ["ntfy-1"] }, scope)).toBe(true); + expect(matches("notifications", path, { hideLogos: false, integrationIds: ["ntfy-1"] }, scope)).toBe(false); + expect(matches("notifications", path, { hideLogos: true, integrationIds: ["gotify-1"] }, scope)).toBe(false); + expect( + matches("notifications", ["widget", "notifications", "dismiss"], { integrationIds: ["ntfy-1"] }, scope), + ).toBe(false); + }); + + test("keeps renamed RSS input fields item-specific", () => { + const scope = createScope({ + integrationIds: [], + options: { feedUrls: ["https://feed.example/rss"], maximumAmountPosts: 100 }, + }); + const path = ["widget", "rssFeed", "getFeeds"]; + + expect(matches("rssFeed", path, { urls: ["https://feed.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(true); + expect(matches("rssFeed", path, { urls: ["https://other.example/rss"], maximumAmountPosts: 100 }, scope)).toBe( + false, + ); + }); + + test("generic fallback ignores transformed non-identity inputs", () => { + const scope = createScope({ integrationIds: ["integration-1"], options: { pageSize: 10 } }); + const definitionKeys = [[["widget", "example"]]] as const; + + expect( + matchesWidgetItemQuery( + [["widget", "example", "getData"], { input: { integrationIds: ["integration-1"], pageSize: 50 } }], + definitionKeys, + scope, + ), + ).toBe(true); + }); +}); diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.ts b/apps/nextjs/src/components/board/items/widget-query-scope.ts new file mode 100644 index 000000000..973f50794 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-query-scope.ts @@ -0,0 +1,83 @@ +import type { QueryKey } from "@tanstack/react-query"; + +import type { WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets/definition"; +import { normalizeWidgetQuery, widgetQueryValueEquals } from "@homarr/widgets/definition"; + +export const matchesWidgetItemQuery = ( + queryKey: QueryKey, + widgetQueryKeys: readonly QueryKey[], + scope: WidgetQueryMatcherScope, + queryMatcher?: WidgetQueryMatcher, +) => { + const query = normalizeWidgetQuery(queryKey); + if (!query || !widgetQueryKeys.some((candidate) => matchesDefinitionPath(query.path, candidate))) return false; + if (queryMatcher) return queryMatcher(query, scope); + + return matchesQueryInput(query.input, scope); +}; + +const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => { + const candidatePath = candidate[0]; + return ( + Array.isArray(candidatePath) && + candidatePath.every((part, index) => typeof part === "string" && path[index] === part) + ); +}; + +const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => { + if (input == null) return true; + if (Array.isArray(input)) { + return widgetQueryValueEquals(input, scope.integrationIds) || containsValue(scope.options, input); + } + + if (!isRecord(input)) { + return ( + input === scope.itemId || + input === scope.boardId || + scope.integrationIds.includes(String(input)) || + containsValue(scope.options, input) + ); + } + + for (const [key, value] of Object.entries(input)) { + if (key === "itemId") { + if (value !== scope.itemId) return false; + continue; + } + if (key === "boardId") { + if (value !== scope.boardId) return false; + continue; + } + if (key === "integrationId") { + if (!scope.integrationIds.includes(String(value))) return false; + continue; + } + if (key === "integrationIds") { + if (!widgetQueryValueEquals(value, scope.integrationIds)) return false; + continue; + } + + if (isIdentityKey(key) && !matchesAnyScopedValue(value, scope)) return false; + } + + return true; +}; + +const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) => + value === scope.itemId || + value === scope.boardId || + scope.integrationIds.includes(String(value)) || + containsValue(scope.options, value); + +const isIdentityKey = (key: string) => key === "id" || key.endsWith("Id") || key.endsWith("Ids"); + +const containsValue = (container: unknown, expected: unknown, seen = new WeakSet()): boolean => { + if (widgetQueryValueEquals(container, expected)) return true; + if (container === null || typeof container !== "object" || seen.has(container)) return false; + seen.add(container); + + return Object.values(container).some((value) => containsValue(value, expected, seen)); +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md index 8a12bc180..12ac371d0 100644 --- a/apps/nextjs/src/components/board/layout/README.md +++ b/apps/nextjs/src/components/board/layout/README.md @@ -5,12 +5,14 @@ The board uses fixed logical units: - one `1 × 1` item is exactly `200 × 200` logical pixels; - the gap is always `24` logical pixels, giving a `224` pixel track pitch; - multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`); -- the canvas is transformed once with one uniform scale derived from its available width. +- the canvas is zoomed once with one uniform scale derived from its available width. Items keep the same logical dimensions at every viewport. The viewport reserves the painted size while the inner canvas remains at its logical dimensions, so a widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay -stable. +stable. The visual canvas always fits the available width, every root lane +keeps at least one viewport of height, and edit-mode previews extend it downward +as needed. The read-only renderer should: diff --git a/apps/nextjs/src/components/board/layout/constants.ts b/apps/nextjs/src/components/board/layout/constants.ts index 9c3103b4b..26e3f0765 100644 --- a/apps/nextjs/src/components/board/layout/constants.ts +++ b/apps/nextjs/src/components/board/layout/constants.ts @@ -5,6 +5,5 @@ export const LOGICAL_GRID_CELL_SIZE = 200; export const LOGICAL_GRID_GAP = 24; export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; -export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75; export const COLLAPSED_SECTION_ROW_COUNT = 0.5; diff --git a/apps/nextjs/src/components/board/layout/geometry.ts b/apps/nextjs/src/components/board/layout/geometry.ts index 32b7fced4..449e74d4d 100644 --- a/apps/nextjs/src/components/board/layout/geometry.ts +++ b/apps/nextjs/src/components/board/layout/geometry.ts @@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert export const getLayoutRowCount = (placements: readonly GridCoordinates[]) => placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0); +export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => { + if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1; + if (!Number.isFinite(visualScale) || visualScale <= 0) return 1; + + return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH)); +}; + export const normalizeGridPlacement = ( placement: TPlacement, columnCount: number, diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index 6c98e40a2..feb2a8ac5 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -2,9 +2,7 @@ width: 100%; max-width: 100%; min-width: 0; - overflow: visible; - overflow-x: auto; - overscroll-behavior-inline: contain; + overflow: clip; } .viewport[data-board-hydrated="false"] { @@ -18,8 +16,6 @@ .canvas { position: absolute; inset: 0 auto auto 0; - transform-origin: top left; - will-change: transform; /* * Keep dashboard UI at its normal readable density while the fixed canvas @@ -44,9 +40,3 @@ --mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1)); --mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1)); } - -@media (prefers-reduced-motion: reduce) { - .canvas { - will-change: auto; - } -} diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx index 3d5de2b72..b11356b93 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx @@ -5,11 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { Box } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; -import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants"; import classes from "./scaled-board-canvas.module.css"; -export { MIN_ACCESSIBLE_CANVAS_SCALE }; - const BoardCanvasScaleContext = createContext(1); export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext); @@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1; if (availableWidth <= 0 || logicalWidth <= 0) return 1; - return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth); + return availableWidth / logicalWidth; }; export const calculateBoardUiScale = (canvasScale: number) => { @@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps { /** * Scales the complete board as one surface while keeping widget layout in - * fixed logical pixels. The scale floor deliberately allows horizontal - * overflow so browser zoom still magnifies content instead of being cancelled - * by responsive downscaling. + * fixed logical pixels. The complete canvas always fits its viewport so board + * content can never widen the document or require horizontal scrolling. */ export const ScaledBoardCanvas = ({ logicalWidth, @@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({ [logicalWidth, resolvedAvailableWidth], ); const uiScale = calculateBoardUiScale(scale); + const inverseScale = scale > 0 ? 1 / scale : 1; const visualWidth = logicalWidth * scale; const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5; @@ -84,8 +81,9 @@ export const ScaledBoardCanvas = ({ className={classes.canvas} w={logicalWidth} style={{ + "--board-canvas-inverse-scale": inverseScale, "--board-canvas-ui-scale": uiScale, - transform: `scale(${scale})`, + zoom: scale, }} > {children} diff --git a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts index 66f084703..3d46398bb 100644 --- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; -import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index"; +import { + getGridRowCountForVisualHeight, + getLayoutRowCount, + getLogicalItemStyle, + getLogicalTrackSize, + normalizeGridPlacement, +} from "../index"; describe("fixed dashboard geometry", () => { test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => { @@ -24,4 +30,12 @@ describe("fixed dashboard geometry", () => { expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 }); expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7); }); + + test("derives enough logical rows to fill the visual viewport at any canvas scale", () => { + expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6); + expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200); + + expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11); + expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200); + }); }); diff --git a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts index 83e14ea02..50fae3420 100644 --- a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "vitest"; -import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas"; +import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas"; describe("board canvas scale policy", () => { - test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => { + test("fits the logical canvas exactly", () => { const scale = calculateBoardCanvasScale(840, 1000); expect(scale).toBe(0.84); @@ -14,16 +14,16 @@ describe("board canvas scale policy", () => { expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8); }); - test("stops counter-scaling at 0.75 and lets the canvas overflow", () => { + test("keeps fitting narrow viewports without horizontal overflow", () => { const availableWidth = 600; const logicalWidth = 1000; const scale = calculateBoardCanvasScale(availableWidth, logicalWidth); - expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE); - expect(logicalWidth * scale).toBeGreaterThan(availableWidth); + expect(scale).toBe(0.6); + expect(logicalWidth * scale).toBe(availableWidth); }); - test("keeps content materially magnified at 200% effective zoom", () => { + test("keeps the board fitted at 200% effective zoom", () => { const logicalWidth = 1696; const normalAvailableWidth = 1872; const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth); @@ -31,8 +31,8 @@ describe("board canvas scale policy", () => { const normalPaintedCellSize = 200 * normalScale; const zoomedPaintedCellSize = 200 * zoomedScale * 2; - expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE); - expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25); + expect(zoomedScale).toBeCloseTo(normalScale / 2); + expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize); }); test.each([ diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx index 5e4ef1d65..ab958e75f 100644 --- a/apps/nextjs/src/components/board/sections/empty-section.tsx +++ b/apps/nextjs/src/components/board/sections/empty-section.tsx @@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai const [isEditMode] = useEditMode(); const t = useScopedI18n("board.landmark"); - if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null; + if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null; return ( .board-grid-content-mount { + overflow: clip; +} + .board-grid-placeholder { z-index: 0; background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent); @@ -178,7 +182,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder { .board-grid-resize-handle[data-grid-resize-handle="s"] { right: 44px; left: 44px; - height: 32px; + height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1))); cursor: ns-resize; } @@ -194,7 +198,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder { .board-grid-resize-handle[data-grid-resize-handle="w"] { top: 44px; bottom: 44px; - width: 32px; + width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1))); cursor: ew-resize; } @@ -217,8 +221,8 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder { .board-grid-resize-handle[data-grid-resize-handle="sw"], .board-grid-resize-handle[data-grid-resize-handle="nw"] { z-index: 9; - width: 44px; - height: 44px; + width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1))); + height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1))); } .board-grid-resize-handle[data-grid-resize-handle="ne"] { diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index 192cbf7f9..d4d0782ab 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract"; import { pointerIntersection } from "@dnd-kit/collision"; import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom"; import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom"; +import { RestrictToElement } from "@dnd-kit/dom/modifiers"; import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities"; import type { BeforeDragStartEvent, @@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement if (!previewGrid || !interaction) return placements; if (interaction.sourceGridId === sectionId) { + if (interaction.mode === "resize") return previewGrid.placements; const original = controlledById.get(interaction.activeId); if (!original) return previewGrid.placements; return previewGrid.placements.some((placement) => placement.id === interaction.activeId) @@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement ); const targetPlacement = interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null; + const growsDuringDrag = + interaction?.mode === "drag" && + (interaction.targetGridId === sectionId || + (interaction.targetGridId === null && interaction.sourceGridId === sectionId)); + const participatesInInteraction = + interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId); + const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements); const renderedRowCount = - section.kind !== "container" && interaction?.mode === "resize" - ? Math.max(rowCount, getLayoutRowCount(renderPlacements)) + section.kind !== "container" && participatesInInteraction + ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount; const { ref: droppableRef, isDropTarget } = useDroppable({ @@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: ], [], ); + const modifiers = useMemo( + () => [ + RestrictToElement.configure({ + element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null, + }), + ], + [], + ); const { ref: draggableRef, handleRef, @@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: type: GRID_ENTRY_TYPE, data: sourceData, sensors, + modifiers, }); const setShellRef = useCallback( (element: HTMLDivElement | null) => { diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index 3ab7b292b..ab1b1fb4b 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { Box, LoadingOverlay } from "@mantine/core"; import combineClasses from "clsx"; @@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types import { getCollapsedDisplayLayout, getEditableCanvasAttributes, + getGridRowCountForVisualHeight, getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, getReadonlyCanvasAttributes, normalizeGridPlacement, } from "~/components/board/layout"; +import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import { loadGridEditorAsync } from "./grid-editor-loader"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import type { SectionGridPlacement } from "./use-grid-layout-actions"; @@ -81,6 +83,7 @@ export const SectionGrid = ({ className, }: SectionGridProps) => { const [isEditMode] = useEditMode(); + const canvasScale = useBoardCanvasScale(); const editorRuntimeStatus = useGridEditorRuntimeStatus(); const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); @@ -144,8 +147,9 @@ export const SectionGrid = ({ ); const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id))); const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id))); + const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); - const rowCount = Math.max(contentRowCount, requestedRowCount); + const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount); const logicalWidth = getLogicalTrackSize(columnCount); const logicalHeight = getLogicalTrackSize(rowCount); const canvasAttributes = isEditMode @@ -199,6 +203,24 @@ export const SectionGrid = ({ ); }; +const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => { + const [visualHeight, setVisualHeight] = useState(0); + + useEffect(() => { + if (!enabled) return; + const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight); + update(); + window.addEventListener("resize", update, { passive: true }); + window.visualViewport?.addEventListener("resize", update, { passive: true }); + return () => { + window.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("resize", update); + }; + }, [enabled]); + + return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0; +}; + const getContainerMinimumSize = ( board: ReturnType, layoutId: string, diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts index 8491dfc17..6da6a4f2a 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts @@ -27,6 +27,7 @@ describe("board grid import boundary", () => { { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/abstract" }, { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/collision" }, { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom" }, + { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/modifiers" }, { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/utilities" }, { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/react" }, ]); diff --git a/apps/nextjs/src/components/board/sections/use-open-section-apps.ts b/apps/nextjs/src/components/board/sections/use-open-section-apps.ts index b8f7bb64f..d462fdda9 100644 --- a/apps/nextjs/src/components/board/sections/use-open-section-apps.ts +++ b/apps/nextjs/src/components/board/sections/use-open-section-apps.ts @@ -2,6 +2,7 @@ import { use, useCallback, useMemo } from "react"; import { clientApi } from "@homarr/api/client"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { getSafeAppHref } from "@homarr/common"; import { useConfirmModal } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; @@ -107,10 +108,10 @@ export const useOpenSectionApps = (sectionId: string, enabled: boolean) => { }); const open = useCallback(() => { - const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0); + const appUrls = apps.map((app) => getSafeAppHref(app.href)).filter((href) => href !== undefined); - for (const app of appsWithUrls) { - const openedWindow = window.open(app.href ?? undefined); + for (const href of appUrls) { + const openedWindow = window.open(href); if (openedWindow) continue; openConfirmModal({ diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 03056c130..dde74820f 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -22,6 +22,7 @@ export const categoryTranslationKeys: Record = { smartHomeServer: "integration.category.smartHomeServer", indexerManager: "integration.category.indexerManager", healthMonitoring: "integration.category.healthMonitoring", + beszel: "integration.category.beszel", search: "integration.category.search", mediaTranscoding: "integration.category.mediaTranscoding", networkController: "integration.category.networkController", diff --git a/apps/nextjs/src/components/layout/constants.ts b/apps/nextjs/src/components/layout/constants.ts new file mode 100644 index 000000000..2799f8304 --- /dev/null +++ b/apps/nextjs/src/components/layout/constants.ts @@ -0,0 +1 @@ +export const appShellHeaderHeight = 60; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index 8710fb631..2ad377576 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -18,7 +18,13 @@ interface Props { export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { return ( - + {hasNavigation && } diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index b51cf3220..56001cafb 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -5,6 +5,7 @@ import { AppShell } from "@mantine/core"; import { useAtomValue } from "jotai"; import { useOptionalBackgroundProps } from "./background"; +import { appShellHeaderHeight } from "./constants"; import { navigationCollapsedAtom } from "./header/burger"; interface ClientShellProps { @@ -23,7 +24,7 @@ export const ClientShell = ({ return ( {children}; }; diff --git a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx index 89ab01c59..0c79f728f 100644 --- a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx +++ b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import { Button, Modal } from "@mantine/core"; import { IconDownload } from "@tabler/icons-react"; +import { modalSizeSelect } from "@homarr/modals"; + import { WorkshopBrowser } from "./workshop-browser"; export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) { @@ -14,7 +16,7 @@ export function WorkshopCssImportButton({ onImport }: { onImport(css: string): v - setOpened(false)} title="Import Custom CSS" size="xl"> + setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}> { diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts new file mode 100644 index 000000000..b970eec28 --- /dev/null +++ b/e2e/board-advanced-interactions.spec.ts @@ -0,0 +1,509 @@ +import type { Browser, BrowserContext, Page } from "@playwright/test"; +import { chromium, expect } from "@playwright/test"; +import { createId } from "@paralleldrive/cuid2"; +import { eq } from "drizzle-orm"; +import { stringify } from "superjson"; +import { afterAll, beforeAll, describe, test } from "vitest"; + +import { hashPasswordAsync } from "../packages/auth/security"; +import { + apps, + boardUserPermissions, + boards, + itemLayouts, + items, + layouts, + sections, + users, +} from "../packages/db/schema/sqlite"; +import type { SqliteDatabase } from "./shared/e2e-db"; +import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { createHomarrContainer } from "./shared/create-homarr-container"; +import { loginAsync } from "./shared/login"; +import { seedAdminUserAsync } from "./shared/seed-admin-user"; + +const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" }; +const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" }; +const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" }; +const boardName = "advanced-interactions-e2e"; + +describe("Board advanced interactions", () => { + let browser: Browser; + let baseUrl: string; + let stopContainerAsync: (() => Promise) | undefined; + + beforeAll(async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { userId: ownerId } = await seedAdminUserAsync(db, ownerCredentials); + await seedInteractionBoardAsync(db, ownerId); + await seedCredentialsUserAsync(db, { + ...disabledModifierCredentials, + enableRightClickOnWidgets: false, + boardPermission: "modify", + }); + await seedCredentialsUserAsync(db, { + ...viewerCredentials, + enableRightClickOnWidgets: true, + boardPermission: "view", + }); + + const homarrContainer = await createHomarrContainer({ + environment: { AUTH_PROVIDERS: "credentials" }, + mounts: { "/appdata": localMountPath }, + }).start(); + stopContainerAsync = async () => { + await homarrContainer.stop(); + }; + baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + browser = await chromium.launch(); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + await stopContainerAsync?.(); + }); + + test("opens advanced view from Shift-hover, keyboard, and the widget context menu", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first(); + const compactSurface = page.locator(".clock-wrapper").first(); + const previewSurface = page.getByRole("region", { name: "Date and time advanced view" }); + const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" }); + const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" }); + const dimmingOverlay = page.locator("[data-advanced-focus-overlay]"); + const otherWidget = page + .locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]") + .first(); + const header = page.locator("header[data-advanced-focus-background]"); + const boardMain = page.locator("main[data-advanced-focus-background]"); + + await compactSurface.evaluate((element) => { + element.setAttribute("data-lifecycle-probe", "same-instance"); + }); + await widget.hover(); + await widget.focus(); + await page.keyboard.down("Shift"); + await expect(previewSurface).toBeVisible({ timeout: 2_000 }); + await expect(previewSurface).toHaveCSS("animation-name", "none"); + const compactBounds = await widget.boundingBox(); + const advancedBounds = await previewSurface.boundingBox(); + if (!compactBounds || !advancedBounds) throw new Error("Advanced widget has no bounds"); + expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0); + await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(dimmingOverlay).toBeVisible(); + await expect(dimmingOverlay).toHaveCSS("pointer-events", "none"); + await expect(otherWidget).toBeVisible(); + await expect(widget).toBeFocused(); + await expect(header).not.toHaveAttribute("inert", ""); + await expect(boardMain).not.toHaveAttribute("inert", ""); + + await previewSurface.click({ button: "right", position: { x: 80, y: 80 } }); + const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" }); + await expect(portalledMenuItem).toBeVisible(); + await portalledMenuItem.hover(); + await expect(previewSurface).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(portalledMenuItem).toBeHidden(); + await expect(previewSurface).toBeVisible(); + + const otherBounds = await otherWidget.boundingBox(); + expect(otherBounds).not.toBeNull(); + if (!otherBounds) throw new Error("Bookmarks widget has no bounds"); + await previewSurface.evaluate( + (element, position) => { + const probe = document.createElement("button"); + probe.dataset.interactivePreviewProbe = "true"; + probe.style.cssText = `position:absolute;z-index:20;width:40px;height:40px;left:${position.left}px;top:${position.top}px`; + element.append(probe); + }, + { + left: otherBounds.x + otherBounds.width / 2 - advancedBounds.x - 20, + top: otherBounds.y + otherBounds.height / 2 - advancedBounds.y - 20, + }, + ); + const interactivePreviewProbe = previewSurface.locator("[data-interactive-preview-probe='true']"); + await interactivePreviewProbe.hover(); + await expect(previewSurface).toBeVisible(); + await expect(bookmarksPreviewSurface).toBeHidden(); + await interactivePreviewProbe.evaluate((element) => element.remove()); + await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4); + await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2); + await expect(previewSurface).toBeVisible(); + await expect(bookmarksPreviewSurface).toBeHidden(); + await page.mouse.move(4, 4); + await expect(previewSurface).toBeHidden(); + await otherWidget.hover({ position: { x: 4, y: 4 } }); + await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 }); + await page.keyboard.up("Shift"); + await expect(bookmarksPreviewSurface).toBeHidden(); + await expect(dimmingOverlay).toBeHidden(); + await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(widget).toBeFocused(); + + await page.keyboard.press("Shift+Enter"); + await expect(manualSurface).toBeVisible(); + await expect(manualSurface).toHaveAttribute("aria-modal", "true"); + await expect(header).toHaveAttribute("inert", ""); + await expect(boardMain).toHaveAttribute("inert", ""); + expect(await manualSurface.evaluate((element) => element.closest("[data-grid-item-id]"))).toBeNull(); + await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto"); + expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden"); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount( + 0, + ); + await manualSurface.evaluate((element) => { + const mainViewport = document.createElement("div"); + mainViewport.dataset.scrollbars = "y"; + mainViewport.style.cssText = "height:80px;overflow-y:auto"; + + const probe = document.createElement("div"); + probe.dataset.shiftScrollProbe = "true"; + probe.style.cssText = "height:80px;overflow-y:auto"; + probe.innerHTML = '
'; + probe.addEventListener("wheel", (event) => event.stopPropagation()); + mainViewport.append(probe); + mainViewport.insertAdjacentHTML("beforeend", '
'); + element.append(mainViewport); + + const unrelated = document.createElement("div"); + unrelated.dataset.shiftScrollProbe = "unrelated"; + unrelated.style.cssText = "height:80px;overflow-y:hidden"; + unrelated.innerHTML = '
'; + element.append(unrelated); + }); + const scrollProbe = manualSurface.locator("[data-shift-scroll-probe='true']"); + await page.keyboard.down("Shift"); + const pixelEventCancelled = await scrollProbe.evaluate( + (element) => + !element.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }), + ), + ); + expect(pixelEventCancelled).toBe(true); + await scrollProbe.evaluate((element) => { + element.scrollTop = 0; + }); + const lineEventCancelled = await scrollProbe + .locator("[data-shift-scroll-target='true']") + .evaluate( + (element) => + !element.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 3, deltaMode: 1 }), + ), + ); + await page.keyboard.up("Shift"); + expect(lineEventCancelled).toBe(true); + expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThanOrEqual(48); + await scrollProbe.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }), + ); + }); + expect(await manualSurface.locator("[data-scrollbars='y']").evaluate((element) => element.scrollTop)).toBe(120); + expect( + await manualSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop), + ).toBe(0); + + await manualSurface.click({ button: "right", position: { x: 80, y: 80 } }); + await expect(portalledMenuItem).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(portalledMenuItem).toBeHidden(); + await expect(manualSurface).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(manualSurface).toBeHidden(); + await expect(widget).toBeFocused(); + await expect(header).not.toHaveAttribute("inert", ""); + await expect(boardMain).not.toHaveAttribute("inert", ""); + expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden"); + + await widget.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open advanced view" }).click(); + await expect(manualSurface).toBeVisible(); + const viewport = page.viewportSize(); + if (!viewport) throw new Error("Board page has no viewport"); + await page.mouse.click(4, viewport.height - 4); + await expect(manualSurface).toBeHidden(); + await expect(widget).toBeFocused(); + + for (const compactOnlyKind of ["app", "iframe"]) { + const compactOnlyWidget = page + .locator(`[data-grid-item-id][data-kind='${compactOnlyKind}'] [data-advanced-focus-source]`) + .first(); + await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); + await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await page.keyboard.down("Shift"); + await compactOnlyWidget.hover({ position: { x: 4, y: 4 } }); + await expect(previewSurface).toBeHidden(); + await expect(dimmingOverlay).toBeHidden(); + await page.keyboard.up("Shift"); + } + } finally { + await context.close(); + } + }, 60_000); + + test("keeps compact-only iframe state and dismisses retained focus when a responsive layout remounts", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const iframeSlot = page.locator("[data-grid-item-id][data-kind='iframe'] [data-advanced-focus-source]").first(); + const iframe = iframeSlot.locator("iframe"); + await iframe.evaluate((element) => { + (element as HTMLIFrameElement).srcdoc = ''; + (element as HTMLIFrameElement).dataset.advancedFocusProbe = "true"; + }); + const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']"); + await frame.locator("#state").fill("changed"); + + await expect(iframeSlot).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); + await iframeSlot.dispatchEvent("contextmenu", { button: 2 }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await page.keyboard.down("Shift"); + await iframeSlot.hover({ position: { x: 4, y: 4 } }); + await expect(page.getByRole("region", { name: "iFrame advanced view" })).toHaveCount(0); + await page.keyboard.up("Shift"); + await expect(frame.locator("#state")).toHaveValue("changed"); + + const bookmarksSlot = page + .locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]") + .first(); + await bookmarksSlot.focus(); + await page.keyboard.press("Shift+Enter"); + await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible(); + await page.setViewportSize({ width: 800, height: 900 }); + await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden(); + await expect(page.locator("[data-grid-item-id][data-kind='iframe'] iframe")).toBeVisible(); + } finally { + await context.close(); + } + }, 60_000); + + test("keeps advanced controls below the fixed header on short screens", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 }); + + try { + const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first(); + await widget.focus(); + await page.keyboard.press("Shift+Enter"); + const closeButton = page.getByRole("button", { name: "Close advanced view" }); + const bounds = await closeButton.boundingBox(); + expect(bounds).not.toBeNull(); + if (!bounds) return; + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(60); + expect(bounds.width).toBeGreaterThanOrEqual(44); + expect(bounds.height).toBeGreaterThanOrEqual(44); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(768); + const surfaceBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox(); + const contentBounds = await page.locator(".clock-widget-container").boundingBox(); + expect(surfaceBounds).not.toBeNull(); + expect(contentBounds).not.toBeNull(); + if (surfaceBounds && contentBounds) { + expect(contentBounds.y - surfaceBounds.y).toBeLessThan(32); + expect(bounds.x >= surfaceBounds.x + surfaceBounds.width || bounds.x + bounds.width <= surfaceBounds.x).toBe( + true, + ); + } + await closeButton.click(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden(); + } finally { + await context.close(); + } + }, 60_000); + + test("honors the user setting gate even when the user can modify the board", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials); + + try { + const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first(); + + await widget.click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); + } finally { + await context.close(); + } + }, 60_000); + + test("keeps read-only advanced viewing", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials); + + try { + const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first(); + + await widget.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open advanced view" }).click(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible(); + await page.keyboard.press("Escape"); + } finally { + await context.close(); + } + }, 60_000); +}); + +const openBoardAsync = async ( + browser: Browser, + baseUrl: string, + credentials: { username: string; password: string }, + viewport = { width: 1280, height: 900 }, +): Promise<{ context: BrowserContext; page: Page }> => { + const context = await browser.newContext({ reducedMotion: "reduce", viewport }); + const page = await context.newPage(); + + await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` }); + await expect(page.locator(".clock-wrapper").first()).toBeVisible({ + timeout: 15_000, + }); + + return { context, page }; +}; + +const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => { + const boardId = createId(); + const baseLayoutId = createId(); + const desktopLayoutId = createId(); + const sectionId = createId(); + const clockItemId = createId(); + const bookmarksItemId = createId(); + const iframeItemId = createId(); + const appItemId = createId(); + const appId = createId(); + + await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false }); + await db.insert(apps).values({ + id: appId, + name: "Example app", + iconUrl: "/favicon.ico", + href: "https://example.com", + }); + await db.insert(layouts).values([ + { id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId }, + { id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId }, + ]); + await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId }); + await db.insert(items).values([ + { + id: clockItemId, + kind: "clock", + boardId, + options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + { + id: bookmarksItemId, + kind: "bookmarks", + boardId, + options: stringify({ + title: "", + layout: "column", + hideTitle: false, + hideIcon: false, + hideHostname: false, + openNewTab: true, + withBorder: false, + items: [appId], + }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + { + id: iframeItemId, + kind: "iframe", + boardId, + options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + { + id: appItemId, + kind: "app", + boardId, + options: stringify({ appId, openInNewTab: true, showTitle: true, pingEnabled: false }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + ]); + await db.insert(itemLayouts).values( + [baseLayoutId, desktopLayoutId].flatMap((layoutId) => [ + { + itemId: clockItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 0, + width: 4, + height: 3, + }, + { + itemId: bookmarksItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 3, + width: 4, + height: 3, + }, + { + itemId: iframeItemId, + sectionId, + layoutId, + xOffset: 8, + yOffset: 0, + width: 4, + height: 3, + }, + { + itemId: appItemId, + sectionId, + layoutId, + xOffset: 4, + yOffset: 0, + width: 3, + height: 3, + }, + ]), + ); + await db + .update(users) + .set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true }) + .where(eq(users.id, ownerId)); +}; + +const seedCredentialsUserAsync = async ( + db: SqliteDatabase, + input: { + username: string; + password: string; + enableRightClickOnWidgets: boolean; + boardPermission: "view" | "modify"; + }, +) => { + const userId = createId(); + const board = await db.query.boards.findFirst({ where: eq(boards.name, boardName) }); + if (!board) throw new Error("Interaction board was not seeded"); + + await db.insert(users).values({ + id: userId, + name: input.username, + provider: "credentials", + password: await hashPasswordAsync(input.password), + enableRightClickOnWidgets: input.enableRightClickOnWidgets, + homeBoardId: board.id, + completedBoardTour: true, + completedManageTour: true, + }); + await db.insert(boardUserPermissions).values({ + boardId: board.id, + userId, + permission: input.boardPermission, + }); +}; diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 47d734263..1be6bdb9b 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -6,11 +6,7 @@ import { createId } from "@paralleldrive/cuid2"; import { stringify as stringifySuperJSON } from "superjson"; import { describe, test } from "vitest"; -import { - LOGICAL_GRID_CELL_SIZE, - LOGICAL_GRID_GAP, - MIN_ACCESSIBLE_CANVAS_SCALE, -} from "../apps/nextjs/src/components/board/layout/constants"; +import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP } from "../apps/nextjs/src/components/board/layout/constants"; import * as sqliteSchema from "../packages/db/schema/sqlite"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; @@ -26,7 +22,6 @@ const gridRuntimeMarker = "homarr-dnd-kit-v1"; const dndKitPayloadMarker = "dnd-kit-description"; const logicalCellSize = LOGICAL_GRID_CELL_SIZE; const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; -const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE; describe("Board grid", () => { test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => { @@ -100,6 +95,11 @@ describe("Board grid", () => { await expect(rail).toBeVisible(); await expect(rail).toHaveAttribute("data-board-gutter", "left"); await expect(railRootSection).toHaveAttribute("data-rail-placement", "left"); + await expect(rail).toHaveCSS("overflow-x", "hidden"); + const initialRailBox = await expectBoundingBoxAsync(rail); + const initialRailRootBox = await expectBoundingBoxAsync(railRootSection); + expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1); + expect(initialRailRootBox.height).toBeGreaterThanOrEqual(initialRailBox.height - 1); await expect(railItem).toHaveAttribute("data-grid-x", "0"); await expect(containerSection).toHaveAttribute("data-grid-w", "2"); await expect(containerSection).toHaveAttribute("data-grid-h", "2"); @@ -174,21 +174,15 @@ describe("Board grid", () => { expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01); // CSS zoom exercises Chromium's browser-zoom layout path without relying - // on desktop browser chrome. At 200% the scale floor must preserve - // magnification and expose horizontal scrolling instead of shrinking the - // board by the inverse zoom factor. + // on desktop browser chrome. The board must keep fitting without adding + // horizontal scrolling at any effective zoom. await setDocumentZoomAsync(page, 2); - await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale)); - await expect(canvas).toHaveAttribute("data-canvas-overflow", "true"); + const zoomedCanvasScale = await readCanvasScaleAsync(canvas); + expect(zoomedCanvasScale).toBeLessThan(normalCanvasScale); + await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); await expectFixedLogicalTileAsync(logicalTile); await expectUniformVisualScaleAsync(logicalTile); - const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => { - return Number.parseFloat(getComputedStyle(element).fontSize); - }); - expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25); - const zoomedTileBox = await expectBoundingBoxAsync(logicalTile); - expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25); - await expectHorizontalOverflowAsync(canvas); + await expect(canvas).toHaveCSS("overflow-x", "clip"); await expectDocumentNotHorizontallyScrollableAsync(page); const overflowRailY = (await expectBoundingBoxAsync(rail)).y; await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" })); @@ -251,6 +245,14 @@ describe("Board grid", () => { await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0); await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0); + await expect(rail).toHaveAttribute("data-board-editing", "true"); + await expect(rail).toHaveCSS("position", "sticky"); + await expect(rail).toHaveCSS("transform", "none"); + const railBox = await expectBoundingBoxAsync(rail); + expect(railBox.height + railBox.y).toBeCloseTo(page.viewportSize()?.height ?? 0, 0); + expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe( + "Left dashboard rail", + ); await expect(logicalTile).toHaveCSS("overflow-x", "hidden"); await expect(logicalTile).toHaveCSS("overflow-y", "auto"); await logicalTile.evaluate((element) => { @@ -337,8 +339,11 @@ describe("Board grid", () => { await expect(itemSelectDialog).toHaveCount(0); const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`); + const editorViewport = page.viewportSize(); + if (!editorViewport) throw new Error("Expected a browser viewport"); + expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1); const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid); - await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem); + await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem); await firstItem.locator("[data-grid-item-content]").evaluate((element) => { const iframe = document.createElement("iframe"); iframe.dataset.e2eDragIframe = "true"; @@ -367,8 +372,8 @@ describe("Board grid", () => { .evaluateAll((elements) => elements.forEach((element) => element.remove())); await page.setViewportSize({ width: 600, height: 1200 }); - await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale)); - await expectHorizontalOverflowAsync(canvas); + await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); + await expect(canvas).toHaveCSS("overflow-x", "clip"); await expectDocumentNotHorizontallyScrollableAsync(page); for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) { await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`)); @@ -718,13 +723,12 @@ describe("Board grid", () => { expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale); await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 }); - await expect(containerSection).toHaveAttribute("data-grid-w", "2"); + await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1"); expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1); - const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor); const liveContainerBox = await expectBoundingBoxAsync(containerSection); - expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1); - expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1); + expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1); + await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip"); await page.mouse.up(); await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizeOutline).toHaveCount(0); @@ -759,15 +763,26 @@ describe("Board grid", () => { await expect(containerSection).toHaveAttribute("data-grid-h", "2"); const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]'); - await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale); + const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height; + const southResizeBox = await expectBoundingBoxAsync(southResizeHandle); + const southResizeStart = { + x: southResizeBox.x + southResizeBox.width / 2, + y: southResizeBox.y + southResizeBox.height / 2, + }; + await page.mouse.move(southResizeStart.x, southResizeStart.y); + await page.mouse.down(); + await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 }); await expect(containerSection).toHaveAttribute("data-grid-h", "3"); await expect(belowItem).toHaveAttribute("data-grid-y", "3"); + await expect + .poll(async () => (await expectBoundingBoxAsync(mainGrid)).height) + .toBeGreaterThanOrEqual(gridHeightBeforeSouthResize); + await page.mouse.up(); expect( gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)), ).toBe(false); - // Auto-scroll needs real document overflow. Grow the canvas through an - // explicit resize instead of relying on edit mode to add empty rows. + // Grow the canvas further to exercise sustained document auto-scroll. await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6); await expect(containerSection).toHaveAttribute("data-grid-h", "9"); await expect(belowItem).toHaveAttribute("data-grid-y", "9"); @@ -843,14 +858,10 @@ describe("Board grid", () => { false, ); - const stickyRailY = (await expectBoundingBoxAsync(rail)).y; await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" })); - await expect - .poll(async () => { - const y = (await expectBoundingBoxAsync(rail)).y; - return y >= 0 && y <= stickyRailY + 0.5; - }) - .toBe(true); + const stickyRailY = (await expectBoundingBoxAsync(rail)).y; + await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" })); + await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeCloseTo(stickyRailY, 0); await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" })); await setDocumentZoomAsync(page, 1.25); @@ -979,6 +990,15 @@ describe("Board grid", () => { const emptyRightGutter = page.getByRole("complementary", { name: "Right dashboard rail" }); await expect(emptyRightGutter).toHaveCount(1); expect((await expectBoundingBoxAsync(emptyRightGutter)).width).toBeGreaterThan(0); + await page.getByTestId("board-edit-mode-toggle").click(); + await expect(emptyRightGutter).toHaveAttribute("data-board-editing", "true"); + expect( + await emptyRightGutter.evaluate((element) => ({ + content: getComputedStyle(element, "::before").content.replaceAll('"', ""), + insetInlineStart: getComputedStyle(element, "::before").insetInlineStart, + top: getComputedStyle(element, "::before").top, + })), + ).toEqual({ content: "Right dashboard rail", insetInlineStart: "0px", top: "0px" }); expect(pageErrors).toEqual([]); } finally { runtimeResources?.stop(); @@ -1713,9 +1733,10 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); }; -const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => { +const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => { const snapshot = await readEditorGridSnapshotAsync(grid); const gridBox = await expectBoundingBoxAsync(grid); + const canvasBox = await expectBoundingBoxAsync(canvas); const itemBox = await expectBoundingBoxAsync(locator); const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 }; @@ -1723,14 +1744,25 @@ const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, await page.mouse.down(); await page.mouse.move(start.x + 8, start.y); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); - await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 }); - await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0); - const activeGridBox = await expectBoundingBoxAsync(grid); - expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1); + await expect.poll(async () => (await expectBoundingBoxAsync(grid)).height).toBeGreaterThan(gridBox.height); + await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, { + steps: 12, + }); + await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1); + const activeGridBox = await expectBoundingBoxAsync(grid); + expect(activeGridBox.height).toBeGreaterThan(gridBox.height); + + await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 }); + const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]")); + expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1); + + await page.keyboard.press("Escape"); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); + await locator.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); + await expectGridEntryGeometrySettledAsync(locator); }; const expectInvalidNestedDropRestoresSnapshotAsync = async ( @@ -2036,18 +2068,6 @@ const expectUniformVisualScaleAsync = async (locator: Locator) => { expect(scale.horizontal).toBeCloseTo(scale.vertical, 3); }; -const expectHorizontalOverflowAsync = async (locator: Locator) => { - const dimensions = await locator.evaluate((element) => { - const htmlElement = element as HTMLElement; - return { - clientWidth: htmlElement.clientWidth, - scrollWidth: htmlElement.scrollWidth, - }; - }); - - expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth); -}; - const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => { await expect .poll( diff --git a/e2e/health-checks.spec.ts b/e2e/health-checks.spec.ts index 411d8e29c..d2b179f4f 100644 --- a/e2e/health-checks.spec.ts +++ b/e2e/health-checks.spec.ts @@ -4,9 +4,12 @@ import { createHomarrContainer } from "./shared/create-homarr-container"; import { createRedisContainer } from "./shared/redis-container"; describe("Health checks", () => { - test("ready and live should return 200 OK with normal image and no extra configuration", async () => { + test("ready and live should return 200 OK with normal image and no extra configuration", async ({ + onTestFinished, + }) => { // Arrange const homarrContainer = await createHomarrContainer().start(); + onTestFinished(() => homarrContainer.stop()); try { // Act @@ -21,9 +24,10 @@ describe("Health checks", () => { } }, 60_000); - test("ready and live should return 200 OK with external redis", async () => { + test("ready and live should return 200 OK with external redis", async ({ onTestFinished }) => { // Arrange const redisContainer = await createRedisContainer().start(); + onTestFinished(() => redisContainer.stop()); const homarrContainer = await createHomarrContainer({ environment: { REDIS_IS_EXTERNAL: "true", @@ -32,6 +36,7 @@ describe("Health checks", () => { REDIS_PASSWORD: redisContainer.getPassword(), }, }).start(); + onTestFinished(() => homarrContainer.stop()); try { // Act diff --git a/e2e/shared/login.ts b/e2e/shared/login.ts new file mode 100644 index 000000000..7a3e3be1c --- /dev/null +++ b/e2e/shared/login.ts @@ -0,0 +1,36 @@ +import type { Page } from "@playwright/test"; + +interface LoginInput { + page: Page; + baseUrl: string; + credentials: { + username: string; + password: string; + }; + destination?: string; +} + +export const loginAsync = async ({ page, baseUrl, credentials, destination = "/" }: LoginInput) => { + const destinationUrl = new URL(destination, baseUrl); + const loginUrl = new URL("/auth/login", baseUrl); + loginUrl.searchParams.set("callbackUrl", `${destinationUrl.pathname}${destinationUrl.search}${destinationUrl.hash}`); + + await page.goto(loginUrl.href, { waitUntil: "load" }); + await page.waitForFunction(() => { + const form = document.querySelector("form"); + return form !== null && Object.keys(form).some((key) => key.startsWith("__reactProps$")); + }); + await page.getByLabel("Username").fill(credentials.username); + await page.locator("#password").fill(credentials.password); + + const destinationNavigation = page.waitForURL( + (url) => + url.origin === destinationUrl.origin && + url.pathname === destinationUrl.pathname && + url.search === destinationUrl.search && + url.hash === destinationUrl.hash, + { timeout: 60_000 }, + ); + await page.locator("button[type='submit']").click(); + await destinationNavigation; +}; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index d7cbebb39..3053da760 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -70,6 +70,7 @@ import { throwIfCustomWidgetPlacementChangeForbidden, } from "./board/custom-widget-placement-access"; import { generateResponsiveGridFor } from "./board/grid-algorithm"; +import { validateTimetableOptionsChangeAsync } from "./widgets/timetable"; export const boardRouter = createTRPCRouter({ exists: permissionRequiredProcedure @@ -894,6 +895,15 @@ export const boardRouter = createTRPCRouter({ storedItems: dbBoard.items, }); + for (const item of input.items) { + if (item.kind !== "timetable") continue; + const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id); + await validateTimetableOptionsChangeAsync( + item.options, + previousItem?.kind === "timetable" ? previousItem.options : undefined, + ); + } + await handleTransactionsAsync(ctx.db, { async handleAsync(db, schema) { await db.transaction(async (transaction) => { @@ -1479,6 +1489,10 @@ export const boardRouter = createTRPCRouter({ storedItems: [], }); + if (input.kind === "timetable") { + await validateTimetableOptionsChangeAsync(input.options); + } + if (input.integrationIds.length > 0) { const existing = await ctx.db.query.integrations.findMany({ columns: { id: true }, diff --git a/packages/api/src/router/custom-widget/custom-widget-access.ts b/packages/api/src/router/custom-widget/custom-widget-access.ts new file mode 100644 index 000000000..90abeebc3 --- /dev/null +++ b/packages/api/src/router/custom-widget/custom-widget-access.ts @@ -0,0 +1,45 @@ +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; +import { z } from "zod/v4"; + +import type { Session } from "@homarr/auth"; +import type { Database } from "@homarr/db"; +import { and, eq } from "@homarr/db"; +import { boards, items } from "@homarr/db/schema"; +import type { BoardPermission } from "@homarr/definitions"; + +import { throwIfActionForbiddenAsync } from "../board/board-access"; + +export const customApiItemInputSchema = z.object({ + boardId: z.string().min(1).max(100), + itemId: z.string().min(1).max(100), + definitionId: z.string().min(1).max(100), +}); + +const customApiOptionsSchema = z.object({ definitionId: z.string() }).passthrough(); + +export async function assertCustomApiItemBindingAsync( + ctx: { db: Database; session: Session | null }, + input: z.infer, + permission?: BoardPermission, +) { + const item = await ctx.db.query.items.findFirst({ + columns: { options: true }, + where: and(eq(items.id, input.itemId), eq(items.boardId, input.boardId), eq(items.kind, "customApi")), + }); + + if (!item) throwCustomApiWidgetNotFound(); + if (permission) await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), permission); + + try { + const options = customApiOptionsSchema.parse(parse(item.options)); + if (options.definitionId !== input.definitionId) throwCustomApiWidgetNotFound(); + } catch (error) { + if (error instanceof TRPCError) throw error; + throwCustomApiWidgetNotFound(); + } +} + +function throwCustomApiWidgetNotFound(): never { + throw new TRPCError({ code: "NOT_FOUND", message: "Custom API widget not found" }); +} diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts index 99bb9c3af..050d66cb4 100644 --- a/packages/api/src/router/location.ts +++ b/packages/api/src/router/location.ts @@ -37,11 +37,10 @@ export const locationRouter = createTRPCRouter({ .input(locationSearchCityInput) .output(locationSearchCityOutput) .query(async ({ input }) => { + const url = new URL("https://geocoding-api.open-meteo.com/v1/search"); + url.searchParams.set("name", input.query); const res = await withTimeoutAsync(async (signal) => { - return await fetchWithTrustedCertificatesAsync( - `https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`, - { signal }, - ); + return await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); }); return (await res.json()) as z.infer; }), diff --git a/packages/api/src/router/test/location.spec.ts b/packages/api/src/router/test/location.spec.ts new file mode 100644 index 000000000..64c6cdfb4 --- /dev/null +++ b/packages/api/src/router/test/location.spec.ts @@ -0,0 +1,36 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +import { locationRouter } from "../location"; + +const mocks = vi.hoisted(() => ({ fetch: vi.fn() })); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: mocks.fetch, +})); + +vi.mock("@homarr/core/infrastructure/http/timeout", () => ({ + withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise) => + await callback(new AbortController().signal), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] }))); +}); + +describe("locationRouter", () => { + test("encodes the complete search text as one name parameter", async () => { + const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null }); + + await caller.searchCity({ query: "Paris&language=xx&token=secret" }); + + const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string); + expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret"); + expect(requestedUrl.searchParams.has("language")).toBe(false); + expect(requestedUrl.searchParams.has("token")).toBe(false); + }); +}); diff --git a/packages/api/src/router/test/widgets/immich.spec.ts b/packages/api/src/router/test/widgets/immich.spec.ts new file mode 100644 index 000000000..eec383aaa --- /dev/null +++ b/packages/api/src/router/test/widgets/immich.spec.ts @@ -0,0 +1,76 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { integrations, integrationUserPermissions, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { immichRouter } from "../../widgets/immich"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; +}); + +const mocks = vi.hoisted(() => ({ + albumsHandler: vi.fn(), +})); + +vi.mock("@homarr/request-handler/immich", () => ({ + immichAlbumRequestHandler: { handler: vi.fn() }, + immichStatsRequestHandler: { handler: vi.fn() }, + immichAlbumsRequestHandler: { + handler: (_integration: unknown, input: unknown) => { + mocks.albumsHandler(input); + return { getDataAsync: async () => ({ data: [], timestamp: new Date() }) }; + }, + }, +})); + +const createSession = (userId: string): Session => ({ + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const createCallerAsync = async () => { + const db = createDb(); + const userId = createId(); + const integrationId = createId(); + await db.insert(users).values({ id: userId }); + await db.insert(integrations).values({ + id: integrationId, + kind: "immich", + name: "Immich", + url: "https://immich.example.com", + }); + await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" }); + + return { + integrationId, + caller: immichRouter.createCaller({ db: db as Database, deviceType: undefined, session: createSession(userId) }), + }; +}; + +describe("immichRouter.getAlbums", () => { + beforeEach(() => vi.clearAllMocks()); + + test("passes a validated limit to the cached request handler", async () => { + const { caller, integrationId } = await createCallerAsync(); + + await caller.getAlbums({ integrationId, limit: 50 }); + + expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: 50 }); + await expect(caller.getAlbums({ integrationId, limit: 0 })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect(caller.getAlbums({ integrationId, limit: 501 })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + test("keeps the limit optional for unbounded callers", async () => { + const { caller, integrationId } = await createCallerAsync(); + + await caller.getAlbums({ integrationId }); + + expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: undefined }); + }); +}); diff --git a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts new file mode 100644 index 000000000..d72d125d2 --- /dev/null +++ b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts @@ -0,0 +1,284 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { integrations, integrationUserPermissions, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import type { IntegrationKind } from "@homarr/definitions"; +import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types"; +import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list"; + +import { calendarRouter } from "../../widgets/calendar"; +import { beszelRouter } from "../../widgets/beszel"; +import { firewallRouter } from "../../widgets/firewall"; +import { indexerManagerRouter } from "../../widgets/indexer-manager"; +import { mediaOrganizerRouter } from "../../widgets/media-organizer"; +import { mediaRequestsRouter } from "../../widgets/media-requests"; +import { mediaServerRouter } from "../../widgets/media-server"; +import { notificationsRouter } from "../../widgets/notifications"; +import { vpnRouter } from "../../widgets/vpn"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; +}); + +const createHandler = (integration: { name: string }, data: T) => ({ + getDataAsync: async () => { + if (integration.name === "Offline") { + throw new Error( + "GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body", + ); + } + return { data, timestamp: new Date() }; + }, +}); + +vi.mock("@homarr/request-handler/calendar", () => ({ + calendarMonthRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/media-server", () => ({ + mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/media-organizer", () => ({ + mediaOrganizerRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }), + }, +})); + +vi.mock("@homarr/request-handler/beszel", () => ({ + beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/firewall", () => ({ + firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) }, + firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, + firewallMemoryRequestHandler: { + handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }), + }, + firewallVersionRequestHandler: { + handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }), + }, +})); + +vi.mock("@homarr/request-handler/indexer-manager", () => ({ + indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/notifications", () => ({ + notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/vpn", () => ({ + vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) }, +})); + +vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mediaRequestListRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, [ + { + id: 42, + name: "Healthy request", + type: "movie", + backdropImageUrl: "https://example.com/backdrop.jpg", + posterImagePath: "https://example.com/poster.jpg", + href: "https://example.com/request/42", + createdAt: new Date("2026-08-01T12:00:00Z"), + status: "pending", + availability: "requested", + } satisfies MediaRequest, + ]), + }, + }; +}); + +vi.mock("@homarr/request-handler/media-request-stats", () => ({ + mediaRequestStatsRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + stats: { total: 0, movie: 0, tv: 0, pending: 0, approved: 0, declined: 0, processing: 0, available: 0 }, + users: [ + { + id: 7, + displayName: "Healthy user", + avatar: "https://example.com/avatar.jpg", + requestCount: 3, + link: "https://example.com/user/7", + }, + ], + } satisfies MediaRequestStats), + }, +})); + +const createSession = (userId: string): Session => ({ + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const setupAsync = async (kind: IntegrationKind) => { + const db = createDb(); + const userId = createId(); + const onlineId = createId(); + const offlineId = createId(); + await db.insert(users).values({ id: userId }); + await db.insert(integrations).values([ + { id: onlineId, kind, name: "Online", url: "https://online.example.com" }, + { id: offlineId, kind, name: "Offline", url: "https://offline.example.com" }, + ]); + await db.insert(integrationUserPermissions).values([ + { integrationId: onlineId, userId, permission: "use" }, + { integrationId: offlineId, userId, permission: "use" }, + ]); + return { + db: db as Database, + session: createSession(userId), + integrationIds: [onlineId, offlineId], + onlineId, + offlineId, + }; +}; + +beforeEach(() => vi.clearAllMocks()); + +describe("partial integration failures", () => { + test("calendar and media server retain a failed integration record", async () => { + const calendar = await setupAsync("sonarr"); + const calendarResults = await calendarRouter + .createCaller({ db: calendar.db, deviceType: undefined, session: calendar.session }) + .findAllEvents({ + integrationIds: calendar.integrationIds, + year: 2026, + month: 7, + releaseType: [], + showUnmonitored: false, + }); + expect(calendarResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integration: expect.objectContaining({ id: calendar.offlineId }), + error: "INTEGRATION_REQUEST_FAILED", + }), + ]), + ); + expect(JSON.stringify(calendarResults)).not.toContain("secret"); + + const mediaServer = await setupAsync("plex"); + const streamResults = await mediaServerRouter + .createCaller({ db: mediaServer.db, deviceType: undefined, session: mediaServer.session }) + .getCurrentStreams({ integrationIds: mediaServer.integrationIds, showOnlyPlaying: false }); + expect(streamResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integrationId: mediaServer.offlineId, + sessions: [], + error: "INTEGRATION_REQUEST_FAILED", + }), + ]), + ); + expect(JSON.stringify(streamResults)).not.toContain("secret"); + }); + + test("media request responses expose failed integrations without dropping successful data", async () => { + const mediaRequests = await setupAsync("overseerr"); + const caller = mediaRequestsRouter.createCaller({ + db: mediaRequests.db, + deviceType: undefined, + session: mediaRequests.session, + }); + + const latest = await caller.getLatestRequests({ + integrationIds: mediaRequests.integrationIds, + statuses: ["pending"], + recentDays: 0, + }); + expect(latest.requests).toEqual([ + expect.objectContaining({ + id: 42, + name: "Healthy request", + integrationId: mediaRequests.onlineId, + integration: expect.objectContaining({ id: mediaRequests.onlineId }), + }), + ]); + expect(latest.failedIntegrations).toEqual([ + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }), + ]); + expect(JSON.stringify(latest)).not.toContain("secret"); + + const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds }); + expect(stats.failedIntegrations).toEqual([ + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }), + ]); + expect(JSON.stringify(stats)).not.toContain("secret"); + expect(stats.stats).toHaveLength(1); + expect(stats.users).toEqual([ + expect.objectContaining({ + id: 7, + displayName: "Healthy user", + integration: expect.objectContaining({ id: mediaRequests.onlineId }), + }), + ]); + }); + + test("aggregate widget payloads never expose sensitive partial-failure details", async () => { + const cases = [ + { + kind: "sonarr" as const, + run: async (setup: Awaited>) => + await mediaOrganizerRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getData({ integrationIds: setup.integrationIds, pageSize: 10 }), + }, + { + kind: "beszel" as const, + run: async (setup: Awaited>) => + await beszelRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getSystems({ integrationIds: setup.integrationIds }), + }, + { + kind: "opnsense" as const, + run: async (setup: Awaited>) => + await firewallRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getFirewallCpuStatus({ integrationIds: setup.integrationIds }), + }, + { + kind: "prowlarr" as const, + run: async (setup: Awaited>) => + await indexerManagerRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getIndexersStatus({ integrationIds: setup.integrationIds }), + }, + { + kind: "ntfy" as const, + run: async (setup: Awaited>) => + await notificationsRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getNotifications({ integrationIds: setup.integrationIds }), + }, + { + kind: "gluetun" as const, + run: async (setup: Awaited>) => + await vpnRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getSummaries({ integrationIds: setup.integrationIds }), + }, + ]; + + for (const testCase of cases) { + const setup = await setupAsync(testCase.kind); + const payload = JSON.stringify(await testCase.run(setup)); + expect(payload).toContain("INTEGRATION_REQUEST_FAILED"); + expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i); + } + }); +}); diff --git a/packages/api/src/router/test/widgets/rssFeed.spec.ts b/packages/api/src/router/test/widgets/rssFeed.spec.ts index 4773fa149..d5fef09b6 100644 --- a/packages/api/src/router/test/widgets/rssFeed.spec.ts +++ b/packages/api/src/router/test/widgets/rssFeed.spec.ts @@ -1,5 +1,5 @@ import SuperJSON from "superjson"; -import { describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; @@ -17,7 +17,18 @@ import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; import type { WidgetComponentProps } from "../../../../../widgets/src/definition"; -import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed"; +import { canAccessAllFeedsAsync, restrictUrlsAsync, rssFeedRouter } from "../../widgets/rssFeed"; + +const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, getFeed: vi.fn() })); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => mocks.logger })); +vi.mock("@homarr/request-handler/rss-feeds", () => ({ + rssFeedsRequestHandler: { + handler: (input: unknown) => ({ getDataAsync: async () => await mocks.getFeed(input) }), + }, +})); + +beforeEach(() => vi.clearAllMocks()); const createSession = (permissions: GroupPermissionKey[] = []): Session => ({ user: { @@ -29,6 +40,53 @@ const createSession = (permissions: GroupPermissionKey[] = []): Session => ({ }); describe("rssFeedRouter", () => { + describe("getFeeds", () => { + test("redacts feed secrets from logs and returns a generic error when every feed fails", async () => { + const feedUrl = "https://user:password@example.com/feed?token=secret"; + mocks.getFeed.mockRejectedValue(new Error(`Failed to fetch ${feedUrl}`)); + const caller = rssFeedRouter.createCaller({ + db: createDb(), + deviceType: undefined, + session: createSession(["board-create"]), + }); + + await expect(caller.getFeeds({ urls: [feedUrl], maximumAmountPosts: 5 })).rejects.toMatchObject({ + code: "BAD_GATEWAY", + message: "All RSS feed requests failed", + }); + expect(mocks.logger.warn).toHaveBeenCalledWith("RSS feed fetch failed", { + feedIndex: 0, + origin: "https://example.com", + errorType: "Error", + errorMessage: "Failed to fetch [redacted URL]", + }); + const logged = JSON.stringify(mocks.logger.warn.mock.calls); + expect(logged).not.toContain("password"); + expect(logged).not.toContain("token"); + expect(logged).not.toContain("secret"); + }); + + test("returns successful entries and only a failure count when some feeds fail", async () => { + const failedUrl = "https://user:password@example.com/feed?token=secret"; + mocks.getFeed.mockImplementation(async (input: { url: string }) => { + if (input.url === failedUrl) throw new Error(`Failed to fetch ${failedUrl}`); + return { data: { entries: [{ id: "entry" }] } }; + }); + const caller = rssFeedRouter.createCaller({ + db: createDb(), + deviceType: undefined, + session: createSession(["board-create"]), + }); + + const result = await caller.getFeeds({ urls: ["https://example.com/feed", failedUrl], maximumAmountPosts: 5 }); + + expect(result).toEqual({ entries: [{ id: "entry" }], failedFeedCount: 1 }); + expect(JSON.stringify(result)).not.toContain("password"); + expect(JSON.stringify(result)).not.toContain("token"); + expect(JSON.stringify(result)).not.toContain("secret"); + }); + }); + describe("canAccessAllFeedsAsync", () => { test("should return false for unauthenticated users", async () => { // Arrange diff --git a/packages/api/src/router/test/widgets/timetable.spec.ts b/packages/api/src/router/test/widgets/timetable.spec.ts new file mode 100644 index 000000000..d3b31a046 --- /dev/null +++ b/packages/api/src/router/test/widgets/timetable.spec.ts @@ -0,0 +1,381 @@ +// @vitest-environment node + +import { parse, stringify } from "superjson"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { eq } from "@homarr/db"; +import { boards, items, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { boardRouter } from "../../board"; +import { optionsRouter } from "../../widgets/options"; +import { timetableRouter } from "../../widgets/timetable"; + +const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() })); + +vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup })); + +vi.mock("@homarr/request-handler/timetable", () => ({ + timetableGetTimetableRequestHandler: { + handler: (input: unknown) => ({ + getDataAsync: async () => { + mocks.getTimetable(input); + return { data: { stationId: "station", timestamp: new Date(0), entries: [] } }; + }, + }), + }, + timetableSearchStationsRequestHandler: { + handler: (input: unknown) => ({ + getDataAsync: async () => { + mocks.searchStations(input); + return { data: [] }; + }, + }), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]); +}); + +const createSession = (userId: string): Session => ({ + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const createCaller = (db: Database, session: Session | null) => + timetableRouter.createCaller({ db, deviceType: undefined, session }); + +const createTimetableItemAsync = async ( + db: Database, + { baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean }, +) => { + const ownerId = createId(); + const boardId = createId(); + const itemId = createId(); + await db.insert(users).values({ id: ownerId }); + await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic }); + await db.insert(items).values({ + id: itemId, + boardId, + kind: "timetable", + options: stringify({ baseUrl }), + }); + return { boardId, itemId, ownerId }; +}; + +describe("timetableRouter source authorization", () => { + it("allows itemless setup only for the fixed default provider", async () => { + const caller = createCaller(createDb(), null); + + await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]); + await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("reports invalid timetable URLs as bad requests", async () => { + const caller = createCaller(createDb(), null); + + await expect( + caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("rejects a persisted private endpoint at runtime even through a viewable timetable item", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { itemId } = await createTimetableItemAsync(db, { baseUrl }); + const caller = createCaller(db, null); + + await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mocks.getTimetable).not.toHaveBeenCalled(); + await expect( + caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("rejects a persisted endpoint when its board is not viewable", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + + await expect( + createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("allows board modifiers to search an unsaved public endpoint", async () => { + const db = createDb(); + const baseUrl = "https://timetable.example.com"; + const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false }); + const caller = createCaller(db, createSession(ownerId)); + + await caller.searchStations({ baseUrl, boardId, query: "Bern" }); + expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true }); + expect(mocks.searchStations).toHaveBeenLastCalledWith( + expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }), + ); + }); + + it.each([ + "http://127.0.0.1:8080", + "http://timetable.internal:8080", + "http://[::1]:8080", + "http://[::ffff:127.0.0.1]:8080", + "https://[fd00::1]:8443", + "https://192.88.99.1", + "http://[64:ff9b::7f00:1]:8080", + "http://[2002:7f00:1::]:8080", + ])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => { + const db = createDb(); + const { boardId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "https://search.ch", + isPublic: false, + }); + const caller = createCaller(db, createSession(ownerId)); + + await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mocks.searchStations).not.toHaveBeenCalled(); + }); + + it("blocks an RFC 2765 IPv4-translated loopback address", async () => { + const db = createDb(); + const { boardId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "https://search.ch", + isPublic: false, + }); + const caller = createCaller(db, createSession(ownerId)); + + await expect( + caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.searchStations).not.toHaveBeenCalled(); + }); + + it("blocks an unsaved public hostname that resolves to a private address", async () => { + mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]); + const db = createDb(); + const { boardId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "https://search.ch", + isPublic: false, + }); + const caller = createCaller(db, createSession(ownerId)); + + await expect( + caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.searchStations).not.toHaveBeenCalled(); + }); + + it("rejects the exact saved private endpoint at runtime", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + + await expect( + createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + expect(mocks.lookup).not.toHaveBeenCalled(); + expect(mocks.searchStations).not.toHaveBeenCalled(); + }); + + it("re-resolves and pins a saved public endpoint for searches and timetable requests", async () => { + const db = createDb(); + const baseUrl = "https://timetable.example.com"; + const pinnedAddresses = [ + { address: "93.184.216.34", family: 4 }, + { address: "93.184.216.35", family: 4 }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, + ]; + mocks.lookup.mockResolvedValue([pinnedAddresses[2], pinnedAddresses[1], pinnedAddresses[0]]); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + const caller = createCaller(db, createSession(ownerId)); + + await caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" }); + await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }); + + expect(mocks.lookup).toHaveBeenCalledTimes(2); + expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses })); + expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses })); + }); + + it("rejects a saved public hostname when it resolves to a private address at request time", async () => { + const db = createDb(); + const baseUrl = "https://timetable.example.com"; + mocks.lookup.mockResolvedValue([{ address: "10.0.0.8", family: 4 }]); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + const caller = createCaller(db, createSession(ownerId)); + + await expect(caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mocks.searchStations).not.toHaveBeenCalled(); + expect(mocks.getTimetable).not.toHaveBeenCalled(); + }); + + it("blocks changing a saved timetable to a private endpoint through item options", async () => { + const db = createDb(); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "https://search.ch", + isPublic: false, + }); + const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) }); + + await expect( + caller.saveItemOptions({ boardId, itemId, newOptions: { baseUrl: "http://127.0.0.1:8080" } }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + const item = await db.query.items.findFirst({ where: eq(items.id, itemId) }); + expect(parse>(item?.options ?? "{}")).toMatchObject({ baseUrl: "https://search.ch" }); + }); + + it("blocks changing a saved timetable to a private endpoint through a board save", async () => { + const db = createDb(); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "https://search.ch", + isPublic: false, + }); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) }); + + await expect( + caller.saveBoard({ + id: boardId, + sections: [], + items: [ + { + id: itemId, + kind: "timetable", + options: { baseUrl: "http://127.0.0.1:8080" }, + integrationIds: [], + layouts: [], + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + }, + ], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("blocks adding a timetable with a private endpoint", async () => { + const db = createDb(); + const { boardId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "https://search.ch", + isPublic: false, + }); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) }); + + await expect( + caller.addItem({ + boardId, + kind: "timetable", + options: { baseUrl: "http://127.0.0.1:8080" }, + integrationIds: [], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("rejects an item ID that does not belong to the requested board", async () => { + const db = createDb(); + const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false }); + const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false }); + + await expect( + createCaller(db, createSession(second.ownerId)).searchStations({ + baseUrl: "http://timetable.internal:8080", + boardId: second.boardId, + itemId: first.itemId, + query: "Bern", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(mocks.searchStations).not.toHaveBeenCalled(); + }); + + it("blocks a changed private endpoint for an existing item", async () => { + const db = createDb(); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "http://timetable.internal:8080", + isPublic: false, + }); + + await expect( + createCaller(db, createSession(ownerId)).searchStations({ + baseUrl: "http://127.0.0.1:8080", + boardId, + itemId, + query: "Bern", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.searchStations).not.toHaveBeenCalled(); + }); + + it("allows a changed public endpoint for an existing item and pins its address", async () => { + const db = createDb(); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { + baseUrl: "http://timetable.internal:8080", + isPublic: false, + }); + const baseUrl = "https://timetable.example.com"; + + await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }); + + expect(mocks.searchStations).toHaveBeenLastCalledWith( + expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }), + ); + }); + + it("reports malformed saved widget options as server data corruption", async () => { + const db = createDb(); + const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" }); + await db.update(items).set({ options: "{" }).where(eq(items.id, itemId)); + + await expect( + createCaller(db, null).getTimetable({ + baseUrl: "https://search.ch", + itemId, + stationId: "station", + limit: 10, + }), + ).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" }); + }); + + it("reports an invalid saved timetable URL as server data corruption", async () => { + const db = createDb(); + const { itemId } = await createTimetableItemAsync(db, { + baseUrl: "https://search.ch?redirect=/admin", + }); + + await expect( + createCaller(db, null).getTimetable({ + baseUrl: "https://search.ch", + itemId, + stationId: "station", + limit: 10, + }), + ).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" }); + }); + + it("does not treat public board view access as configuration access", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl }); + + await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); diff --git a/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts new file mode 100644 index 000000000..729e5f4c8 --- /dev/null +++ b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts @@ -0,0 +1,20 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security"; + +describe("custom API request hardening", () => { + it("accepts HTTP endpoints without embedded credentials", () => { + expect(validateCustomApiUrl("https://api.example.com/v1/data").hostname).toBe("api.example.com"); + }); + + it.each(["file:///etc/passwd", "https://user:secret@example.com/data"])("rejects unsafe URL %s", (url) => { + expect(() => validateCustomApiUrl(url)).toThrow(); + }); + + it("rejects a response once its byte budget is exceeded", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + await expect(readBoundedJsonResponseAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); +}); diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 738adecf7..c52ca1717 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -10,7 +10,7 @@ import { beszelSystemsRequestHandler, } from "@homarr/request-handler/beszel"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { BoundedAsyncQueue } from "./bounded-async-queue"; @@ -18,8 +18,6 @@ import { BoundedAsyncQueue } from "./bounded-async-queue"; const logger = createLogger({ module: "beszelRouter" }); const MAX_PENDING_LIVE_EVENTS = 4; -const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); - export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure .meta({ @@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({ return { integrationId: integration.id, integrationName: integration.name, - integrationUrl: integration.url, systems: data, updatedAt: timestamp, }; @@ -50,11 +47,11 @@ export const beszelRouter = createTRPCRouter({ fallback: (integration, error) => ({ integrationId: integration.id, integrationName: integration.name, - integrationUrl: integration.url, systems: [], updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }), + throwOnAllFailures: true, }, ); logger.debug("getSystems completed", { @@ -126,8 +123,9 @@ export const beszelRouter = createTRPCRouter({ history: [], systemNameMap: {}, updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }), + throwOnAllFailures: true, }, ); logger.debug("getAlerts completed", { @@ -192,7 +190,7 @@ export const beszelRouter = createTRPCRouter({ systemStats: [], containerStats: [], updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }; } }), @@ -269,15 +267,14 @@ export const beszelRouter = createTRPCRouter({ integrationId: integration.id, systemId: input.systemId, emittedEventCount, - error: errorMessage(error), + error, }); queue.fail( - error instanceof TRPCError - ? error - : new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : String(error), - }), + new TRPCError({ + code: "BAD_GATEWAY", + message: "Live integration request failed", + cause: error, + }), ); } })(); diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 729e82634..8e9ee31ef 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,16 +1,13 @@ import { z } from "zod/v4"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const logger = createLogger({ module: "calendarRouter" }); - export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure .meta({ @@ -30,8 +27,9 @@ export const calendarRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .query(async ({ ctx, input }) => { - const settled = await Promise.allSettled( - ctx.integrations.map(async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { integrationIds: _integrationIds, ...handlerInput } = input; const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); const { data } = await innerHandler.getDataAsync(); @@ -43,26 +41,21 @@ export const calendarRouter = createTRPCRouter({ name: integration.name, kind: integration.kind, }, + error: undefined as string | undefined, }; - }), - ); - - return settled.flatMap((result, index) => { - if (result.status === "fulfilled") { - return [result.value]; - } - const integration = ctx.integrations[index]; - logger.warn( - new ErrorWithMetadata( - "Calendar integration request failed; skipping events for this integration", - { - integrationId: integration?.id, - integrationKind: integration?.kind, + }, + { + fallback: (integration) => ({ + events: [], + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, }, - { cause: result.reason }, - ), - ); - return []; - }); + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/custom-api-security.ts b/packages/api/src/router/widgets/custom-api-security.ts new file mode 100644 index 000000000..abe005055 --- /dev/null +++ b/packages/api/src/router/widgets/custom-api-security.ts @@ -0,0 +1,67 @@ +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024; +export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000; + +export function validateCustomApiUrl(urlString: string): URL { + if (urlString.length > 2_048) throw new Error("Custom API URL is too long"); + + const url = new URL(urlString); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Custom API URL must use HTTP or HTTPS"); + } + if (url.username || url.password) throw new Error("Custom API URL must not contain credentials"); + return url; +} + +export async function readBoundedJsonResponseAsync( + response: Response, + maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Custom API response"); +} + +export async function consumeBoundedResponseAsync( + response: Response, + maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel(); + throw new Error(`Custom API response exceeds ${maxBytes} bytes`); + } + + if (!response.body) return; + const reader = response.body.getReader(); + let byteLength = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + byteLength += value?.byteLength ?? 0; + if (byteLength > maxBytes) { + await reader.cancel(); + throw new Error(`Custom API response exceeds ${maxBytes} bytes`); + } + } +} + +export async function withCustomApiResponseAsync( + url: URL, + init: Omit, + consumeResponseAsync: (response: Response) => Promise, + timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + ...init, + redirect: "error", + signal: controller.signal, + }); + return await consumeResponseAsync(response); + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index d77125f90..778788e58 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -33,7 +33,6 @@ const namedRequestInputSchema = itemInputSchema.extend({ requestId: z.string().min(1).max(64), params: runtimeParamsSchema.default({}), }); - interface CustomWidgetItemOptions { definitionId: string; configuration: Record; diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 08951da7c..335f75d09 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -7,7 +7,7 @@ import { } from "@homarr/request-handler/firewall"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")); @@ -18,20 +18,33 @@ const queryFirewall = < }, >( handler: THandler, + createFallback: () => Awaited["getDataAsync"]>>["data"], ) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => - settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - summary: data as Awaited["getDataAsync"]>>["data"], - }; - }), + settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + summary: data as Awaited["getDataAsync"]>>["data"], + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) }, + summary: createFallback(), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ), ); export const firewallRouter = createTRPCRouter({ - getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler), - getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler), - getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler), - getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler), + getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })), + getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []), + getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })), + getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })), }); diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts index aa917271f..136d0e111 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -34,9 +34,16 @@ export const immichRouter = createTRPCRouter({ return data.data; }), - getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { - const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {}); - const data = await innerHandler.getDataAsync(); - return data.data; - }), + getAlbums: publicProcedure + .input( + z.object({ + limit: z.number().int().min(1).max(500).optional(), + }), + ) + .concat(createImmichIntegrationMiddleware("query")) + .query(async ({ ctx, input }) => { + const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit }); + const data = await innerHandler.getDataAsync(); + return data.data; + }), }); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 54a949dab..4d1d893d8 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => @@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({ getIndexersStatus: publicProcedure .concat(createIndexerManagerIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = indexerManagerRequestHandler.handler(integration, {}); - const { data: indexers } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = indexerManagerRequestHandler.handler(integration, {}); + const { data: indexers } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - indexers, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + indexers, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + indexers: [], + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), testAllIndexers: protectedProcedure .concat(createIndexerManagerIntegrationMiddleware("interact")) diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index dd0d687cb..91738b4a8 100644 --- a/packages/api/src/router/widgets/media-organizer.ts +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaOrganizerRouter = createTRPCRouter({ @@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer"))) .input(z.object({ pageSize: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize }); const { data } = await innerHandler.getDataAsync(); return { integrationId: integration.id, + integrationName: integration.name, ...data, + error: undefined as string | undefined, }; - }), + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + missing: [], + missingCount: 0, + queued: [], + queuedCount: 0, + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, ); - return results; }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index fbd192499..28d0cb15a 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -1,7 +1,9 @@ import { z } from "zod/v4"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaRequestListInputSchema, @@ -10,9 +12,15 @@ import { import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; +interface IntegrationQueryResult { + integration: { id: string; name: string; kind: IntegrationKindByCategory<"mediaRequest"> }; + data: TData; + error?: string; +} + export const mediaRequestsRouter = createTRPCRouter({ getLatestRequests: publicProcedure .meta({ @@ -25,15 +33,31 @@ export const mediaRequestsRouter = createTRPCRouter({ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .input(mediaRequestListInputSchema) .query(async ({ ctx, input }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }); - return results + const results: IntegrationQueryResult[] = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration) => ({ + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: [], + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); + const requests = results .flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id, + integration, })), ) .toSorted((dataA, dataB) => { @@ -46,6 +70,12 @@ export const mediaRequestsRouter = createTRPCRouter({ mediaRequestStatusConfiguration[dataB.status].position ); }); + return { + requests, + failedIntegrations: results.flatMap(({ integration, error }) => + error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], + ), + }; }), getStats: publicProcedure .meta({ @@ -57,12 +87,30 @@ export const mediaRequestsRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }); + const results: IntegrationQueryResult<{ + stats: MediaRequestStats["stats"] | null; + users: MediaRequestStats["users"]; + }>[] = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: { ...data, stats: data.stats as typeof data.stats | null }, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration) => ({ + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: { stats: null, users: [] }, + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); return { - stats: results.flatMap((result) => result.data.stats), + stats: results.flatMap((result) => (result.data.stats ? [result.data.stats] : [])), users: results .map((result) => result.data.users.map((user) => ({ @@ -73,6 +121,9 @@ export const mediaRequestsRouter = createTRPCRouter({ .flat() .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), integrations: results.map((result) => result.integration), + failedIntegrations: results.flatMap(({ integration, error }) => + error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], + ), }; }), answerRequest: protectedProcedure @@ -99,5 +150,6 @@ export const mediaRequestsRouter = createTRPCRouter({ } as const; await answerActions[input.answer](input.requestId); + return { success: true }; }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 591d58d2b..77e00204b 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server" import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => @@ -23,11 +23,30 @@ export const mediaServerRouter = createTRPCRouter({ .concat(createMediaServerIntegrationMiddleware("query")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data } = await mediaServerRequestHandler - .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) - .getDataAsync(); - return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; - }); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaServerRequestHandler + .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) + .getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationKind: integration.kind, + sessions: data, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationKind: integration.kind, + sessions: [], + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index bae8ba9fd..3453b47a6 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => @@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({ getNotifications: publicProcedure .unstable_concat(createNotificationsIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = notificationsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = notificationsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - data, - }; - }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + data, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: new Date(0), + }, + data: [], + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts index 873f1ab47..e39e5f1dc 100644 --- a/packages/api/src/router/widgets/options.ts +++ b/packages/api/src/router/widgets/options.ts @@ -9,6 +9,7 @@ import { boards, items } from "@homarr/db/schema"; import type { WidgetOptionsSettings } from "../../../../widgets/src"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; +import { validateTimetableOptionsChangeAsync } from "./timetable"; import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access"; export const optionsRouter = createTRPCRouter({ @@ -46,12 +47,15 @@ export const optionsRouter = createTRPCRouter({ }); } - const options = SuperJSON.parse>(item.options); - const updatedOptions = { ...options, ...input.newOptions }; + const previousOptions = SuperJSON.parse>(item.options); + const updatedOptions = { ...previousOptions, ...input.newOptions }; + if (item.kind === "timetable") { + await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions); + } throwIfCustomWidgetPlacementChangeForbidden({ isAdmin: ctx.session.user.permissions.includes("admin"), submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }], - storedItems: [{ id: item.id, kind: item.kind, options }], + storedItems: [{ id: item.id, kind: item.kind, options: previousOptions }], }); await ctx.db .update(items) diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index d234a75c1..8101fefbb 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -13,6 +13,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; const logger = createLogger({ module: "releasesRouter" }); +const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed"; const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { if (!versionFilter) return undefined; @@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({ }) .getDataAsync(); + const data = response.data.success + ? response.data + : { + success: false as const, + error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE }, + }; + if (!response.data.success) { + logger.warn("Release provider request failed", { + repositoryId, + provider: repository.provider, + error: response.data.error, + }); + } + return { id: repositoryId, provider: repository.provider, timestamp: response.timestamp, - ...response.data, + ...data, }; } catch (error) { + logger.warn("Release provider request failed", { + repositoryId, + provider: repository.provider, + error, + }); return { id: repositoryId, provider: repository.provider, timestamp: new Date(), success: false as const, - error: { code: "unexpected" as const, message: String(error) }, + error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE }, }; } }), diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 6cb198395..87c49fca0 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -1,3 +1,4 @@ +import { TRPCError } from "@trpc/server"; import SuperJSON from "superjson"; import { z } from "zod/v4"; @@ -14,6 +15,16 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "rssFeed" }); +const safeFeedLogContext = (url: string, feedIndex: number) => { + try { + return { feedIndex, origin: new URL(url).origin }; + } catch { + return { feedIndex }; + } +}; + +const redactFeedFailureMessage = (message: string) => message.replace(/https?:\/\/\S+/gi, "[redacted URL]"); + const feedsInput = z.object({ urls: z.array(z.string()).max(100), maximumAmountPosts: z.number(), @@ -21,7 +32,7 @@ const feedsInput = z.object({ export const rssFeedRouter = createTRPCRouter({ getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => { - if (env.NO_EXTERNAL_CONNECTION) return []; + if (env.NO_EXTERNAL_CONNECTION) return { entries: [], failedFeedCount: 0 }; const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session)) ? input.urls @@ -37,18 +48,37 @@ export const rssFeedRouter = createTRPCRouter({ }), ); - return settled - .flatMap((result, index) => { - if (result.status === "fulfilled") return result.value.data.entries; - logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason }); - return []; - }) - .toSorted((entryA, entryB) => { - return entryA.published && entryB.published - ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() - : 0; - }) - .slice(0, input.maximumAmountPosts); + const failures = settled.filter((result) => result.status === "rejected"); + settled.forEach((result, index) => { + if (result.status === "rejected") { + logger.warn("RSS feed fetch failed", { + ...safeFeedLogContext(urls[index] ?? "", index), + errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason, + errorMessage: result.reason instanceof Error ? redactFeedFailureMessage(result.reason.message) : undefined, + }); + } + }); + if (settled.length > 0 && failures.length === settled.length) { + throw new TRPCError({ + code: "BAD_GATEWAY", + message: "All RSS feed requests failed", + }); + } + + return { + entries: settled + .flatMap((result) => { + if (result.status === "fulfilled") return result.value.data.entries; + return []; + }) + .toSorted((entryA, entryB) => { + return entryA.published && entryB.published + ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() + : 0; + }) + .slice(0, input.maximumAmountPosts), + failedFeedCount: failures.length, + }; }), }); diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index c37468752..a0f38c7fa 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -1,8 +1,12 @@ import { z } from "zod/v4"; +import { TRPCError } from "@trpc/server"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state"; +import { + smartHomeEntityStateRequestHandler, + toSafeEntityDetails, +} from "@homarr/request-handler/smart-home-entity-state"; import type { IntegrationAction } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; @@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({ .query(async ({ ctx: { integration }, input }) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const { data } = await innerHandler.getDataAsync(); - return data; + return data.state; + }), + entityDetails: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get the state, attributes, and update timestamps for a Home Assistant entity. REQUIRED: integrationId (Home Assistant integration ID), entityId (for example 'sensor.temperature')", + }, + }) + .input(z.object({ entityId: z.string() })) + .concat(createSmartHomeIntegrationMiddleware("query")) + .query(async ({ ctx: { integration }, input }) => { + const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); + const { data } = await innerHandler.getDataAsync(); + return toSafeEntityDetails(data); }), switchEntity: protectedProcedure .meta({ @@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); const success = await client.triggerToggleAsync(input.entityId); - - return success; + if (!success) { + throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" }); + } + return true; }), executeAutomation: protectedProcedure .meta({ @@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({ .input(z.object({ automationId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); - await client.triggerAutomationAsync(input.automationId); + const success = await client.triggerAutomationAsync(input.automationId); + if (!success) { + throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" }); + } + return true; }), }); diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index ef60ce320..ced16645e 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -1,36 +1,281 @@ +import { lookup } from "node:dns/promises"; +import { BlockList, isIP } from "node:net"; +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; import z from "zod"; +import type { Session } from "@homarr/auth"; +import type { Database } from "@homarr/db"; +import { and, eq } from "@homarr/db"; +import { boards, items } from "@homarr/db/schema"; +import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable"; import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler, } from "@homarr/request-handler/timetable"; +import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { throwIfActionForbiddenAsync } from "../board/board-access"; -const baseUrlSchema = z.string().url(); +const baseUrlSchema = z.string().url().max(2_048); +const timetableSourceSchema = z.object({ + baseUrl: baseUrlSchema, + itemId: z.string().max(100).optional(), + boardId: z.string().max(100).optional(), +}); +const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); + +const blockedTimetableIpv4Addresses = new BlockList(); +const blockedTimetableIpv6Addresses = new BlockList(); +const blockedTimetableSubnets = [ + ["0.0.0.0", 8, "ipv4"], + ["10.0.0.0", 8, "ipv4"], + ["100.64.0.0", 10, "ipv4"], + ["127.0.0.0", 8, "ipv4"], + ["169.254.0.0", 16, "ipv4"], + ["172.16.0.0", 12, "ipv4"], + ["192.0.0.0", 24, "ipv4"], + ["192.0.2.0", 24, "ipv4"], + ["192.88.99.0", 24, "ipv4"], + ["192.168.0.0", 16, "ipv4"], + ["198.18.0.0", 15, "ipv4"], + ["198.51.100.0", 24, "ipv4"], + ["203.0.113.0", 24, "ipv4"], + ["224.0.0.0", 4, "ipv4"], + ["240.0.0.0", 4, "ipv4"], + ["::", 96, "ipv6"], + ["::ffff:0:0", 96, "ipv6"], + ["::ffff:0:0:0", 96, "ipv6"], + ["64:ff9b::", 96, "ipv6"], + ["64:ff9b:1::", 48, "ipv6"], + ["100::", 64, "ipv6"], + ["2001::", 32, "ipv6"], + ["2001:2::", 48, "ipv6"], + ["2001:10::", 28, "ipv6"], + ["2001:db8::", 32, "ipv6"], + ["2002::", 16, "ipv6"], + ["fc00::", 7, "ipv6"], + ["fe80::", 10, "ipv6"], + ["fec0::", 10, "ipv6"], + ["ff00::", 8, "ipv6"], +] as const; + +for (const [network, prefix, family] of blockedTimetableSubnets) { + const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses; + blockList.addSubnet(network, prefix, family); +} + +const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => { + try { + return normalizeTimetableBaseUrl(baseUrl); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: cause instanceof Error ? cause.message : "Invalid timetable base URL", + cause, + }); + } +}; + +const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Timetable widget configuration is invalid", + cause, + }); +}; + +const parseSavedTimetableOptions = (serializedOptions: string) => { + try { + return timetableOptionsSchema.parse(parse(serializedOptions)); + } catch (cause) { + return throwInvalidSavedTimetableConfiguration(cause); + } +}; + +const normalizeSavedTimetableBaseUrl = (baseUrl: string) => { + try { + return normalizeTimetableBaseUrl(baseUrl); + } catch (cause) { + return throwInvalidSavedTimetableConfiguration(cause); + } +}; + +const normalizeHostname = (hostname: string) => { + const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + return withoutBrackets.toLowerCase().replace(/\.$/, ""); +}; + +const isBlockedTimetableAddress = (address: string, family: number) => { + return family === 6 + ? blockedTimetableIpv6Addresses.check(address, "ipv6") + : blockedTimetableIpv4Addresses.check(address, "ipv4"); +}; + +const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { + const hostname = normalizeHostname(new URL(baseUrl).hostname); + const hostnameFamily = isIP(hostname); + const isInternalHostname = + hostnameFamily === 0 && + (!hostname.includes(".") || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") || + hostname.endsWith(".internal") || + hostname.endsWith(".lan") || + hostname.endsWith(".home.arpa")); + + if (isInternalHostname) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" }); + } + + let addresses: TimetableResolvedAddress[]; + try { + const resolvedAddresses = + hostnameFamily === 0 + ? await lookup(hostname, { all: true, verbatim: true }) + : [{ address: hostname, family: hostnameFamily }]; + addresses = resolvedAddresses.filter( + (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, + ); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL host could not be resolved", + cause, + }); + } + + if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL must not target a private or internal address", + }); + } + + return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address)); +}; + +const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { + const parsedOptions = timetableOptionsSchema.safeParse(options); + if (!parsedOptions.success) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" }); + } + return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); +}; + +export const validateTimetableOptionsChangeAsync = async ( + nextOptions: Record, + previousOptions?: Record, +) => { + const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions); + if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return; + + let previousBaseUrl: string | undefined; + if (previousOptions) { + try { + previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions); + } catch { + // Invalid legacy options must not prevent replacing them with a valid configuration. + } + } + if (nextBaseUrl === previousBaseUrl) return; + + await resolvePublicTimetableAddressesAsync(nextBaseUrl); +}; + +const getSavedTimetableBaseUrl = (serializedOptions: string) => { + const options = parseSavedTimetableOptions(serializedOptions); + return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); +}; + +interface ResolvedTimetableSource { + baseUrl: string; + pinnedAddresses?: TimetableResolvedAddress[]; +} + +const resolveTimetableBaseUrlAsync = async ( + ctx: { db: Database; session: Session | null }, + input: z.infer, + allowBoardConfiguration = false, +): Promise => { + const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl); + if (allowBoardConfiguration) { + if (input.boardId) { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + if (input.itemId) { + const item = await ctx.db.query.items.findFirst({ + columns: { id: true }, + where: and(eq(items.id, input.itemId), eq(items.kind, "timetable"), eq(items.boardId, input.boardId)), + }); + if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" }); + } + if (requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) { + return { baseUrl: requestedBaseUrl }; + } + + // Resolve immediately before every custom request. A URL that was public when saved can later resolve to a + // private address, so matching the persisted value is not sufficient protection against DNS rebinding. + const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl); + return { baseUrl: requestedBaseUrl, pinnedAddresses }; + } + if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) { + throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" }); + } + return { baseUrl: DEFAULT_TIMETABLE_BASE_URL }; + } + if (!input.itemId) { + if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) { + throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" }); + } + return { baseUrl: DEFAULT_TIMETABLE_BASE_URL }; + } + + const item = await ctx.db.query.items.findFirst({ + where: and(eq(items.id, input.itemId), eq(items.kind, "timetable")), + }); + if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" }); + + await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view"); + const savedBaseUrl = getSavedTimetableBaseUrl(item.options); + if (requestedBaseUrl !== savedBaseUrl) { + throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" }); + } + if (savedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return { baseUrl: savedBaseUrl }; + + const pinnedAddresses = await resolvePublicTimetableAddressesAsync(savedBaseUrl); + return { baseUrl: savedBaseUrl, pinnedAddresses }; +}; export const timetableRouter = createTRPCRouter({ getTimetable: publicProcedure .input( - z.object({ - baseUrl: baseUrlSchema, - stationId: z.string(), + timetableSourceSchema.extend({ + stationId: z.string().min(1).max(200), limit: z.number().int().min(1).max(100), }), ) - .query(async ({ input }) => { - const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync(); + .query(async ({ ctx, input }) => { + const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input); + const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input; + const { data } = await timetableGetTimetableRequestHandler + .handler({ ...handlerInput, baseUrl, pinnedAddresses }) + .getDataAsync(); return data; }), searchStations: publicProcedure .input( - z.object({ - baseUrl: baseUrlSchema, - query: z.string(), + timetableSourceSchema.extend({ + query: z.string().max(200), }), ) - .query(async ({ input }) => { - const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + .query(async ({ ctx, input }) => { + const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input, true); + const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input; + const { data } = await timetableSearchStationsRequestHandler + .handler({ ...handlerInput, baseUrl, pinnedAddresses }) + .getDataAsync(); return data; }), }); diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 407e4986f..276367440 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -2,24 +2,41 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const vpnRouter = createTRPCRouter({ getSummaries: publicProcedure .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data as typeof data | null, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: new Date(0), + }, + summary: null, + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index a9dbff5ba..c095c7715 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -1,8 +1,14 @@ +import { TRPCError } from "@trpc/server"; + import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; const logger = createLogger({ module: "settleIntegrations" }); +export const PUBLIC_INTEGRATION_ERROR: string = "INTEGRATION_REQUEST_FAILED"; + +export const toPublicIntegrationError = (_error: unknown): string => PUBLIC_INTEGRATION_ERROR; + interface IntegrationLike { id: string; name: string; @@ -11,6 +17,7 @@ interface IntegrationLike { interface Options { fallback?: (integration: TIntegration, error: unknown) => TResult; + throwOnAllFailures?: boolean; } export async function settleIntegrationQueries( @@ -37,16 +44,23 @@ export async function settleIntegrationQueries 0) { - throw errors[0]; + if ( + errors.length > 0 && + (results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length)) + ) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "All integration queries failed", + cause: errors[0], + }); } return results; diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 21165dd13..5df3a9f06 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -53,6 +53,7 @@ const MCP_TOOL_ALLOWLIST = { "mediaServer_getCurrentStreams", "patchmon_getStats", "serverSettings_getBoardSettings", + "smartHome_entityDetails", "smartHome_entityState", "widgetSecrets_getConfiguredKinds", ], diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts index 09f80bc31..37e7c3640 100644 --- a/packages/api/src/test/settle-integrations.spec.ts +++ b/packages/api/src/test/settle-integrations.spec.ts @@ -1,6 +1,6 @@ -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; -import { settleIntegrationQueries } from "../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries, toPublicIntegrationError } from "../settle-integrations"; const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, @@ -19,7 +19,20 @@ const integrations = [ { id: "second", name: "Second", kind: "jellyfin" }, ]; +beforeEach(() => vi.clearAllMocks()); + describe("settleIntegrationQueries", () => { + test("maps sensitive integration failures to a stable public code", () => { + const error = new Error( + "GET http://admin:password@nas.internal.local/private/path?token=secret returned\nprivate response body", + ); + + const publicError = toPublicIntegrationError(error); + + expect(publicError).toBe(PUBLIC_INTEGRATION_ERROR); + expect(publicError).not.toMatch(/admin|password|nas\.internal|private|token|secret|response body/i); + }); + test("returns an empty result when no integrations are configured", async () => { await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]); }); @@ -35,9 +48,14 @@ describe("settleIntegrationQueries", () => { }); test("rejects when every integration fails", async () => { - const error = new Error("Integration unavailable"); + const error = new Error("Integration unavailable with token=secret"); - await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error); + await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toMatchObject({ + code: "INTERNAL_SERVER_ERROR", + message: "All integration queries failed", + cause: error, + }); + expect(mocks.logger.warn).toHaveBeenCalledTimes(integrations.length); }); test("uses configured fallbacks when every integration fails", async () => { @@ -47,4 +65,25 @@ describe("settleIntegrationQueries", () => { }), ).resolves.toEqual(["First", "Second"]); }); + + test("can retain partial fallbacks while rejecting total failure", async () => { + const error = new Error("Integration unavailable"); + const options = { + fallback: (integration: (typeof integrations)[number]) => integration.name, + throwOnAllFailures: true, + }; + + await expect( + settleIntegrationQueries( + integrations, + (integration) => (integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name)), + options, + ), + ).resolves.toEqual(["First", "Second"]); + await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toMatchObject({ + code: "INTERNAL_SERVER_ERROR", + message: "All integration queries failed", + cause: error, + }); + }); }); diff --git a/packages/boards/src/updater.ts b/packages/boards/src/updater.ts index 49a301a8c..f11c70db8 100644 --- a/packages/boards/src/updater.ts +++ b/packages/boards/src/updater.ts @@ -5,14 +5,15 @@ import { useCallback } from "react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +type Board = RouterOutputs["board"]["getHomeBoard"]; +type UpdateCallback = (previous: Board) => Board; + let boardName: string | null = null; export const updateBoardName = (name: string | null) => { boardName = name; }; -type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"]; - export const useUpdateBoard = () => { const utils = clientApi.useUtils(); @@ -21,9 +22,13 @@ export const useUpdateBoard = () => { if (!boardName) { throw new Error("Board name is not set"); } - utils.board.getBoardByName.setData({ name: boardName }, (previous) => - previous ? updaterWithoutUndefined(previous) : previous, - ); + let updatedBoard: Board | undefined; + utils.board.getBoardByName.setData({ name: boardName }, (previous) => { + if (!previous) return previous; + updatedBoard = updaterWithoutUndefined(previous); + return updatedBoard; + }); + return updatedBoard; }, [utils], ); @@ -32,3 +37,35 @@ export const useUpdateBoard = () => { updateBoard, }; }; + +interface PersistBoardOptions { + onError?: (error: unknown) => void; +} + +export const usePersistBoard = ({ id, name }: Pick) => { + const utils = clientApi.useUtils(); + const { updateBoard } = useUpdateBoard(); + const { mutateAsync: saveBoardAsync } = clientApi.board.saveBoard.useMutation({ + scope: { id: `board-save:${id}` }, + }); + + const updateAndPersistBoard = useCallback( + (updater: UpdateCallback, options?: PersistBoardOptions) => { + const persistedBoard = updateBoard(updater); + if (!persistedBoard) return undefined; + + void saveBoardAsync(persistedBoard).catch((error: unknown) => { + const currentBoard = utils.board.getBoardByName.getData({ name }); + if (currentBoard === persistedBoard) { + void utils.board.getBoardByName.invalidate({ name }); + } + options?.onError?.(error); + }); + + return persistedBoard; + }, + [name, saveBoardAsync, updateBoard, utils], + ); + + return { updateAndPersistBoard }; +}; diff --git a/packages/common/src/bounded-response.ts b/packages/common/src/bounded-response.ts new file mode 100644 index 000000000..d1432fb39 --- /dev/null +++ b/packages/common/src/bounded-response.ts @@ -0,0 +1,45 @@ +interface ReadableResponse { + headers: { get: (name: string) => string | null }; + body: { + getReader: () => { + read: () => Promise<{ done: boolean; value?: Uint8Array }>; + cancel: () => Promise; + }; + } | null; +} + +export async function readBoundedJsonResponseAsync( + response: ReadableResponse, + maxBytes: number, + label = "Response", +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error(`${label} exceeds ${maxBytes} bytes`); + } + + if (!response.body) return null; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + byteLength += value.byteLength; + if (byteLength > maxBytes) { + await reader.cancel(); + throw new Error(`${label} exceeds ${maxBytes} bytes`); + } + chunks.push(value); + } + + const body = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)) as unknown; +} diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index d6e62d5b9..502ae5f6b 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -2,3 +2,4 @@ export * from "./security"; export * from "./encryption"; export * from "./request"; export * from "./errors"; +export * from "./bounded-response"; diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index f13cc1efc..271d380e0 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -48,3 +48,54 @@ const absoluteUrlRegex = /^[a-z]+:(\/\/)?/; export const isAbsoluteUrl = (urlOrPath: string): boolean => { return absoluteUrlRegex.test(urlOrPath.toLowerCase()); }; + +export const SAFE_NEW_TAB_REL = "noopener noreferrer"; + +interface SafeApplicationUrlOptions { + baseUrl?: unknown; +} + +/** Returns an absolute, credential-free HTTP(S) URL suitable for application navigation. */ +export const getSafeApplicationUrl = (value: unknown, options: SafeApplicationUrlOptions = {}): string | undefined => { + if (typeof value !== "string" || value.trim() === "") return undefined; + + try { + let baseUrl: string | undefined; + if (options.baseUrl !== undefined) { + const parsedBase = parseHttpUrl(options.baseUrl); + if (!parsedBase) return undefined; + parsedBase.search = ""; + parsedBase.hash = ""; + baseUrl = parsedBase.toString(); + } + + return parseHttpUrl(value, baseUrl)?.toString(); + } catch { + return undefined; + } +}; + +/** Returns a safe user-configured app link, including same-origin paths and custom URI schemes. */ +export const getSafeAppHref = (value: unknown): string | undefined => { + if (typeof value !== "string" || value.trim() === "") return undefined; + + const href = value.trim(); + if (/^(?:javascript|data|vbscript|file|blob):/i.test(href)) return undefined; + + try { + const url = new URL(href); + if (url.username !== "" || url.password !== "") return undefined; + return url.toString(); + } catch { + return /^(?:[#?]|\/(?![\\/])|\.{1,2}\/)/.test(href) ? href : undefined; + } +}; + +const parseHttpUrl = (value: unknown, baseUrl?: string): URL | undefined => { + if (typeof value !== "string") return undefined; + const url = new URL(value, baseUrl); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") { + return undefined; + } + return url; +}; diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index b3c3ea354..ab0fdaf2f 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -1,8 +1,9 @@ import type { AgentOptions } from "node:https"; import { Agent as HttpsAgent } from "node:https"; +import type { LookupFunction } from "node:net"; import { checkServerIdentity } from "node:tls"; import axios from "axios"; -import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici"; +import type { EnvHttpProxyAgent as UndiciEnvHttpProxyAgent, RequestInfo, RequestInit, Response } from "undici"; import { fetch } from "undici"; import { @@ -30,17 +31,25 @@ export const createCustomCheckServerIdentity = ( }; export const createCertificateAgentAsync = async ( - override?: { + override?: Partial<{ ca: string | string[]; checkServerIdentity: typeof checkServerIdentity; - }, - agentOptions?: Pick, + lookup: LookupFunction; + }>, + agentOptions?: Pick< + UndiciEnvHttpProxyAgent.Options, + "autoSelectFamily" | "bodyTimeout" | "httpProxy" | "httpsProxy" | "noProxy" + >, ) => { + const ca = override?.ca ?? (await getAllTrustedCertificatesAsync()); + const identityCheck = + override?.checkServerIdentity ?? createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()); return new UndiciHttpAgent({ ...agentOptions, - connect: override ?? { - ca: await getAllTrustedCertificatesAsync(), - checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), + connect: { + ca, + checkServerIdentity: identityCheck, + ...(override?.lookup ? { lookup: override.lookup } : {}), }, }); }; diff --git a/packages/custom-widgets/src/test/options.spec.ts b/packages/custom-widgets/src/test/options.spec.ts index 078cfc62a..628246b7a 100644 --- a/packages/custom-widgets/src/test/options.spec.ts +++ b/packages/custom-widgets/src/test/options.spec.ts @@ -71,17 +71,16 @@ describe("lean Custom Widget options", () => { }); it("replaces stale values with current defaults", () => { - const options = customWidgetDefinitionSchema.parse({ - $schema: "homarr-custom-widget-v2", - name: "Updated options", - sources: {}, - requests: {}, - options: { - limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 }, - label: { label: "Label", control: "text", default: "Default" }, - }, - template: "{options.limit}", - }).options; + const options = { + limit: customWidgetOptionSchema.parse({ + label: "Limit", + control: "slider", + default: 5, + min: 1, + max: 10, + }), + label: customWidgetOptionSchema.parse({ label: "Label", control: "text", default: "Default" }), + }; const normalized = normalizeCustomWidgetOptions(options, { limit: "5", diff --git a/packages/db/collection.ts b/packages/db/collection.ts index 5adb52ea2..b70418759 100644 --- a/packages/db/collection.ts +++ b/packages/db/collection.ts @@ -62,7 +62,6 @@ export const createDbInsertCollectionForTransaction = ( tablesInInsertOrder: TTableKey[], ) => { diff --git a/packages/db/test/schema.spec.ts b/packages/db/test/schema.spec.ts index 51993f4d2..5e1eeab98 100644 --- a/packages/db/test/schema.spec.ts +++ b/packages/db/test/schema.spec.ts @@ -24,7 +24,6 @@ type FixedMysqlConfig = { }; }; }; - type FixedPostgresqlConfig = { [key in keyof PostgreisqlConfig]: { [column in keyof PostgreisqlConfig[key]]: { diff --git a/packages/definitions/src/test/integration.spec.ts b/packages/definitions/src/test/integration.spec.ts index bdb5194fd..3372a450d 100644 --- a/packages/definitions/src/test/integration.spec.ts +++ b/packages/definitions/src/test/integration.spec.ts @@ -6,7 +6,7 @@ import { integrationDefs } from "../integration"; describe("Icon url's of integrations should be valid and return 200", () => { objectEntries(integrationDefs).forEach(([integration, { iconUrl }]) => { - it.concurrent(`should return 200 for ${integration}`, async () => { + it(`should return 200 for ${integration}`, { concurrent: true, retry: 2, timeout: 15_000 }, async () => { const res = await fetch(iconUrl); expect(res.status).toBe(200); }); diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts index 441d7fe3a..2dee921d0 100644 --- a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts @@ -54,6 +54,7 @@ export class AudiobookshelfIntegration extends Integration { const statsByLibrary = await Promise.all( libraries.map(async (library) => ({ + library, mediaType: library.mediaType, stats: await this.getLibraryStatsAsync(library.id), })), @@ -75,6 +76,14 @@ export class AudiobookshelfIntegration extends Integration { totalPodcasts: counts.totalPodcasts, totalListeningTimeSeconds: listeningStats.totalTime, activeSessions, + libraries: statsByLibrary.map(({ library, stats }) => ({ + id: library.id, + name: library.name, + mediaType: library.mediaType, + totalItems: stats.totalItems, + totalDurationSeconds: stats.totalDuration ?? null, + totalSizeBytes: stats.totalSize ?? null, + })), }; } diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts index 495a24c08..4c853c139 100644 --- a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts @@ -30,6 +30,14 @@ export interface AudiobookshelfDashboardData { totalPodcasts: number; totalListeningTimeSeconds: number; activeSessions: number; + libraries?: { + id: string; + name: string; + mediaType: "book" | "podcast"; + totalItems: number; + totalDurationSeconds: number | null; + totalSizeBytes: number | null; + }[]; } export type AudiobookshelfLibrary = z.infer; diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts index 9496482d6..ce4892d10 100644 --- a/packages/integrations/src/immich/immich-integration.ts +++ b/packages/integrations/src/immich/immich-integration.ts @@ -36,7 +36,6 @@ export interface ImmichAlbum { export interface ImmichAsset { id: string; - originalPath: string; thumbhash: string | null; fileModifiedAt: string; fileCreatedAt: string; @@ -156,7 +155,6 @@ export class ImmichIntegration extends Integration { thumbhash: asset.thumbhash, fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, - originalPath: asset.originalPath, updatedAt: asset.updatedAt, publicLink, }; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index f172cec36..71b0652fc 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -61,6 +61,7 @@ export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/heal export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types"; export type { StreamSession } from "./interfaces/media-server/media-server-types"; +export type { EntityState } from "./interfaces/smart-home/smart-home-types"; export type { TdarrQueue, TdarrPieSegment, diff --git a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts index efc113dc7..2208c4953 100644 --- a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts +++ b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts @@ -1,4 +1,4 @@ -interface EntityState { +export interface EntityState { attributes: Record; entity_id: string; last_changed: Date; diff --git a/packages/integrations/src/overseerr/overseerr-integration.spec.ts b/packages/integrations/src/overseerr/overseerr-integration.spec.ts new file mode 100644 index 000000000..2f021f608 --- /dev/null +++ b/packages/integrations/src/overseerr/overseerr-integration.spec.ts @@ -0,0 +1,39 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { OverseerrIntegration } from "./overseerr-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const integration = new OverseerrIntegration({ + id: "overseerr-1", + name: "Overseerr", + url: "https://overseerr.example.com", + externalUrl: null, + decryptedSecrets: [{ kind: "apiKey", value: "test-key" }], +}); + +describe("Overseerr media request actions", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ["approve", () => integration.approveRequestAsync(42)], + ["decline", () => integration.declineRequestAsync(42)], + ])("rejects %s when Overseerr returns a non-success response", async (_action, run) => { + mockFetch.mockResolvedValue(new Response(null, { status: 500 })); + + await expect(run()).rejects.toMatchObject({ + name: "IntegrationResponseError", + message: "Response from integration did not indicate success", + cause: { + name: "ResponseError", + message: "Response did not indicate success", + statusCode: 500, + }, + }); + expect(mockFetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 10aa73114..f36e46745 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -1,8 +1,8 @@ import { z } from "zod/v4"; +import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -259,49 +259,27 @@ export class OverseerrIntegration public async approveRequestAsync(requestId: number): Promise { logger.info("Approving media request", { requestId, integration: this.integration.name }); - await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { + const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, - }).then((response) => { - if (!response.ok) { - logger.error( - new ErrorWithMetadata("Failed to approve media request", { - requestId, - integration: this.integration.name, - reason: `${response.status} ${response.statusText}`, - url: response.url, - }), - ); - } - - logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); }); + if (!response.ok) throw new ResponseError(response); + logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); } public async declineRequestAsync(requestId: number): Promise { logger.info("Declining media request", { requestId, integration: this.integration.name }); - await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { + const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, - }).then((response) => { - if (!response.ok) { - logger.error( - new ErrorWithMetadata("Failed to decline media request", { - requestId, - integration: this.integration.name, - reason: `${response.status} ${response.statusText}`, - url: response.url, - }), - ); - } - - logger.info("Successfully declined media request", { requestId, integration: this.integration.name }); }); + if (!response.ok) throw new ResponseError(response); + logger.info("Successfully declined media request", { requestId, integration: this.integration.name }); } private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise { diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 6b772a589..cd4e9d6e9 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -97,8 +97,8 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision); const containerChanged = Boolean( transcodeElement?.$.container && - mediaInfoElement?.$.container && - transcodeElement.$.container !== mediaInfoElement.$.container, + mediaInfoElement?.$.container && + transcodeElement.$.container !== mediaInfoElement.$.container, ); const bitrateKbps = parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate); diff --git a/packages/integrations/src/traefik/traefik-bounds.spec.ts b/packages/integrations/src/traefik/traefik-bounds.spec.ts new file mode 100644 index 000000000..1f14aff89 --- /dev/null +++ b/packages/integrations/src/traefik/traefik-bounds.spec.ts @@ -0,0 +1,12 @@ +import { Response } from "undici"; +import { describe, expect, it } from "vitest"; + +import { readBoundedTraefikJsonAsync } from "./traefik-bounds"; + +describe("readBoundedTraefikJsonAsync", () => { + it("rejects a response that exceeds its byte budget", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + + await expect(readBoundedTraefikJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); +}); diff --git a/packages/integrations/src/traefik/traefik-bounds.ts b/packages/integrations/src/traefik/traefik-bounds.ts new file mode 100644 index 000000000..99a6c8b1b --- /dev/null +++ b/packages/integrations/src/traefik/traefik-bounds.ts @@ -0,0 +1,12 @@ +import type { Response } from "undici"; + +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const MAX_TRAEFIK_RESPONSE_BYTES = 2 * 1024 * 1024; + +export async function readBoundedTraefikJsonAsync( + response: Response, + maxBytes = MAX_TRAEFIK_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Traefik response"); +} diff --git a/packages/integrations/src/traefik/traefik-integration.spec.ts b/packages/integrations/src/traefik/traefik-integration.spec.ts new file mode 100644 index 000000000..ced92e33a --- /dev/null +++ b/packages/integrations/src/traefik/traefik-integration.spec.ts @@ -0,0 +1,43 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { TraefikIntegration } from "./traefik-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const integration = new TraefikIntegration({ + id: "traefik-1", + name: "Traefik", + url: "https://traefik.example.com", + externalUrl: null, + decryptedSecrets: [], +}); + +describe("TraefikIntegration response bounds", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockImplementation((url) => { + const path = new URL(url.toString()).pathname; + if (path === "/api/version") return Promise.resolve(jsonResponse({ Version: "3.0.0" })); + if (path === "/api/http/routers") { + return Promise.resolve( + jsonResponse(Array.from({ length: 250 }, (_, index) => ({ name: `router-${index}`, status: "enabled" }))), + ); + } + return Promise.resolve(jsonResponse([])); + }); + }); + + it("caps detailed resource allocation while retaining bounded summary counts", async () => { + const result = await integration.getDashboardDataAsync(); + + expect(result.resources).toHaveLength(200); + expect(result.resources.at(-1)?.name).toBe("router-199"); + expect(result.http.routers.total).toBe(250); + }); +}); + +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); diff --git a/packages/integrations/src/traefik/traefik-integration.ts b/packages/integrations/src/traefik/traefik-integration.ts index 0dc8cf57f..2e0e72b4c 100644 --- a/packages/integrations/src/traefik/traefik-integration.ts +++ b/packages/integrations/src/traefik/traefik-integration.ts @@ -4,10 +4,12 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { readBoundedTraefikJsonAsync } from "./traefik-bounds"; import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResource, + TraefikResourceDetail, TraefikResourceStatus, TraefikResourceSummary, } from "./traefik-types"; @@ -24,6 +26,8 @@ type ResourcePath = | "/api/udp/services" | "/api/entrypoints"; +const MAX_RESOURCE_DETAILS = 200; + export class TraefikIntegration extends Integration { protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() }); @@ -32,7 +36,7 @@ export class TraefikIntegration extends Integration { throw new ResponseError(response); } - traefikVersionSchema.parse(await response.json()); + traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response)); return { success: true }; } @@ -60,6 +64,38 @@ export class TraefikIntegration extends Integration { this.getResourcesAsync("/api/udp/routers"), this.getResourcesAsync("/api/udp/services"), ]); + + const allResults = [ + versionResult, + entryPointsResult, + httpRoutersResult, + httpServicesResult, + httpMiddlewaresResult, + tcpRoutersResult, + tcpServicesResult, + tcpMiddlewaresResult, + udpRoutersResult, + udpServicesResult, + ]; + if (allResults.every((result) => result.status === "rejected")) { + throw versionResult.status === "rejected" ? versionResult.reason : new Error("Every Traefik endpoint failed"); + } + + const endpointResults = [ + ["version", versionResult], + ["entrypoints", entryPointsResult], + ["http/routers", httpRoutersResult], + ["http/services", httpServicesResult], + ["http/middlewares", httpMiddlewaresResult], + ["tcp/routers", tcpRoutersResult], + ["tcp/services", tcpServicesResult], + ["tcp/middlewares", tcpMiddlewaresResult], + ["udp/routers", udpRoutersResult], + ["udp/services", udpServicesResult], + ] as const; + const failedEndpoints = endpointResults + .filter(([, result]) => result.status === "rejected") + .map(([endpoint]) => endpoint); const version = versionResult.status === "fulfilled" ? versionResult.value : null; const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : []; const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : []; @@ -71,9 +107,30 @@ export class TraefikIntegration extends Integration { const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : []; const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : []; + const resources: TraefikResourceDetail[] = []; + const resourceGroups = [ + ["http", "router", httpRouters], + ["http", "service", httpServices], + ["http", "middleware", httpMiddlewares], + ["tcp", "router", tcpRouters], + ["tcp", "service", tcpServices], + ["tcp", "middleware", tcpMiddlewares], + ["udp", "router", udpRouters], + ["udp", "service", udpServices], + ] as const; + for (const [protocol, type, entries] of resourceGroups) { + const remaining = MAX_RESOURCE_DETAILS - resources.length; + if (remaining === 0) break; + resources.push(...this.createResourceDetails(protocol, type, entries.slice(0, remaining))); + } + return { version, - entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`), + entryPoints: entryPoints + .slice(0, MAX_RESOURCE_DETAILS) + .map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`), + resources, + failedEndpoints, http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares), tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares), udp: { @@ -83,15 +140,29 @@ export class TraefikIntegration extends Integration { }; } + private createResourceDetails( + protocol: TraefikResourceDetail["protocol"], + type: TraefikResourceDetail["type"], + resources: TraefikResource[], + ): TraefikResourceDetail[] { + return resources.map((resource, index) => ({ + protocol, + type, + name: resource.name ?? `${type}-${index + 1}`, + provider: resource.provider ?? null, + status: this.normalizeStatus(resource.status), + })); + } + private async getVersionAsync() { const response = await this.fetchOkAsync("/api/version"); - const version = traefikVersionSchema.parse(await response.json()); + const version = traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response)); return version.Version ?? null; } private async getResourcesAsync(path: ResourcePath) { const response = await this.fetchOkAsync(path); - return traefikResourcesSchema.parse(await response.json()); + return traefikResourcesSchema.parse(await readBoundedTraefikJsonAsync(response)); } private createProtocolSummary( @@ -137,6 +208,7 @@ export class TraefikIntegration extends Integration { const response = await fetchWithTrustedCertificatesAsync(this.url(path), { headers: this.getAuthHeaders(), timeout: 10_000, + bodyTimeout: 10_000, }); if (!response.ok) { diff --git a/packages/integrations/src/traefik/traefik-types.ts b/packages/integrations/src/traefik/traefik-types.ts index 5874ef41b..188941465 100644 --- a/packages/integrations/src/traefik/traefik-types.ts +++ b/packages/integrations/src/traefik/traefik-types.ts @@ -31,9 +31,19 @@ export interface TraefikProtocolSummary { middlewares: TraefikResourceSummary; } +export interface TraefikResourceDetail { + protocol: "http" | "tcp" | "udp"; + type: "router" | "service" | "middleware"; + name: string; + provider: string | null; + status: TraefikResourceStatus; +} + export interface TraefikDashboardData { version: string | null; entryPoints: string[]; + resources: TraefikResourceDetail[]; + failedEndpoints: string[]; http: TraefikProtocolSummary; tcp: TraefikProtocolSummary; udp: Omit; diff --git a/packages/modals/src/index.tsx b/packages/modals/src/index.tsx index 02bc4cff4..0071185a8 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -125,6 +125,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => { interface OpenModalOptions { keepMounted?: boolean; + onClose?: () => void; title?: stringOrTranslation; } diff --git a/packages/request-handler/src/immich.spec.ts b/packages/request-handler/src/immich.spec.ts new file mode 100644 index 000000000..199341933 --- /dev/null +++ b/packages/request-handler/src/immich.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { immichAlbumsRequestHandler } from "./immich"; + +const mocks = vi.hoisted(() => ({ + createIntegrationAsync: vi.fn(), +})); + +vi.mock("@homarr/integrations", () => ({ + createIntegrationAsync: mocks.createIntegrationAsync, +})); + +const integration = { + id: "immich-integration", + kind: "immich", +} as Parameters[0]; + +const albums = [ + { id: "upstream-first", albumName: "Zebra", assetCount: 1 }, + { id: "second-b", albumName: "Beta", assetCount: 10 }, + { id: "second-a", albumName: "Alpha", assetCount: 10 }, +]; + +describe("immichAlbumsRequestHandler", () => { + beforeEach(() => { + immichAlbumsRequestHandler.invalidateCache(); + mocks.createIntegrationAsync.mockReset(); + }); + + test("sorts deterministically before caching a limited response", async () => { + const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]); + mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync }); + + const first = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync(); + const cached = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync(); + + expect(first.data.map(({ id }) => id)).toStrictEqual(["second-a", "second-b"]); + expect(cached.data).toStrictEqual(first.data); + expect(getAlbumsAsync).toHaveBeenCalledOnce(); + }); + + test("preserves upstream order when no limit is requested", async () => { + const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]); + mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync }); + + const result = await immichAlbumsRequestHandler.handler(integration, {}).getDataAsync(); + + expect(result.data).toStrictEqual(albums); + }); +}); diff --git a/packages/request-handler/src/immich.ts b/packages/request-handler/src/immich.ts index a6e2a996c..bc93eb83c 100644 --- a/packages/request-handler/src/immich.ts +++ b/packages/request-handler/src/immich.ts @@ -22,14 +22,27 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler< assetCount: number; }[], IntegrationKindByCategory<"photoService">, - Record + { limit?: number } >({ - async requestAsync(integration) { + async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getAlbumsAsync(); + const albums = await integrationInstance.getAlbumsAsync(); + + if (input.limit === undefined) return albums; + + return albums + .toSorted( + (left, right) => + right.assetCount - left.assetCount || + compareText(left.albumName, right.albumName) || + compareText(left.id, right.id), + ) + .slice(0, input.limit); }, }); +const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + export const immichAlbumRequestHandler = createIntegrationRequestHandler< ImmichAlbum, IntegrationKindByCategory<"photoService">, diff --git a/packages/request-handler/src/smart-home-entity-state.spec.ts b/packages/request-handler/src/smart-home-entity-state.spec.ts new file mode 100644 index 000000000..bee81d2de --- /dev/null +++ b/packages/request-handler/src/smart-home-entity-state.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { toSafeEntityDetails } from "./smart-home-entity-state"; + +describe("toSafeEntityDetails", () => { + it("keeps display attributes and removes sensitive Home Assistant metadata", () => { + const details = toSafeEntityDetails({ + entity_id: "camera.front_door", + state: "streaming", + last_changed: new Date("2026-01-01T00:00:00Z"), + last_updated: new Date("2026-01-01T00:00:01Z"), + attributes: { + friendly_name: "Front door", + icon: "mdi:camera", + access_token: "secret-camera-token", + latitude: 48.85, + }, + }); + + expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" }); + expect(details.attributes).not.toHaveProperty("access_token"); + expect(details.attributes).not.toHaveProperty("latitude"); + }); +}); diff --git a/packages/request-handler/src/smart-home-entity-state.ts b/packages/request-handler/src/smart-home-entity-state.ts index 3c8851522..1d0d2471c 100644 --- a/packages/request-handler/src/smart-home-entity-state.ts +++ b/packages/request-handler/src/smart-home-entity-state.ts @@ -1,10 +1,29 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import type { EntityState } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; +const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const; + +export const toSafeEntityDetails = (entity: EntityState) => { + const attributes: Partial> = {}; + for (const key of safeAttributeKeys) { + const value = entity.attributes[key]; + if (typeof value === "string") attributes[key] = value; + } + + return { + attributes, + entity_id: entity.entity_id, + last_changed: entity.last_changed, + last_updated: entity.last_updated, + state: entity.state, + }; +}; + export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler< - string, + EntityState, IntegrationKindByCategory<"smartHomeServer">, { entityId: string } >({ @@ -16,6 +35,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`); } - return result.data.state; + return result.data; }, }); diff --git a/packages/request-handler/src/timetable-url.ts b/packages/request-handler/src/timetable-url.ts new file mode 100644 index 000000000..f7241a542 --- /dev/null +++ b/packages/request-handler/src/timetable-url.ts @@ -0,0 +1,26 @@ +import type { Response } from "undici"; + +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch"; +export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024; + +export const normalizeTimetableBaseUrl = (baseUrl: string) => { + if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long"); + + const url = new URL(baseUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Timetable base URL must use HTTP or HTTPS"); + } + if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials"); + if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment"); + + return url.toString().replace(/\/$/, ""); +}; + +export async function readBoundedTimetableJsonAsync( + response: Response, + maxBytes = MAX_TIMETABLE_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Timetable response"); +} diff --git a/packages/request-handler/src/timetable.spec.ts b/packages/request-handler/src/timetable.spec.ts new file mode 100644 index 000000000..ea39392ab --- /dev/null +++ b/packages/request-handler/src/timetable.spec.ts @@ -0,0 +1,216 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable"; +import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + createCertificateAgentAsync: vi.fn(), + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync); +const mockCloseDispatcher = vi.fn(); +const mockDestroyDispatcher = vi.fn().mockResolvedValue(undefined); +const mockDispatcher = { close: mockCloseDispatcher, destroy: mockDestroyDispatcher }; + +beforeEach(() => { + vi.clearAllMocks(); + mockCreateCertificateAgent.mockResolvedValue(mockDispatcher as never); +}); + +describe("normalizeTimetableBaseUrl", () => { + it("normalizes an HTTP endpoint", () => { + expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch"); + }); + + it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])( + "rejects unsafe base URL %s", + (url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(), + ); +}); + +describe("timetable response bounds", () => { + it("rejects a response once its byte budget is exceeded", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); + + it("caps and caches station results and rejects redirects", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify( + Array.from({ length: 125 }, (_, index) => ({ + id: String(index), + label: `Station ${index}`, + iconclass: "train", + })), + ), + ), + ); + const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" }; + + const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + + expect(first.data).toHaveLength(100); + expect(second.data).toHaveLength(100); + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }), + ); + }); + + it("pins the vetted address into a direct connection without changing the request hostname", async () => { + const pinnedAddresses = [ + { address: "93.184.216.34", family: 4 as const }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 as const }, + ]; + mockFetch.mockImplementation(async () => { + const pinnedLookup = mockCreateCertificateAgent.mock.calls[0]?.[0]?.lookup; + expect(pinnedLookup).toBeTypeOf("function"); + if (!pinnedLookup) throw new Error("Expected a pinned lookup function"); + + const lookupCallback = vi.fn(); + pinnedLookup("timetable.example.com", { all: true }, lookupCallback); + expect(lookupCallback).toHaveBeenCalledWith(null, pinnedAddresses); + + const repeatedLookupCallback = vi.fn(); + pinnedLookup("timetable.example.com", { all: true }, repeatedLookupCallback); + expect(repeatedLookupCallback).toHaveBeenCalledWith(null, pinnedAddresses); + expect(mockCloseDispatcher).not.toHaveBeenCalled(); + + const unexpectedHostCallback = vi.fn(); + pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback); + expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" }); + + return new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }])); + }); + + await timetableSearchStationsRequestHandler + .handler({ + baseUrl: "https://timetable.example.com", + query: "pinned-stations", + pinnedAddresses, + }) + .getDataAsync(); + + expect(mockCreateCertificateAgent).toHaveBeenCalledWith( + { lookup: expect.any(Function) }, + { + autoSelectFamily: true, + bodyTimeout: 10_000, + httpProxy: "", + httpsProxy: "", + noProxy: "*", + }, + ); + const requestUrl = mockFetch.mock.calls[0]?.[0]; + expect(new URL(String(requestUrl)).hostname).toBe("timetable.example.com"); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }), + ); + + expect(mockCloseDispatcher).toHaveBeenCalledOnce(); + }); + + it("pins timetable requests and closes their direct dispatcher", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + connections: [ + { + time: "2026-01-01T00:00:00Z", + line: "1", + color: "ffffff", + terminal: { name: "Stop" }, + dep_delay: "0", + }, + ], + }), + ), + ); + const pinnedAddresses = [{ address: "93.184.216.34", family: 4 as const }]; + + await timetableGetTimetableRequestHandler + .handler({ + baseUrl: "https://timetable.example.com", + stationId: "pinned-departures", + limit: 1, + pinnedAddresses, + }) + .getDataAsync(); + + expect(mockCreateCertificateAgent).toHaveBeenCalledWith( + { lookup: expect.any(Function) }, + expect.objectContaining({ noProxy: "*" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }), + ); + expect(mockCloseDispatcher).toHaveBeenCalledOnce(); + }); + + it("destroys the pinned station dispatcher when response handling fails", async () => { + mockFetch.mockResolvedValue(new Response("not json")); + + await expect( + timetableSearchStationsRequestHandler + .handler({ + baseUrl: "https://timetable.example.com", + query: "invalid-stations", + pinnedAddresses: [{ address: "93.184.216.34", family: 4 }], + }) + .getDataAsync(), + ).rejects.toThrow(); + + expect(mockDestroyDispatcher).toHaveBeenCalledOnce(); + expect(mockCloseDispatcher).not.toHaveBeenCalled(); + }); + + it("destroys the pinned timetable dispatcher when response handling fails", async () => { + mockFetch.mockResolvedValue(new Response("not json")); + + await expect( + timetableGetTimetableRequestHandler + .handler({ + baseUrl: "https://timetable.example.com", + stationId: "invalid-departures", + limit: 1, + pinnedAddresses: [{ address: "93.184.216.34", family: 4 }], + }) + .getDataAsync(), + ).rejects.toThrow(); + + expect(mockDestroyDispatcher).toHaveBeenCalledOnce(); + expect(mockCloseDispatcher).not.toHaveBeenCalled(); + }); + + it("never caches more departures than requested", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + connections: Array.from({ length: 5 }, (_, index) => ({ + time: `2026-01-01T00:0${index}:00Z`, + line: "1", + color: "ffffff", + terminal: { name: `Stop ${index}` }, + dep_delay: "0", + })), + }), + ), + ); + + const result = await timetableGetTimetableRequestHandler + .handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 }) + .getDataAsync(); + + expect(result.data.entries).toHaveLength(2); + }); +}); diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts index 2be7355d8..27f549ac5 100644 --- a/packages/request-handler/src/timetable.ts +++ b/packages/request-handler/src/timetable.ts @@ -1,9 +1,11 @@ +import type { LookupFunction } from "node:net"; import { z } from "zod/v4"; import { ResponseError } from "@homarr/common/server"; -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createRequestHandler } from "./lib/request-handler"; +import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url"; export interface Station { id: string; @@ -31,51 +33,126 @@ export interface Timetable { } const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; +const MAX_STATION_RESULTS = 100; +const MAX_TIMETABLE_ENTRIES = 100; +const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const; + +export interface TimetableResolvedAddress { + address: string; + family: 4 | 6; +} export const timetableSearchStationsRequestHandler = createRequestHandler< Station[], - { baseUrl: string; query: string } + { baseUrl: string; query: string; pinnedAddresses?: TimetableResolvedAddress[] } >({ async requestAsync(input) { - return await searchStationsAsync(input.baseUrl, input.query); + return await searchStationsAsync(input.baseUrl, input.query, input.pinnedAddresses); }, cacheTtlMs: 24 * 60 * 60 * 1000, }); export const timetableGetTimetableRequestHandler = createRequestHandler< Timetable, - { baseUrl: string; stationId: string; limit: number } + { baseUrl: string; stationId: string; limit: number; pinnedAddresses?: TimetableResolvedAddress[] } >({ async requestAsync(input) { - return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit }); + return await getTimetableAsync( + input.baseUrl, + { stationId: input.stationId, limit: input.limit }, + input.pinnedAddresses, + ); }, cacheTtlMs: 60_000, }); const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record) => { - const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`); + const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`); for (const [key, value] of Object.entries(queryParams)) { url.searchParams.set(key, value.toString()); } return url.toString(); }; -const searchStationsAsync = async (baseUrl: string, query: string): Promise => { - const response = await fetchWithTrustedCertificatesAsync( - buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }), - ); - if (!response.ok) throw new ResponseError(response); +const normalizeLookupHostname = (hostname: string) => + hostname + .replace(/^\[(.*)\]$/, "$1") + .toLowerCase() + .replace(/\.$/, ""); - const data = await searchSchema.parseAsync(await response.json()); - return data - .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) - .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) - .filter((item) => item !== null); +const createPinnedLookup = (baseUrl: string, addresses: TimetableResolvedAddress[]): LookupFunction => { + const expectedHostname = normalizeLookupHostname(new URL(baseUrl).hostname); + + return (hostname, options, callback) => { + const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined; + const candidates = requestedFamily ? addresses.filter(({ family }) => family === requestedFamily) : addresses; + if (normalizeLookupHostname(hostname) !== expectedHostname || candidates.length === 0) { + const error = new Error("Pinned timetable address is unavailable") as NodeJS.ErrnoException; + error.code = "ENOTFOUND"; + callback(error, []); + return; + } + + if (options.all) { + callback(null, candidates); + return; + } + + const selected = candidates[0]; + if (!selected) return callback(new Error("Pinned timetable address is unavailable"), []); + callback(null, selected.address, selected.family); + }; +}; + +const createPinnedTimetableDispatcherAsync = async (baseUrl: string, addresses: TimetableResolvedAddress[]) => { + // Proxies resolve CONNECT targets independently, which would defeat DNS pinning. Force this security-sensitive + // configuration request to connect directly. The original URL remains unchanged for the Host header and TLS SNI. + return await createCertificateAgentAsync( + { lookup: createPinnedLookup(baseUrl, addresses) }, + { + autoSelectFamily: addresses.length > 1, + bodyTimeout: timetableFetchOptions.bodyTimeout, + httpProxy: "", + httpsProxy: "", + noProxy: "*", + }, + ); +}; + +const searchStationsAsync = async ( + baseUrl: string, + query: string, + pinnedAddresses?: TimetableResolvedAddress[], +): Promise => { + let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined; + let response: Awaited> | undefined; + try { + response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }), + { ...timetableFetchOptions, dispatcher }, + ); + if (!response.ok) throw new ResponseError(response); + + const body = await readBoundedTimetableJsonAsync(response); + const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body); + return data + .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) + .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) + .filter((item) => item !== null); + } catch (error) { + if (response?.body) await response.body.cancel().catch(() => {}); + await dispatcher?.destroy().catch(() => {}); + dispatcher = undefined; + throw error; + } finally { + await dispatcher?.close(); + } }; const getTimetableAsync = async ( baseUrl: string, options: { stationId: string; limit: number }, + pinnedAddresses?: TimetableResolvedAddress[], ): Promise => { const now = new Date(); const date = new Intl.DateTimeFormat("en-US", { @@ -90,43 +167,59 @@ const getTimetableAsync = async ( minute: "2-digit", hour12: false, }).format(now); - const response = await fetchWithTrustedCertificatesAsync( - buildUrl(baseUrl, "/timetable/api/stationboard.json", { - stop: options.stationId, - limit: options.limit, - show_delays: 1, - show_tracks: 1, - date, - time, - }), - ); - if (!response.ok) throw new ResponseError(response); + let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined; + let response: Awaited> | undefined; + try { + response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, "/timetable/api/stationboard.json", { + stop: options.stationId, + limit: options.limit, + show_delays: 1, + show_tracks: 1, + date, + time, + }), + { ...timetableFetchOptions, dispatcher }, + ); + if (!response.ok) throw new ResponseError(response); - const data = await timetableSchema.parseAsync(await response.json()); - return { - stationId: options.stationId, - timestamp: now, - entries: data.connections.map((connection) => { - const color = connection.color.split("~")[0]; - return { - timestamp: connection.time, - line: connection.line - ? { - name: connection.line, - color: color && color.length >= 1 ? `#${color}` : null, - } - : null, - location: connection.terminal.name, - delay: connection.dep_delay, - platform: connection.track - ? { - name: connection.track.replace("!", ""), - hasChanged: connection.track.includes("!"), - } - : null, - }; - }), - }; + const body = await readBoundedTimetableJsonAsync(response); + const envelope = timetableEnvelopeSchema.parse(body); + const data = await timetableSchema.parseAsync({ + connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)), + }); + return { + stationId: options.stationId, + timestamp: now, + entries: data.connections.map((connection) => { + const color = connection.color.split("~")[0]; + return { + timestamp: connection.time, + line: connection.line + ? { + name: connection.line, + color: color && color.length >= 1 ? `#${color}` : null, + } + : null, + location: connection.terminal.name, + delay: connection.dep_delay, + platform: connection.track + ? { + name: connection.track.replace("!", ""), + hasChanged: connection.track.includes("!"), + } + : null, + }; + }), + }; + } catch (error) { + if (response?.body) await response.body.cancel().catch(() => {}); + await dispatcher?.destroy().catch(() => {}); + dispatcher = undefined; + throw error; + } finally { + await dispatcher?.close(); + } }; const timetableSchema = z.object({ @@ -145,6 +238,8 @@ const timetableSchema = z.object({ ), }); +const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) }); + const searchSchema = z.array( z.object({ id: z.string().optional(), diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts index ebca0fbeb..97182ab11 100644 --- a/packages/request-handler/src/vpn.ts +++ b/packages/request-handler/src/vpn.ts @@ -1,28 +1,16 @@ -import { createLogger } from "@homarr/core/infrastructure/logs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { VpnSummary } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -const logger = createLogger({ module: "vpnRequestHandler" }); - export const vpnSummaryHandler = createIntegrationRequestHandler< - VpnSummary | null, + VpnSummary, IntegrationKindByCategory<"vpn">, Record >({ async requestAsync(integration, _input) { - try { - const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getSummaryAsync(); - } catch (error) { - logger.warn("Failed to fetch VPN summary, marking as unavailable", { - integrationId: integration.id, - integrationKind: integration.kind, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getSummaryAsync(); }, }); diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx index 3d5427506..f56e46a23 100644 --- a/packages/spotlight/src/components/actions/items/children-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -1,5 +1,6 @@ import { Spotlight } from "@mantine/spotlight"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; import { Link } from "@homarr/ui"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -15,10 +16,18 @@ interface ChildrenActionItemProps { export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => { const interaction = action.useInteraction(childrenOptions.option, query); + const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; const renderRoot = - interaction.type === "link" + interaction.type === "link" && safeHref ? (props: Record) => { - return ; + return ( + + ); } : undefined; diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index e6a802c26..bc7cfaf41 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -2,6 +2,7 @@ import { Spotlight } from "@mantine/spotlight"; import type { TranslationObject } from "@homarr/translation"; import { Link } from "@homarr/ui"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; import type { SearchGroup } from "../../../lib/group"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -30,10 +31,18 @@ export const SpotlightGroupActionItem = const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record; + const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; const renderRoot = - interaction.type === "link" + interaction.type === "link" && safeHref ? (props: Record) => { - return ; + return ( + + ); } : undefined; diff --git a/packages/translation/src/client/index.ts b/packages/translation/src/client/index.ts index 990267981..62b684d6b 100644 --- a/packages/translation/src/client/index.ts +++ b/packages/translation/src/client/index.ts @@ -6,7 +6,7 @@ import type { SupportedLanguage } from "../config"; import type englishTranslation from "../lang/en.json"; export { useChangeLocale } from "./use-change-locale"; -export { useCurrentLocale } from "./use-current-locale"; +export { useCurrentIntlLocale, useCurrentLocale } from "./use-current-locale"; declare module "next-intl" { interface AppConfig { diff --git a/packages/translation/src/client/use-current-locale.ts b/packages/translation/src/client/use-current-locale.ts index a0e796010..6a8ec03a5 100644 --- a/packages/translation/src/client/use-current-locale.ts +++ b/packages/translation/src/client/use-current-locale.ts @@ -1,3 +1,7 @@ import { useLocale } from "next-intl"; +import { getIntlLocale } from "../config"; + export const useCurrentLocale = useLocale; + +export const useCurrentIntlLocale = () => getIntlLocale(useLocale()); diff --git a/packages/translation/src/config.spec.ts b/packages/translation/src/config.spec.ts new file mode 100644 index 000000000..55c3adcbb --- /dev/null +++ b/packages/translation/src/config.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { getIntlLocale } from "./config"; + +describe("getIntlLocale", () => { + it("maps Homarr-specific locale keys to canonical Intl locales", () => { + expect(getIntlLocale("cn")).toBe("zh-CN"); + expect(getIntlLocale("zh")).toBe("zh-TW"); + expect(getIntlLocale("cr")).toBe("en"); + }); + + it("preserves locale keys that Intl already understands", () => { + expect(getIntlLocale("fr")).toBe("fr"); + expect(getIntlLocale("de-CH")).toBe("de-CH"); + }); +}); diff --git a/packages/translation/src/config.ts b/packages/translation/src/config.ts index ea3bc7dca..883bc80c1 100644 --- a/packages/translation/src/config.ts +++ b/packages/translation/src/config.ts @@ -438,5 +438,13 @@ export type LanguageIconDefinition = export { fallbackLocale } from "./languages"; +const intlLocaleOverrides: Partial> = { + cn: "zh-CN", + cr: "en", + zh: "zh-TW", +}; + +export const getIntlLocale = (locale: SupportedLanguage): string => intlLocaleOverrides[locale] ?? locale; + export const isLocaleRTL = (locale: SupportedLanguage) => "isRTL" in localeConfigurations[locale] && localeConfigurations[locale].isRTL; diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index 7ac28559f..121488db7 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -1254,6 +1254,8 @@ "success": "Erfolgreich", "beta": "Beta", "error": "Fehler", + "total": "Gesamt", + "filesystemUsage": "{count, plural, one {Auslastung für # Dateisystem anzeigen} other {Auslastung für # Dateisysteme anzeigen}}", "action": { "add": "Hinzufügen", "apply": "Übernehmen", @@ -1917,6 +1919,12 @@ "clickable": { "label": "Anklickbar" } + }, + "state": { + "on": "Ein", + "off": "Aus", + "unavailable": "Nicht verfügbar", + "unknown": "Unbekannt" } }, "smartHome-executeAutomation": { @@ -3382,8 +3390,13 @@ "networkControllerSummary": { "option": {}, "card": { + "wan": "WAN", + "web": "Web", + "wifi": "WLAN", + "lan": "LAN", "vpn": { - "countConnected": "{count} verbunden" + "label": "VPN", + "countConnected": "{count, plural, one {# verbunden} other {# verbunden}}" } }, "error": { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1f8354dec..770e9ec07 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -741,7 +741,7 @@ }, "create": { "title": "Create Custom Widget", - "subtitle": "Build a safe JSX widget with inline API sources, options, queries, and actions." + "subtitle": "Define an API URL, authentication, and how to display the response data." }, "edit": { "title": "Edit Custom Widget" @@ -766,12 +766,62 @@ "migrate": "Migrate Custom Widget", "copyMigrationPrompt": "Copy LLM migration prompt", "pasteMigration": "Paste migrated widget", - "importMigration": "Import migrated JSON file" + "importMigration": "Import migrated JSON file", + "addMapping": "Add Mapping", + "addColumn": "Add Column", + "removeMapping": "Remove mapping", + "removeColumn": "Remove column", + "addItem": "Add Item", + "removeItem": "Remove item", + "addBar": "Add Bar" }, "field": { "name": "Name", "description": "Description", - "iconUrl": "Icon URL" + "iconUrl": "Icon URL", + "url": "URL", + "authType": "Auth Type", + "headerName": "Header/Query Parameter Name", + "method": "Method", + "requestBody": "Request Body", + "displayType": "Display Type", + "jsonPath": "JSON Path", + "jsonPathHint": "Use JSONPath syntax to extract values, e.g. $.data.temperature or $.items[0].name", + "label": "Label", + "unit": "Unit", + "mappings": "Mappings", + "tablePath": "Array JSON Path", + "tablePathHint": "Path to the array in the response, e.g. $.data.items", + "columns": "Columns", + "header": "Header", + "items": "Items", + "gridColumns": "Grid Columns", + "cardStyle": "Card Style", + "bars": "Bars", + "valuePath": "Value JSON Path", + "maxPath": "Max Value JSON Path", + "showPercentage": "Show Percentage", + "barSize": "Bar Size", + "goodValues": "Good Values (comma-separated)", + "dotSize": "Dot Size", + "valueSize": "Value Size", + "labelPosition": "Label Position", + "kvLayout": "Layout", + "striped": "Striped Rows", + "compact": "Compact Mode", + "maxHeight": "Max Height (px)", + "color": "Color", + "buttonLabel": "Button Label", + "buttonColor": "Button Color", + "confirmText": "Confirmation Text", + "confirmTextHint": "If set, a confirmation dialog will appear before executing the request", + "successMessage": "Success Message", + "successMessageHint": "Message shown after successful execution", + "enabled": "Enabled", + "template": { + "label": "JSX Template", + "description": "Mantine JSX template. Access API response fields via data bindings (e.g. data.name). Use whitelisted components only. Forbidden: constructor, __proto__, eval, Function, window, document." + } }, "editor": { "errorBoundary": { @@ -1028,9 +1078,9 @@ "journal": "Redacted request journal", "actionParameters": "Action parameters (JSON)", "invalidParameters": "Use the declared string, number, and boolean parameter types.", - "runDestructive": "Run destructive action", - "runAction": "Run action", - "deleteConfirmation": "This preview action sends a DELETE request to the configured API.", + "runDestructive": "Run destructive action", + "runAction": "Run action", + "deleteConfirmation": "This preview action sends a DELETE request to the configured API.", "simulated": "Action simulated", "httpStatus": "HTTP {status, number}", "tab": { @@ -1065,7 +1115,11 @@ "modify": "Modify", "full": "Full" } - } + }, + "title": "Preview", + "test": "Test", + "hint": "Click Test to preview the API response with your current configuration.", + "rawResponse": "Raw JSON response" }, "notification": { "created": "Widget \"{name}\" created successfully.", @@ -1078,15 +1132,21 @@ "migrationPromptCopyError": "Failed to copy the migration prompt.", "aiPromptCopied": "AI prompt copied to clipboard", "aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).", - "aiPromptCopyError": "Failed to copy the prompt to the clipboard.", + "aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.", "updateError": "Failed to update widget. Your changes were not saved.", - "importError": "Failed to import widget. Ensure the file uses the homarr-custom-widget-v2 schema.", + "importError": "Failed to import widget. Ensure the file is a valid custom widget JSON export.", "exportError": "Failed to export widget.", "duplicated": "Widget \"{name}\" duplicated.", "duplicateError": "Failed to duplicate widget.", "toggleError": "Failed to toggle widget status.", "deleteError": "Failed to delete widget.", - "previewError": "Failed to create the preview. Check the API source and try again." + "previewError": "Failed to fetch preview. Check the URL and try again.", + "aiPromptDescription": "Copy a pre-built prompt to paste into any AI chatbot (ChatGPT, Claude, Gemini...). It includes the full widget schema and your API response so the AI can generate a ready-to-import JSON config for you.", + "aiPromptCopy": "Copy to clipboard", + "aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.", + "createError": "Failed to create widget. Check your API endpoint is reachable.", + "executed": "Action executed successfully.", + "executeError": "Action failed: {error}" }, "importReview": { "title": "Review custom widget import", @@ -1128,6 +1188,98 @@ "cancel": "Cancel import", "confirm": "Import widget", "confirmMigration": "Confirm v2 replacement" + }, + "placeholder": { + "exampleLabel": "CPU Temperature", + "exampleLabelShort": "CPU Temp", + "exampleHeader": "Name", + "exampleUnit": "°C", + "iconUrl": "https://...", + "url": "https://api.example.com/api/v1/status", + "headerName": "X-API-Key", + "jsonPath": "$.data.value", + "tablePath": "$.data.items", + "columnJsonPath": "$.name", + "valuePath": "$.data.used", + "maxPath": "$.data.total", + "goodValues": "online, true, up", + "color": "blue" + }, + "cardStyleOption": { + "filled": "Filled", + "outline": "Outline", + "subtle": "Subtle" + }, + "layoutOption": { + "list": "List", + "grid": "Grid" + }, + "sizeOption": { + "sm": "Small", + "md": "Medium", + "lg": "Large", + "xl": "Extra Large" + }, + "labelPositionOption": { + "above": "Above", + "below": "Below" + }, + "fieldset": { + "general": "General", + "connection": "API Connection", + "display": "Display Configuration" + }, + "displayTypeDescription": { + "singleValue": "Show one extracted value with a label and unit", + "keyValue": "Show multiple labeled values side by side", + "table": "Show rows of data in a table format", + "statGrid": "Colored stat cards in a grid — like Pi-Hole or Speedtest widgets", + "progressBars": "Progress bars showing usage or completion — for disk, memory, quotas", + "statusIndicator": "Colored status dots showing online/offline or health states", + "countGrid": "Compact number + label counters in a tight grid", + "raw": "Pretty-printed JSON output for debugging", + "actionButton": "A button that triggers a request when clicked", + "customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding" + }, + "method": { + "GET": "GET", + "POST": "POST", + "PUT": "PUT", + "DELETE": "DELETE", + "PATCH": "PATCH" + }, + "authType": { + "none": "None", + "bearer": "Bearer Token", + "basic": "Basic Auth", + "apiKeyHeader": "API Key (Header)", + "apiKeyQuery": "API Key (Query)" + }, + "displayType": { + "singleValue": "Single Value", + "keyValue": "Key-Value", + "table": "Table", + "statGrid": "Stat Grid", + "progressBars": "Progress Bars", + "statusIndicator": "Status Indicator", + "countGrid": "Count Grid", + "raw": "Raw JSON", + "actionButton": "Action Button", + "customJsx": "Custom JSX" + }, + "secret": { + "apiKey": "API Key / Token", + "username": "Username", + "password": "Password", + "savedPlaceholder": "••••••• (saved, leave empty to keep)" + }, + "table": { + "name": "Name", + "url": "URL", + "display": "Display" + }, + "widget": { + "disabled": "This widget is disabled" } }, "workshop": { @@ -1327,7 +1479,8 @@ "analytics": "Analytics", "vpn": "VPN", "mediaLibrary": "Media Library", - "uptimeMonitoring": "Uptime Monitoring" + "uptimeMonitoring": "Uptime Monitoring", + "beszel": "Beszel" }, "testConnection": { "action": { @@ -1846,7 +1999,13 @@ "kilometersPerHour": "km/h", "milesPerHour": "mph" } - } + }, + "widgetQuery": { + "loading": "Loading widget data", + "stale": "Could not refresh {widget}; showing saved data" + }, + "total": "Total", + "filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}" }, "section": { "option": { @@ -1894,6 +2053,67 @@ "title": "Remove container", "message": "Remove this container? Its contents will move into the surrounding area." } + }, + "dynamic": { + "action": { + "create": "New dynamic section", + "remove": "Remove dynamic section" + }, + "option": { + "title": { + "label": "Title" + }, + "customCssClasses": { + "label": "Custom css classes" + }, + "borderColor": { + "label": "Border color" + } + }, + "remove": { + "title": "Remove dynamic section", + "message": "Are you sure you want to remove this dynamic section? Items will be moved at the same location in the parent section." + } + }, + "category": { + "field": { + "name": { + "label": "Name" + } + }, + "action": { + "create": "New category", + "edit": "Rename category", + "remove": "Remove category", + "moveUp": "Move up", + "moveDown": "Move down", + "createAbove": "New category above", + "createBelow": "New category below", + "openAllInNewTabs": "Open all in tabs" + }, + "create": { + "title": "New category", + "submit": "Add category" + }, + "remove": { + "title": "Remove category", + "message": "Are you sure you want to remove the category {name}?" + }, + "edit": { + "title": "Rename category", + "submit": "Rename category" + }, + "menu": { + "label": { + "create": "New category", + "changePosition": "Change position", + "settings": "Settings" + } + }, + "openAllInNewTabs": { + "title": "Open all in tabs", + "text": "Some browsers may block the bulk-opening of tabs for security reasons. Homarr was unable to open all windows, because your browser blocked this action. Please allow \"Open pop-up windows\" and re-try." + } } }, "item": { @@ -1918,12 +2138,25 @@ "error": "Error", "loading": "Loading", "idle": "Idle" + }, + "notification": { + "saveError": { + "title": "Unable to save widget", + "message": "The widget changes were rolled back" + } } }, "create": { "title": "Choose item to add", "search": "Filter items", - "addToBoard": "Add to board" + "addToBoard": "Add to board", + "addAtPosition": "Add item here", + "notification": { + "error": { + "title": "Unable to add item", + "message": "The item could not be added to this board" + } + } }, "moveResize": { "title": "Move / resize item", @@ -1987,6 +2220,11 @@ "remove": { "title": "Remove item", "message": "Are you sure you want to remove this item?" + }, + "advancedFocus": { + "open": "Open advanced view", + "close": "Close advanced view", + "label": "{widget} advanced view" } }, "widget": { @@ -2091,6 +2329,26 @@ "showBroadcastMessage": { "label": "Show broadcast messages" } + }, + "noProjectSelected": "No project selected", + "runnerStatus": "Runner: {status}", + "updatedAt": "Updated {date}", + "metric": { + "running": "Running", + "completed": "Done", + "failed": "Failed", + "canceled": "Canceled", + "download": "Download", + "upload": "Upload" + }, + "status": { + "running": "Running", + "completed": "Completed", + "failed": "Failed", + "canceled": "Canceled", + "stopped": "Stopped", + "idle": "Idle", + "unknown": "Unknown" } }, "dnsHoleSummary": { @@ -2125,7 +2383,12 @@ "dnsQueriesToday": "Queries today", "domainsBeingBlocked": "Domains on blocklist" }, - "domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked" + "domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked", + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "unknown": "Unknown" + } }, "dnsHoleControls": { "name": "DNS Hole Controls", @@ -2150,7 +2413,8 @@ } }, "error": { - "internalServerError": "Failed to control DNS Hole" + "internalServerError": "Failed to control DNS Hole", + "bulkActionsFailed": "{count, plural, one {# action failed} other {# actions failed}}" }, "controls": { "enableAll": "Enable All", @@ -2244,11 +2508,20 @@ "noStation": "No station selected", "option": { "baseUrl": { - "label": "Search.ch URL" + "label": "Search.ch URL", + "description": "Custom URLs must resolve to a public IP address. Private and local hosts are rejected." }, "station": { "label": "Station" } + }, + "error": { + "customSourceUnavailable": "The timetable source is unavailable. Check that its URL resolves to a public IP address.", + "stationSearch": "Unable to search stations. Check the custom URL and your board access." + }, + "warning": { + "stale": "The timetable could not be refreshed. Showing departures last updated at {time}.", + "staleCompact": "Stale data" } }, "notebook": { @@ -2321,7 +2594,9 @@ "discard": "Discard changes", "keepEditing": "Keep editing" } - } + }, + "saveFailed": "Unable to save note", + "documentStats": "{words, plural, one {# word} other {# words}} · {characters, plural, one {# character} other {# characters}}" }, "anchorNote": { "name": "Anchor Note", @@ -2349,7 +2624,17 @@ "cancel": "Cancel", "readOnlyViewer": "You have viewer access to this note. Editing is disabled.", "saveForbidden": "You do not have permission to edit this note.", - "saveFailed": "Failed to save this note. Please try again." + "saveFailed": "Failed to save this note. Please try again.", + "createdAt": "Created {date}", + "permission": { + "owner": "Owner", + "editor": "Editor", + "viewer": "Viewer" + }, + "status": { + "pinned": "Pinned", + "archived": "Archived" + } }, "iframe": { "name": "iFrame", @@ -2408,6 +2693,25 @@ "clickable": { "label": "Clickable" } + }, + "state": { + "on": "On", + "off": "Off", + "unavailable": "Unavailable", + "unknown": "Unknown" + }, + "advanced": { + "entityId": "Entity ID: {id}", + "lastUpdated": "Updated {date}", + "attribute": { + "unit": "Unit", + "deviceClass": "Device class", + "icon": "Icon" + } + }, + "error": { + "loadFailed": "Unable to load entity", + "toggleFailed": "Unable to update entity" } }, "smartHome-executeAutomation": { @@ -2423,6 +2727,17 @@ }, "spotlightAction": { "run": "Run {name}" + }, + "advanced": { + "lastExecuted": "Last run at {time}", + "automationId": "Automation ID: {id}" + }, + "status": { + "running": "Running automation", + "success": "Automation completed" + }, + "error": { + "executeFailed": "Unable to run automation" } }, "stockPrice": { @@ -2525,6 +2840,13 @@ "showUnmonitored": { "label": "Show unmonitored" } + }, + "advanced": { + "previousMonth": "Show previous month", + "nextMonth": "Show next month", + "eventCount": "{count, plural, one {# event} other {# events}}", + "loading": "Loading calendar events...", + "noEvents": "No events in this month" } }, "weather": { @@ -2585,6 +2907,18 @@ "thunderstorm": "Thunderstorm", "thunderstormWithHail": "Thunderstorm with hail", "unknown": "Unknown" + }, + "details": "Show weather details", + "detailsFor": "Show weather details for {date}", + "advanced": { + "forecast": "7-day forecast", + "day": "Day", + "conditions": "Conditions", + "temperature": "High / low", + "humidity": "Humidity", + "wind": "Wind / gusts ({unit})", + "sun": "Sunrise / sunset", + "noForecast": "No forecast data available" } }, "indexerManager": { @@ -2772,17 +3106,40 @@ "internalServerError": "Failed to fetch Coolify data" }, "server": { - "buildServer": "Build" + "buildServer": "Build", + "resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}" }, "instances": { "title": "Instances" + }, + "action": { + "openResource": "Open {name}", + "openLogs": "Open {name} logs", + "showIp": "Show IP address", + "hideIp": "Hide IP address" + }, + "status": { + "running": "Running", + "stopped": "Stopped", + "exited": "Exited", + "starting": "Starting", + "restarting": "Restarting", + "unknown": "Unknown", + "online": "Online", + "offline": "Offline" } }, "vpn": { "name": "VPN", "description": "Overview of your VPN connections", "serviceUnavailable": "VPN unavailable", - "option": {} + "option": {}, + "status": { + "running": "Running", + "notRunning": "Not running", + "unavailable": "Unavailable", + "vpn": "VPN: {status}" + } }, "ups": { "name": "UPS", @@ -2809,7 +3166,13 @@ "runtime": "Runtime", "load": "Load", "input": "Input", - "output": "Output" + "output": "Output", + "batteryVoltage": "Battery voltage", + "power": "Power", + "temperature": "Temperature", + "manufacturer": "Manufacturer", + "model": "Model", + "serial": "Serial" }, "error": { "internalServerError": "Failed to fetch UPS data" @@ -3080,7 +3443,13 @@ "resolution": "Resolution", "target": "Target codec" } - } + }, + "unknownUser": "Unknown user" + }, + "summary": { + "sessions": "{count, plural, one {# session} other {# sessions}}", + "playing": "{count} playing", + "transcoding": "{count} transcoding" } }, "paperlessNgx": { @@ -3339,7 +3708,8 @@ "users": "Users", "photos": "Photos", "videos": "Videos", - "storage": "Used Storage" + "storage": "Used Storage", + "albumLimit": "Largest albums (up to {count} shown)" }, "immich-albumCarousel": { "name": "Immich Album", @@ -3363,7 +3733,16 @@ } }, "description": "Slideshow of pictures from an Immich album or your full library", - "noPhotos": "No photos found" + "noPhotos": "No photos found", + "albumPhoto": "Album photo", + "actions": { + "previousPhoto": "Previous photo", + "nextPhoto": "Next photo", + "pauseSlideshow": "Pause slideshow", + "resumeSlideshow": "Resume slideshow", + "toggleSlideshow": "Pause or resume slideshow", + "photo": "Photo {number}" + } }, "downloads": { "name": "Download Client", @@ -3738,6 +4117,13 @@ "showPosterImage": { "label": "Show poster image" } + }, + "error": { + "allFeedsFailed": "Unable to load RSS feeds. Check the feed URLs and try again." + }, + "warning": { + "partial": "{count, plural, one {# RSS feed could not be loaded} other {# RSS feeds could not be loaded}}", + "stale": "RSS feeds could not be refreshed. Showing previously loaded entries." } }, "releases": { @@ -3868,14 +4254,21 @@ "noProviderResponse": "No response from provider", "unexpected": "Unexpected provider error" } - } + }, + "expandDetails": "Show details for {name}", + "collapseDetails": "Hide details for {name}" }, "networkControllerSummary": { "option": {}, "card": { "vpn": { - "countConnected": "{count} connected" - } + "countConnected": "{count, plural, one {# connected} other {# connected}}", + "label": "VPN" + }, + "wan": "WAN", + "web": "Web", + "wifi": "Wi-Fi", + "lan": "LAN" }, "error": { "integrationsDisconnected": "No data available, all integrations disconnected", @@ -3953,6 +4346,16 @@ "recv": "Received", "title": "Network Interfaces" } + }, + "empty": { + "noInterfaces": "No network interface data yet" + }, + "versionUnknown": "Unknown version", + "metricAccessible": "{metric}: {value}%, {status}", + "status": { + "normal": "Normal", + "warning": "Warning", + "critical": "Critical" } }, "notifications": { @@ -4002,6 +4405,11 @@ "noSectionsEnabled": "Enable at least one section in widget options.", "error": { "internalServerError": "Failed to fetch speedtest data" + }, + "range": { + "twelveHours": "12h", + "oneDay": "24h", + "all": "All" } }, "uptimeKuma": { @@ -4042,6 +4450,11 @@ }, "error": { "internalServerError": "Failed to fetch uptime data" + }, + "status": { + "up": "Up", + "down": "Down", + "paused": "Paused" } }, "traefik": { @@ -4076,7 +4489,20 @@ }, "error": { "internalServerError": "Failed to fetch Traefik data" - } + }, + "resourceType": { + "router": "Router", + "service": "Service", + "middleware": "Middleware" + }, + "resourceStatus": { + "enabled": "Enabled", + "disabled": "Disabled", + "warning": "Warning", + "error": "Error", + "unknown": "Unknown" + }, + "failedEndpoint": "Failed: {endpoint}" }, "audioStats": { "name": "Audio stats", @@ -4459,7 +4885,9 @@ "internalServerError": "Unable to connect to Beszel. Check your integration settings.", "liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.", "switchToHistorical": "Switch to historical view", - "systemNotFound": "Selected system was not found. Choose a different system." + "systemNotFound": "Selected system was not found. Choose a different system.", + "selectionSaveTitle": "Unable to save selection", + "selectionSaveMessage": "Your system or time period selection could not be saved. Please try again." }, "chart": { "cpu": { @@ -4507,6 +4935,14 @@ "title": "Docker Network I/O", "subtitle": "Network traffic of docker containers" } + }, + "period": { + "live": "Live", + "oneHour": "1h", + "twelveHours": "12h", + "oneDay": "24h", + "oneWeek": "1w", + "thirtyDays": "30d" } } }, diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 8cc322bab..83e54a33e 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -1253,6 +1253,8 @@ "success": "Succès", "beta": "Bêta", "error": "Erreur", + "total": "Total", + "filesystemUsage": "{count, plural, one {Afficher l’utilisation de # système de fichiers} other {Afficher l’utilisation de # systèmes de fichiers}}", "action": { "add": "Ajouter", "apply": "Appliquer", @@ -1916,6 +1918,12 @@ "clickable": { "label": "Cliquable" } + }, + "state": { + "on": "Activé", + "off": "Désactivé", + "unavailable": "Indisponible", + "unknown": "Inconnu" } }, "smartHome-executeAutomation": { @@ -3165,8 +3173,13 @@ "networkControllerSummary": { "option": {}, "card": { + "wan": "WAN", + "web": "Web", + "wifi": "Wi-Fi", + "lan": "LAN", "vpn": { - "countConnected": "{count} connecté" + "label": "VPN", + "countConnected": "{count, plural, one {# connecté} other {# connectés}}" } }, "error": { diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 9b09a6370..c945b9b16 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -1200,6 +1200,8 @@ "success": "הצלחה", "beta": "בטא", "error": "שגיאה", + "total": "סה״כ", + "filesystemUsage": "{count, plural, one {הצגת השימוש במערכת קבצים אחת} other {הצגת השימוש ב-# מערכות קבצים}}", "action": { "add": "הוסף", "apply": "החל", @@ -1863,6 +1865,12 @@ "clickable": { "label": "ניתן ללחיצה" } + }, + "state": { + "on": "פעיל", + "off": "כבוי", + "unavailable": "לא זמין", + "unknown": "לא ידוע" } }, "smartHome-executeAutomation": { @@ -3328,8 +3336,13 @@ "networkControllerSummary": { "option": {}, "card": { + "wan": "WAN", + "web": "אינטרנט", + "wifi": "אלחוטי", + "lan": "LAN", "vpn": { - "countConnected": "{count} מחובר" + "label": "VPN", + "countConnected": "{count, plural, one {אחד מחובר} other {# מחוברים}}" } }, "error": { diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 07a86f6a3..2a3d7b0eb 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -3,3 +3,11 @@ @import "@mantine/dates/styles.css"; @import "@gfazioli/mantine-onboarding-tour/styles.css"; @import "mantine-react-table/styles.css"; + +:root { + --homarr-z-index-widget-background-tooltip: calc(var(--mantine-z-index-modal) - 4); + --homarr-z-index-widget-preview-backdrop: calc(var(--mantine-z-index-modal) - 3); + --homarr-z-index-board-header: calc(var(--mantine-z-index-modal) - 2); + --homarr-z-index-widget-focus: calc(var(--mantine-z-index-modal) - 1); + --homarr-z-index-modal-tooltip: calc(var(--mantine-z-index-modal) + 2); +} diff --git a/packages/validation/src/board.spec.ts b/packages/validation/src/board.spec.ts index c8220f445..8a6e8b37c 100644 --- a/packages/validation/src/board.spec.ts +++ b/packages/validation/src/board.spec.ts @@ -44,8 +44,16 @@ describe("section behavior validation", () => { }); }); - it.each(["category", "dynamic"])("rejects the legacy %s kind", (kind) => { - expect(() => sectionSchema.parse({ id: "legacy", kind, layouts: [] })).toThrow(); + it("normalizes the legacy dynamic kind to a container", () => { + expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({ + id: "legacy", + kind: "container", + layouts: [], + }); + }); + + it("rejects the legacy category kind", () => { + expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow(); }); it.each([ diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts index cd7ec8ac3..6a6a3e92d 100644 --- a/packages/validation/src/shared.ts +++ b/packages/validation/src/shared.ts @@ -93,4 +93,8 @@ const containerSectionSchema = z.object({ ), }); -export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema]); +const legacyDynamicSectionSchema = containerSectionSchema + .extend({ kind: z.literal("dynamic") }) + .transform((section) => ({ ...section, kind: "container" as const })); + +export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema, legacyDynamicSectionSchema]); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index fb07be119..d5d9740f2 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -81,6 +81,7 @@ "@tiptap/extension-underline": "catalog:", "@tiptap/react": "catalog:", "@tiptap/starter-kit": "catalog:", + "@trpc/react-query": "catalog:", "acorn": "catalog:", "acorn-jsx": "catalog:", "clsx": "catalog:", diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 3d575bfb0..abeb7e350 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -9,6 +9,7 @@ export interface CommonWidgetInputProps { options: Omit, "defaultValue" | "type">; initialOptions: Record; itemId?: string; + boardId?: string; } type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string; diff --git a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx index 3b5b636aa..17a340421 100644 --- a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx @@ -17,18 +17,24 @@ 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(); const inputProps = form.getInputProps(`options.${property}`); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 300); - const { isPending, options: selectOptions } = options.useOptions( - debouncedSearch, - form.values.integrationIds, - form.values.options, - ); + const { + error: optionsError, + isPending, + options: selectOptions, + } = options.useOptions(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; @@ -90,6 +96,7 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg description={options.withDescription ? tWidget("description") : undefined} searchable {...inputProps} + error={inputProps.error ?? optionsError} value={currentOption === null ? null : currentOption.value} onChange={(selectedValue: string | null) => { if (selectedValue === null) { diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx index 37d26cf2e..91501aa2c 100644 --- a/packages/widgets/src/_inputs/widget-location-input.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.tsx @@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createModal, useModalAction } from "@homarr/modals"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; +import { formatLocalizedCompactNumber } from "../common/locale"; import type { OptionLocation } from "../options"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; @@ -128,7 +129,7 @@ const LocationSearchModal = createModal(({ actions, in if (error) { return ( - {error.message} + {tCommon("action.tryAgain")} ); } @@ -187,6 +188,7 @@ interface LocationSearchTableRowProps { const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => { const t = useScopedI18n("widget.common.location.table"); + const locale = useCurrentIntlLocale(); const onSelect = useCallback(() => { onLocationSelect({ name: city.name, @@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location closeModal(); }, [city, onLocationSelect, closeModal]); - const formatter = Intl.NumberFormat("en", { notation: "compact" }); - return ( @@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location {city.population ? ( - {formatter.format(city.population)} + {formatLocalizedCompactNumber(city.population, locale)} ) : ( {t("population.fallback")} )} diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 45c9ed595..9e3b925e4 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -272,19 +272,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId }); const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch(), - onError: (error) => { + onError: () => { showErrorNotification({ - title: "Failed to save token", - message: error.message, + title: tRepository("tokens.label"), + message: tRepository("tokens.notConfigured"), }); }, }); const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch(), - onError: (error) => { + onError: () => { showErrorNotification({ - title: "Failed to delete token", - message: error.message, + title: tRepository("tokens.label"), + message: tRepository("tokens.notConfigured"), }); }, }); diff --git a/packages/widgets/src/advanced-focus-support.spec.ts b/packages/widgets/src/advanced-focus-support.spec.ts new file mode 100644 index 000000000..44eb51a45 --- /dev/null +++ b/packages/widgets/src/advanced-focus-support.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; + +import { supportsAdvancedFocus } from "./definition"; +import { widgetImports } from "."; + +const advancedFocusWidgetKinds = [ + "anchorNote", + "archiveTeamWarrior", + "audioStats", + "bookmarks", + "calendar", + "clock", + "downloads", + "firewall", + "healthMonitoring", + "immich-albumCarousel", + "immich-serverStats", + "mediaMissing", + "mediaReleases", + "mediaServer", + "mediaTranscoding", + "networkControllerStatus", + "smartHome-entityState", + "smartHome-executeAutomation", + "speedtestTracker", + "systemResources", + "timetable", + "tracearr", + "traefik", + "umami", + "ups", + "uptimeKuma", + "weather", +] as const; + +describe("advanced focus support", () => { + test("is reserved for widgets with a distinct advanced experience", () => { + const actualAdvancedFocusKinds = Object.entries(widgetImports) + .filter(([, widgetImport]) => supportsAdvancedFocus(widgetImport.definition)) + .map(([kind]) => kind) + .toSorted(); + + expect(actualAdvancedFocusKinds).toEqual([...advancedFocusWidgetKinds].toSorted()); + }); +}); diff --git a/packages/widgets/src/anchor-note/anchor-note.css b/packages/widgets/src/anchor-note/anchor-note.css index c28655e87..d107b2a77 100644 --- a/packages/widgets/src/anchor-note/anchor-note.css +++ b/packages/widgets/src/anchor-note/anchor-note.css @@ -4,6 +4,34 @@ height: 100%; } +.homarr-anchor-actions { + opacity: 0; + transform: translateY(-2px); + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.homarr-anchor-note:hover .homarr-anchor-actions, +.homarr-anchor-note:focus-within .homarr-anchor-actions, +.homarr-anchor-actions[data-visible="true"] { + opacity: 1; + transform: none; +} + +@media (any-pointer: coarse) { + .homarr-anchor-actions { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .homarr-anchor-actions { + transition: none; + } +} + .homarr-anchor-quill .ql-toolbar.ql-snow, .homarr-anchor-quill .ql-container.ql-snow { border: 0; diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 90a1c998c..9e786afa8 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -3,19 +3,23 @@ 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 { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core"; +import { IconEdit } from "@tabler/icons-react"; 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"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; +import { getUsableWidgetQueryData } from "../common/query-state"; +import actionTargetClasses from "../common/action-target.module.css"; import "react-quill-new/dist/quill.snow.css"; import "./anchor-note.css"; @@ -106,6 +110,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 +130,13 @@ 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, +}: WidgetComponentProps<"anchorNote">) { const t = useScopedI18n("widget.anchorNote"); const noteId = options.noteId.trim(); if (!noteId) { @@ -134,21 +151,43 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const integrationId = integrationIds[0]!; - return ; + return ( + + ); } interface AnchorNoteWidgetContentProps { options: WidgetComponentProps<"anchorNote">["options"]; integrationId: string; noteId: string; + width: number; + height: number; + isAdvanced: boolean; } -const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => { +const AnchorNoteWidgetContent = ({ + options, + integrationId, + noteId, + width, + height, + isAdvanced, +}: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); - const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({ + const locale = useCurrentIntlLocale(); + const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({ integrationId, noteId, }); + const note = getUsableWidgetQueryData(noteQuery); + const { refetch } = noteQuery; const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation(); const [isEditing, setIsEditing] = useState(false); @@ -165,7 +204,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 +218,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( (_html, _delta, source, editor) => { if (!isEditing || source !== "user") return; @@ -260,7 +301,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW if (!note) return ; return ( - + {isEditing ? ( @@ -278,40 +319,85 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW {t("readOnlyViewer")} )} + {!isEditing && isAdvanced && ( + + + {t(`permission.${note.permission}`)} + + {note.isPinned && {t("status.pinned")}} + {note.isArchived && ( + + {t("status.archived")} + + )} + + {t("createdAt", { date: new Date(note.createdAt).toLocaleDateString(locale) })} + + + )} {saveError && ( {saveError} )} - - {isEditing ? ( - <> - + + + ) : isAdvanced ? ( + - - - ) : ( - - )} - + ) : ( + + + + + + )} + + )}
-
- -
+ {isEditing || isAdvanced ? ( +
+ +
+ ) : ( + + {plainText || t("emptyContent")} + + )} ); }; diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts index 896570b65..4eac1a441 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -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) => ({ diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 56e079a8c..acda12e12 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import { Fragment, Suspense } from "react"; -import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { IconMinus } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; +import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; import { PingDot } from "./ping/ping-dot"; @@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); - const { data: app } = clientApi.app.byId.useQuery({ - id: options.appId, - }); + const appQuery = clientApi.app.byId.useQuery({ id: options.appId }); + const app = getUsableWidgetQueryData(appQuery); + const href = getSafeAppHref(app?.href); useRegisterSpotlightContextResults( `app-${app?.id ?? options.appId}`, - app?.href + app && href ? [ { id: app.id, @@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget interaction() { return { type: "link", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - href: app.href!, + href, newTab: options.openInNewTab, }; }, }, ] : [], - [app, options.openInNewTab], + [app, href, options.openInNewTab], ); + if (isInitialWidgetQueryPending(appQuery)) return ; if (!app) return ; const isTiny = height < 100 || width < 100; const isColumnLayout = options.layout.startsWith("column"); return ( - - ( - - {line} -
-
- ))} - position="right-start" - multiline - disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode} - styles={{ tooltip: { maxWidth: 300 } }} - > - e.preventDefault() : undefined} + + + ( + + {line} +
+
+ ))} + position="right-start" + multiline + disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode} + styles={{ tooltip: { maxWidth: 300 } }} > - - {options.showTitle && ( - - {app.name} - - )} - {options.descriptionDisplayMode === "normal" && ( - - {app.description?.split("\n").map((line, index) => ( - - {line} -
-
- ))} -
- )} -
- -
-
- {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( - }> - - - ) : null} -
+ e.preventDefault() : undefined} + > + + {options.showTitle && ( + + {app.name} + + )} + {options.descriptionDisplayMode === "normal" && !isTiny && ( + + {app.description?.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +
+ )} +
+ +
+ + {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( + }> + + + ) : null} + + {appQuery.error && ( + + + + )} + ); } @@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts index 7755ac105..7b8416ca9 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -15,7 +15,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("app", { icon: IconApps, - queryKeys: [[["app", "byId"]], [["widget", "app"]]], + supportsAdvancedFocus: false, + queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]], createOptions(settings) { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx index 75dc38974..1548881b5 100644 --- a/packages/widgets/src/app/ping/ping-indicator.tsx +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -11,7 +11,9 @@ interface PingIndicatorProps { export const PingIndicator = ({ appId }: PingIndicatorProps) => { const t = useI18n(); - const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false }); + const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false }); + + if (error) return ; if (!pingResult) return ; diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index 5ac03eadc..35cb3049e 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -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 { useCurrentIntlLocale, 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,47 @@ export default function ArchiveTeamWarriorWidget({ return null; } - return ; + return ( + + ); } 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 locale = useCurrentIntlLocale(); 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); + const layout = getArchiveCompactLayout(width, height, isAdvanced); + const countMetrics = [ + { label: t("metric.running"), value: status.counts.running }, + ...(layout.showSecondaryCounts ? [{ label: t("metric.completed"), value: status.counts.completed }] : []), + { label: t("metric.failed"), value: status.counts.failed }, + ...(layout.showSecondaryCounts ? [{ label: t("metric.canceled"), value: status.counts.canceled }] : []), + ]; return ( - + @@ -43,12 +71,12 @@ const ArchiveTeamWarriorWidgetContent = ({ - - {status.status} + + {t(`status.${statusKey}`)} - {options.showBroadcastMessage && status.broadcastMessage && ( + {options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && ( {status.broadcastMessage} @@ -56,32 +84,112 @@ const ArchiveTeamWarriorWidgetContent = ({ )} - - - - - + + {countMetrics.map((metric) => ( + + ))} - {status.bandwidth && ( - - - + {status.bandwidth && layout.showBandwidth && ( + + + )} + {isAdvanced && ( + <> + + + {t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })} + + + {t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })} + + + + + {status.items.map((item) => { + const itemStatusKey = getStatusKey(item.status); + return ( + + + + + {item.name} + + + {item.project ?? projectName} + + + + {t(`status.${itemStatusKey}`)} + + + + ); + })} + + + + )} ); }; -const Metric = ({ label, value }: { label: string; value: number | string }) => ( +const Metric = ({ label, value, dense = false }: { label: string; value: number | string; dense?: boolean }) => ( - + {value} - + {label} ); +export const getArchiveCompactLayout = (width: number, height: number, isAdvanced = false) => { + const dense = !isAdvanced && (width < 240 || height < 160); + const showSecondaryCounts = isAdvanced || (width >= 200 && height >= 120); + const metricCount = showSecondaryCounts ? 4 : 2; + + return { + dense, + gap: dense ? 4 : "xs", + metricColumns: height < 160 ? metricCount : Math.min(metricCount, width >= 260 ? 4 : 2), + showBandwidth: isAdvanced || height >= 190, + showBroadcast: isAdvanced || height >= 240, + showSecondaryCounts, + } as const; +}; + 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"; +}; diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts index 5e0998ec2..38a3eef36 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -5,6 +5,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", { icon: IconArchive, + supportsAdvancedFocus: true, + maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showBroadcastMessage: factory.switch({ diff --git a/packages/widgets/src/archive-team-warrior/layout.spec.ts b/packages/widgets/src/archive-team-warrior/layout.spec.ts new file mode 100644 index 000000000..213c5bd11 --- /dev/null +++ b/packages/widgets/src/archive-team-warrior/layout.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; + +import { getArchiveCompactLayout } from "./component"; + +describe("archive team warrior compact layout", () => { + test("keeps only primary counters in a minimum-size widget", () => { + expect(getArchiveCompactLayout(180, 100)).toMatchObject({ + metricColumns: 2, + showBandwidth: false, + showBroadcast: false, + showSecondaryCounts: false, + }); + }); + + test("adds secondary information as height becomes available", () => { + expect(getArchiveCompactLayout(320, 260)).toMatchObject({ + metricColumns: 4, + showBandwidth: true, + showBroadcast: true, + showSecondaryCounts: true, + }); + }); +}); diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index b6824a1fe..3592744a4 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -8,7 +8,14 @@ import { useScopedI18n } from "@homarr/translation/client"; import classes from "./component.module.css"; import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared"; -import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared"; +import { + AUDIO_STATS_TRANSLATION_SCOPE, + getGridCols, + getIconSize, + prioritizeVisibleStats, + getVisibleStatLimit, + getVisibleStats, +} from "./shared"; const rootClassByCompact = { true: classes.rootCompact, @@ -30,14 +37,17 @@ interface AudioStatsContentProps { stats: NavidromeDashboardData | AudiobookshelfDashboardData; options: AudioStatsDisplayOptions; width: number; + height: number; } -export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) { +export function AudioStatsContent({ backend, stats, options, width, height }: AudioStatsContentProps) { const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE); - const visibleStats = getVisibleStats(backend, options, stats); - const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact; - const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false); - const iconSize = getIconSize(width, options.compactMode ?? false); + const compact = options.compactMode ?? false; + const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats), compact); + const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact)); + const compactKey = String(compact) as keyof typeof rootClassByCompact; + const gridCols = getGridCols(width, visibleStats.length, compact); + const iconSize = getIconSize(width, compact); return (
diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts index 406562f66..c4484ac12 100644 --- a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts +++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; -import { getGridCols, getIconSize, getVisibleStats } from "./shared"; +import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared"; describe("getGridCols", () => { test("caps columns to visible stat count", () => { @@ -37,6 +37,17 @@ describe("getIconSize", () => { }); }); +describe("getVisibleStatLimit", () => { + test("keeps compact metrics legible when height is constrained", () => { + expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1); + expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3); + }); + + test("preserves every configured metric outside compact mode", () => { + expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5); + }); +}); + describe("getVisibleStats", () => { const navidromeStats: NavidromeDashboardData = { artistCount: 100, @@ -91,3 +102,21 @@ describe("getVisibleStats", () => { expect(typeof result[0]?.value).toBe("string"); }); }); + +describe("prioritizeVisibleStats", () => { + test("puts an active session first only on compact surfaces", () => { + const stats = getVisibleStats( + "audiobookshelf", + { showLibraryCount: true, showActiveSessions: true }, + { + libraryCount: 3, + totalAudiobooks: 150, + totalPodcasts: 20, + totalListeningTimeSeconds: 86400, + activeSessions: 2, + }, + ); + expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions"); + expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount"); + }); +}); diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 9e410f527..16ff58069 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -1,17 +1,120 @@ "use client"; +import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { getQueryKey } from "@trpc/react-query"; + 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 { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import type { WidgetComponentProps } from "../definition"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { AudioStatsContent } from "./audio-stats-content"; -export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) { - const { data: response } = clientApi.widget.audioStats.getStats.useQuery({ - integrationId: integrationIds[0] ?? "", - }); +export default function AudioStatsWidget({ + integrationIds, + options, + width, + height, + displayMode = "compact", + widgetRuntimeRef, +}: WidgetComponentProps<"audioStats">) { + const statsInput = { integrationId: integrationIds[0] ?? "" }; + const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput); + const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true }; + const streamsEnabled = displayMode === "advanced" && response?.kind === "navidrome"; + const { data: streamResults, error: streamsError } = clientApi.widget.mediaServer.getCurrentStreams.useQuery( + streamsInput, + { enabled: streamsEnabled }, + ); + useWidgetRuntimeQueries(widgetRuntimeRef, [ + getQueryKey(clientApi.widget.audioStats.getStats, statsInput, "query"), + ...(streamsEnabled ? [getQueryKey(clientApi.widget.mediaServer.getCurrentStreams, streamsInput, "query")] : []), + ]); + if (statsError && response === undefined) throw statsError; + if (streamsEnabled && streamsError && streamResults === undefined) throw streamsError; + const currentStreams = streamResults ?? []; if (!response) return ; - return ; + const summary = ( + + ); + if (displayMode === "compact") return summary; + + const sessions = currentStreams.flatMap((result) => + result.sessions.map((session) => ({ integrationId: result.integrationId, session })), + ); + const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null; + const advancedGridColumns = width >= 760 ? 2 : 1; + const hasStreamErrors = currentStreams.some(({ error }) => Boolean(error)); + return ( + +
{summary}
+ {hasStreamErrors && ( + + + + )} + + {response.kind === "navidrome" ? ( + + {sessions.map(({ integrationId, session }) => { + const currentlyPlaying = session.currentlyPlaying; + if (!currentlyPlaying) return null; + return ( + + + + {currentlyPlaying.name} + + + {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user?.username ?? "—"} + + + + {session.sessionName} + + + ); + })} + + ) : ( + + {(audiobookStats?.libraries ?? []).map((library) => ( + + + + {library.name} + + + {library.totalItems} + + + + {library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "} + {library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)} + + + ))} + + )} + +
+ ); } diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index ffcd2dd93..6fb3b2014 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -1,6 +1,6 @@ import { IconHeadphones, IconServerOff } from "@tabler/icons-react"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; const hideUnlessNavidrome = { @@ -13,6 +13,9 @@ const hideUnlessAudiobookshelf = { export const { definition, componentLoader } = createWidgetDefinition("audioStats", { icon: IconHeadphones, + supportsAdvancedFocus: true, + queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]], + queryMatcher: matchesWidgetRuntimeQuery, supportedIntegrations: ["navidrome", "audiobookshelf"], integrationsRequired: true, maxIntegrations: 1, diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts index d4b79c72c..15e6b8a12 100644 --- a/packages/widgets/src/audio-stats/shared.ts +++ b/packages/widgets/src/audio-stats/shared.ts @@ -155,6 +155,23 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea return Math.min(maxCols, Math.max(visibleCount, 1)); }; +export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => { + if (!compact) return visibleCount; + + const columns = getGridCols(width, visibleCount, true); + const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44)); + return Math.min(visibleCount, columns * rows); +}; + +export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => { + if (!compact) return stats; + return stats.toSorted( + (left, right) => + Number(right.statKey === "activeSessions" && Number(right.value) > 0) - + Number(left.statKey === "activeSessions" && Number(left.value) > 0), + ); +}; + const fallbackIconSize = { true: 14, false: 16 } as const; export const getIconSize = (width: number, compact: boolean): number => { diff --git a/packages/widgets/src/bazarr/component.module.css b/packages/widgets/src/bazarr/component.module.css index 9b5857379..a41eeb5bf 100644 --- a/packages/widgets/src/bazarr/component.module.css +++ b/packages/widgets/src/bazarr/component.module.css @@ -33,6 +33,16 @@ overflow: hidden; } +.grid[data-short] .statTile { + flex-direction: row; + gap: 3px; + padding: 3px 4px; +} + +.grid[data-short] .statLabel { + text-align: left; +} + .statTileWarning { background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5)); } diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 094c32ee1..4d4e9cedd 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -26,6 +26,7 @@ const statIcons = { } as const; const gridColsByWidth = [ + { minWidth: 640, cols: 4 }, { minWidth: 380, cols: 2 }, { minWidth: 0, cols: 1 }, ] as const; @@ -36,13 +37,14 @@ 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 }: WidgetComponentProps<"bazarr">) { const t = useScopedI18n("widget.bazarr"); - const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery( + const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery( { integrationId: integrationIds[0] ?? "" }, { enabled: Boolean(integrationIds[0]) }, ); + if (error && !badges) throw error; if (!badges) return ; const statValues = { @@ -56,8 +58,8 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC .filter(([optionKey]) => options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - const gridCols = getGridCols(width); - const iconSize = getIconSize(width); + const gridCols = getGridCols(width, height, visibleStatKeys.length); + const iconSize = getIconSize(Math.min(width, height)); if (visibleStatKeys.length === 0) { return ( @@ -73,7 +75,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC return (
-
+
{visibleStatKeys.map((statKey) => { const Icon = statIcons[statKey]; const value = statValues[statKey]; @@ -83,7 +89,9 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
{value} - {t(statKey)} + + {t(statKey)} +
); })} @@ -92,12 +100,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): number { + if (itemCount <= 1) return 1; + + const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1; + const maxColumnsByWidth = Math.max(1, Math.floor(width / 100)); + const maxRowsByHeight = Math.max(1, Math.floor(height / 72)); + const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight); + return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit)); } -function getIconSize(width: number): number { +export function getIconSize(width: number): number { const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth); return match?.size ?? 16; } diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index f2e79f570..3a63fe4bd 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -4,9 +4,11 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { + supportsAdvancedFocus: false, icon: IconBadgeCc, supportedIntegrations: ["bazarr"], integrationsRequired: true, + maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showMissingEpisodes: factory.switch({ diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts new file mode 100644 index 000000000..f5e03fba4 --- /dev/null +++ b/packages/widgets/src/bazarr/layout.spec.ts @@ -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)).toBe(4); + }); + + test("sizes icons from the constrained dimension", () => { + expect(getIconSize(180)).toBe(16); + expect(getIconSize(340)).toBe(22); + }); +}); diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 3f934423d..23243f35e 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -1,7 +1,20 @@ "use client"; import { useMemo } from "react"; -import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Badge, + Box, + Center, + Divider, + Group, + Loader, + ScrollArea, + Stack, + Text, + ThemeIcon, + Tooltip, + VisuallyHidden, +} from "@mantine/core"; import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -12,7 +25,8 @@ import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; const alertIconMap: Record = { CPU: Cpu, @@ -35,13 +49,16 @@ export default function BeszelAlertsWidget({ options, integrationIds, isEditMode, + height, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); const alertsInput = useMemo( () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), [integrationIds, options.showHistory, options.maxHistoryItems], ); - const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); + const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); + const results = getUsableWidgetQueryData(alertsQuery) ?? []; + const { isPending } = alertsQuery; const systemNameMap = useMemo(() => { const map: Record = {}; @@ -67,8 +84,9 @@ export default function BeszelAlertsWidget({ const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); - - if (alertsError) throw alertsError; + const showOkAlerts = triggeredAlerts.length === 0 || height >= 260; + const showHistory = options.showHistory && height >= 360; + const showAlertDescriptions = height >= 190; if (isPending) { return ( @@ -81,7 +99,7 @@ export default function BeszelAlertsWidget({ return ( - + @@ -100,7 +118,7 @@ export default function BeszelAlertsWidget({ - + {t("status.triggered")} ({triggeredAlerts.length}) @@ -112,18 +130,19 @@ export default function BeszelAlertsWidget({ min={alert.min} systemName={systemNameMap[alert.system] ?? alert.system} triggered + showDescription={showAlertDescriptions} /> ))} )} - {triggeredAlerts.length > 0 && okAlerts.length > 0 && } + {triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && } - {okAlerts.length > 0 && ( + {showOkAlerts && okAlerts.length > 0 && ( - + {t("status.ok")} ({okAlerts.length}) @@ -135,18 +154,19 @@ export default function BeszelAlertsWidget({ min={alert.min} systemName={systemNameMap[alert.system] ?? alert.system} triggered={false} + showDescription={showAlertDescriptions} /> ))} )} - {options.showHistory && history.length > 0 && ( + {showHistory && history.length > 0 && ( <> - + {t("history")} @@ -199,6 +219,7 @@ interface AlertRowProps { min: number; systemName: string; triggered: boolean; + showDescription: boolean; } const unitSuffixMap: Record = { @@ -222,38 +243,43 @@ function formatAlertDescription(name: string, value: number, min: number): strin return `exceeds ${value}${suffix} over ${min} min`; } -function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) { +function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) { const Icon = alertIconMap[name] ?? Server; + const description = formatAlertDescription(name, value, min); return ( - - - - - - {systemName} - - - · - - - {name} - - - - {formatAlertDescription(name, value, min)} - - - + + + + + + + {systemName} + + + · + + + {name} + + + {showDescription && ( + + {description} + + )} + {!showDescription && {description}} + + + ); } diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index f7dd6dee7..db1e791b2 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -1,12 +1,19 @@ import { IconBell, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, - queryKey: [["widget", "beszel"]], + supportsAdvancedFocus: false, + queryKey: [["widget", "beszel", "getAlerts"]], + queryMatcher: ({ input }, scope) => + widgetQueryInputMatches(input, { + integrationIds: scope.integrationIds, + includeHistory: scope.options.showHistory, + maxHistoryItems: scope.options.maxHistoryItems, + }), supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { diff --git a/packages/widgets/src/beszel-system-grid/component.module.css b/packages/widgets/src/beszel-system-grid/component.module.css index f70cde0e9..94c951dca 100644 --- a/packages/widgets/src/beszel-system-grid/component.module.css +++ b/packages/widgets/src/beszel-system-grid/component.module.css @@ -1,7 +1,20 @@ .clickableCard { transition: background-color 150ms; + cursor: pointer; } -.clickableCard:hover { +.clickableCard:hover, +.clickableCard:focus-visible { background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; } + +.clickableCard:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; +} + +@media (prefers-reduced-motion: reduce) { + .clickableCard { + transition: none; + } +} diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index dd2f56c54..66f32dc35 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -36,7 +36,8 @@ 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 { getUsableWidgetQueryData } from "../common/query-state"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; @@ -330,10 +331,15 @@ 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 ( @@ -362,6 +371,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick {visibleMetrics.map((m) => m.render(system, t, size))} + {hiddenMetricCount > 0 && ( + + +{hiddenMetricCount} + + )} ); }; @@ -377,16 +391,12 @@ export default function BeszelSystemGridWidget({ const board = useRequiredBoard(); const { openModal } = useModalAction(BeszelSystemStatsModal); - const { - data: results = [], - error: systemsError, - isPending, - } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const results = getUsableWidgetQueryData(systemsQuery) ?? []; + const { isPending } = systemsQuery; const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); - if (systemsError) throw systemsError; - if (isPending) { return (
@@ -398,7 +408,7 @@ export default function BeszelSystemGridWidget({ if (filteredSystems.length === 0) { return ( - +
@@ -414,21 +424,22 @@ export default function BeszelSystemGridWidget({ const cols = 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 = 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); return ( - + ) { const t = useScopedI18n("widget.beszelSystemStats"); const board = useOptionalBoard(); const { data: session } = useSession(); const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; - const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation(); - const { - data: systemsResult = [], - isPending: systemsPending, - error: systemsError, - } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const selectionSavePendingRef = useRef(false); + const { mutate: saveItemOptions, isPending: isSelectionSavePending } = + clientApi.widget.options.saveItemOptions.useMutation({ + onError: () => + showErrorNotification({ + title: t("error.selectionSaveTitle"), + message: t("error.selectionSaveMessage"), + }), + }); + const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? []; + const { isPending: systemsPending } = systemsQuery; - const systems = useMemo( - () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))), - [systemsResult], + 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 includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; + const periodOptions = [ + { 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") }, + ]; + useWidgetRuntimeQueries( + widgetRuntimeRef, + selectedSystem && options.timePeriod !== "1m" + ? [ + getQueryKey( + clientApi.widget.beszel.getSystemStats, + { + integrationIds: [selectedSystem.integrationId], + systemId: selectedSystem.systemId, + timePeriod: options.timePeriod as BeszelTimePeriod, + includeDocker, + }, + "query", + ), + ] + : [], ); - 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 handleSelectSystem = useCallback( (value: string) => { + if (selectionSavePendingRef.current) return; + + const previousValue = options.systemId; setOptions({ newOptions: { systemId: value } }); if (hasChangeAccess && boardId && itemId) { - saveItemOptions({ boardId, itemId, newOptions: { systemId: value } }); + selectionSavePendingRef.current = true; + saveItemOptions( + { boardId, itemId, newOptions: { systemId: value } }, + { + onError: () => { + setOptions({ newOptions: { systemId: previousValue } }); + }, + onSettled: () => { + selectionSavePendingRef.current = false; + }, + }, + ); } }, - [setOptions, hasChangeAccess, boardId, itemId, saveItemOptions], + [boardId, hasChangeAccess, itemId, options.systemId, saveItemOptions, setOptions], + ); + + const handleTimePeriod = useCallback( + (value: string) => { + if (selectionSavePendingRef.current) return; + + const timePeriod = value as BeszelTimePeriod; + const previousValue = options.timePeriod; + setOptions({ newOptions: { timePeriod } }); + if (hasChangeAccess && boardId && itemId) { + selectionSavePendingRef.current = true; + saveItemOptions( + { boardId, itemId, newOptions: { timePeriod } }, + { + onError: () => { + setOptions({ newOptions: { timePeriod: previousValue } }); + }, + onSettled: () => { + selectionSavePendingRef.current = false; + }, + }, + ); + } + }, + [boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions], ); - if (systemsError) throw systemsError; if (systemsPending) return (
@@ -66,7 +140,7 @@ export default function BeszelSystemStatsWidget({ if (systems.length === 0) { return ( - +
@@ -90,8 +164,9 @@ export default function BeszelSystemStatsWidget({ value && handleTimePeriod(value)} + disabled={isSelectionSavePending} + data={periodOptions} + /> + ))} 600 ? 2 : 1} visibility={{ @@ -152,7 +248,7 @@ export default function BeszelSystemStatsWidget({ dockerMemory: options.showDockerMemory, dockerNetwork: options.showDockerNetwork, }} - onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })} + onSwitchToHistorical={() => handleTimePeriod("1h")} /> diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index af2907b25..d3df5734b 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -2,8 +2,9 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; +import { createBeszelSystemChoices, resolveStoredBeszelQuerySelection } from "./selection"; const timePeriodOptions = [ { value: "1m", label: "Live" }, @@ -15,8 +16,31 @@ const timePeriodOptions = [ ]; export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", { + supportsAdvancedFocus: false, icon: IconChartAreaLine, - queryKey: [["widget", "beszel"]], + queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]], + queryMatcher(query, scope) { + if (query.path.at(-1) === "getSystems") { + return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds }); + } + + const hasRuntimeStatsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getSystemStats"); + if (hasRuntimeStatsQuery) return matchesWidgetRuntimeQuery(query, scope); + + const selection = resolveStoredBeszelQuerySelection(String(scope.options.systemId ?? ""), scope.integrationIds); + if (!selection) return false; + const dockerEnabled = ["showDockerCpu", "showDockerMemory", "showDockerNetwork"].some( + (key) => scope.options[key] === true, + ); + const expected = { + ...selection, + timePeriod: scope.options.timePeriod, + }; + return ( + widgetQueryInputMatches(query.input, { ...expected, includeDocker: false }) || + (dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true })) + ); + }, supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { @@ -30,7 +54,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 }; }, }), diff --git a/packages/widgets/src/beszel-system-stats/selection.spec.ts b/packages/widgets/src/beszel-system-stats/selection.spec.ts new file mode 100644 index 000000000..cc237e947 --- /dev/null +++ b/packages/widgets/src/beszel-system-stats/selection.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { createBeszelSystemChoices, resolveBeszelSystemChoice, resolveStoredBeszelQuerySelection } 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 unique legacy plain system ids working", () => { + expect(resolveBeszelSystemChoice(choices.slice(0, 1), "same")?.integrationId).toBe("one"); + }); + + it("defaults an empty selection to the first system", () => { + expect(resolveBeszelSystemChoice(choices, "")?.integrationId).toBe("one"); + }); + + it("does not guess when a legacy plain system id is ambiguous", () => { + expect(resolveBeszelSystemChoice(choices, "same")).toBeUndefined(); + }); + + it("does not silently replace an unresolved stored system", () => { + expect(resolveBeszelSystemChoice(choices, "missing")).toBeUndefined(); + }); + + it("derives the queried integration and system from a composite selection", () => { + expect(resolveStoredBeszelQuerySelection("two:same", ["one", "two"])).toEqual({ + integrationIds: ["two"], + systemId: "same", + }); + }); + + it("supports a legacy plain system id only when the integration is unambiguous", () => { + expect(resolveStoredBeszelQuerySelection("same", ["one"])).toEqual({ + integrationIds: ["one"], + systemId: "same", + }); + expect(resolveStoredBeszelQuerySelection("same", ["one", "two"])).toBeNull(); + }); +}); diff --git a/packages/widgets/src/beszel-system-stats/selection.ts b/packages/widgets/src/beszel-system-stats/selection.ts new file mode 100644 index 000000000..d48ffa45e --- /dev/null +++ b/packages/widgets/src/beszel-system-stats/selection.ts @@ -0,0 +1,48 @@ +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) { + const exactMatch = choices.find((choice) => choice.value === storedValue); + if (exactMatch) return exactMatch; + if (storedValue === "") return choices[0]; + + const legacyMatches = choices.filter((choice) => choice.systemId === storedValue); + return legacyMatches.length === 1 ? legacyMatches[0] : undefined; +} + +export function resolveStoredBeszelQuerySelection(storedValue: string, integrationIds: readonly string[]) { + for (const integrationId of integrationIds) { + const prefix = `${integrationId}:`; + if (storedValue.startsWith(prefix)) { + return { integrationIds: [integrationId], systemId: storedValue.slice(prefix.length) }; + } + } + + if (storedValue !== "" && integrationIds.length === 1) { + return { integrationIds: [integrationIds[0] as string], systemId: storedValue }; + } + + return null; +} diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 8286335a6..44c03ec70 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -26,6 +26,7 @@ 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,7 +39,7 @@ 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 { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; @@ -102,12 +103,27 @@ export default function BeszelSystemTableWidget({ 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 results = getUsableWidgetQueryData(systemsQuery) ?? []; + const { isPending } = systemsQuery; const size = useMemo(() => getSizeConfig(width), [width]); + const visibleMetricKeys = useMemo(() => { + const enabled = [ + "showCpu", + "showMemory", + "showDisk", + "showGpu", + "showLoadAvg", + "showNet", + "showTemp", + "showBattery", + "showServices", + "showUptime", + "showAgent", + ].filter((key) => options[key as keyof typeof options]) as string[]; + const budget = width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length; + return new Set(enabled.slice(0, budget)); + }, [options, width]); const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ onError: () => @@ -185,7 +201,7 @@ export default function BeszelSystemTableWidget({ ), }, - options.showCpu && { + visibleMetricKeys.has("showCpu") && { accessor: "cpu", width: 140, title: ( @@ -197,7 +213,7 @@ export default function BeszelSystemTableWidget({ sortable: true, render: (record) => , }, - options.showMemory && { + visibleMetricKeys.has("showMemory") && { accessor: "memory", width: 140, title: ( @@ -209,7 +225,7 @@ export default function BeszelSystemTableWidget({ sortable: true, render: (record) => , }, - options.showDisk && { + visibleMetricKeys.has("showDisk") && { accessor: "disk", width: 160, title: ( @@ -229,7 +245,7 @@ export default function BeszelSystemTableWidget({ /> ), }, - options.showGpu && { + visibleMetricKeys.has("showGpu") && { accessor: "gpu", width: 140, title: ( @@ -241,7 +257,7 @@ export default function BeszelSystemTableWidget({ sortable: true, render: (record) => , }, - options.showLoadAvg && { + visibleMetricKeys.has("showLoadAvg") && { accessor: "loadAvg", width: 100, title: ( @@ -258,7 +274,7 @@ export default function BeszelSystemTableWidget({ ), }, - options.showNet && { + visibleMetricKeys.has("showNet") && { accessor: "netBytes", width: 100, title: ( @@ -274,7 +290,7 @@ export default function BeszelSystemTableWidget({ ), }, - options.showTemp && { + visibleMetricKeys.has("showTemp") && { accessor: "temp", width: 80, title: ( @@ -286,7 +302,7 @@ export default function BeszelSystemTableWidget({ sortable: true, render: (record) => {formatTemp(record.temp, false)}, }, - options.showBattery && { + visibleMetricKeys.has("showBattery") && { accessor: "battery", width: 70, title: ( @@ -297,7 +313,7 @@ export default function BeszelSystemTableWidget({ ), render: (record) => {record.battery ? `${record.battery[0]}%` : "—"}, }, - options.showServices && { + visibleMetricKeys.has("showServices") && { accessor: "services", width: 80, title: ( @@ -309,7 +325,7 @@ export default function BeszelSystemTableWidget({ sortable: true, render: (record) => {record.services}, }, - options.showUptime && { + visibleMetricKeys.has("showUptime") && { accessor: "uptime", width: 90, title: ( @@ -321,7 +337,7 @@ export default function BeszelSystemTableWidget({ sortable: true, render: (record) => {formatUptime(record.uptime)}, }, - options.showAgent && { + visibleMetricKeys.has("showAgent") && { accessor: "agentVersion", width: 90, title: ( @@ -336,21 +352,7 @@ export default function BeszelSystemTableWidget({ ]; return cols.filter(Boolean) as DataTableColumn[]; - }, [ - 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]); const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, @@ -370,8 +372,6 @@ export default function BeszelSystemTableWidget({ [openModal], ); - if (systemsError) throw systemsError; - if (isPending) { return (
@@ -380,10 +380,10 @@ export default function BeszelSystemTableWidget({ ); } - return ( + const table = (
- +
); + + return table; } diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index e31bbc597..4007bebc0 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -1,6 +1,6 @@ import { IconServerOff, IconTable } from "@tabler/icons-react"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { statusOptions } from "../beszel/_shared/options"; import { optionsBuilder } from "../options"; @@ -25,7 +25,12 @@ const sortDirectionOptions = [ export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", { icon: IconTable, - queryKey: [["widget", "beszel"]], + supportsAdvancedFocus: false, + queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]], + queryMatcher: (query, scope) => + query.path.at(-1) === "getSystems" + ? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds }) + : matchesWidgetRuntimeQuery(query, scope), supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 7ba5b66fe..73601b7b6 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -8,33 +8,38 @@ import dayjs from "dayjs"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; +import { formatLocalizedDate } from "../../common/locale"; + export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"; -const timeFormats: Record = { - "1m": "HH:mm:ss", - "1h": "HH:mm", - "12h": "HH:mm", - "24h": "HH:mm", - "1w": "MMM D", - "30d": "MMM D", +const timeFormatOptions: Record = { + "1m": { hour: "numeric", minute: "2-digit", second: "2-digit" }, + "1h": { hour: "numeric", minute: "2-digit" }, + "12h": { hour: "numeric", minute: "2-digit" }, + "24h": { hour: "numeric", minute: "2-digit" }, + "1w": { month: "short", day: "numeric" }, + "30d": { month: "short", day: "numeric" }, }; const periodDays: Partial> = { "1w": 7, "30d": 30 }; -function prepareRecords(records: T[], timePeriod: BeszelTimePeriod) { - const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]); +export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string => + formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]); + +function prepareRecords(records: T[], timePeriod: BeszelTimePeriod, locale: string) { + const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale); if (timePeriod === "1m") { return { fmt, ordered: records }; } return { fmt, ordered: [...records].toReversed() }; } -function padTimeGrid(data: Record[], timePeriod: BeszelTimePeriod) { - if (timePeriod === "1m") return padLiveTimeGrid(data); +function padTimeGrid(data: Record[], timePeriod: BeszelTimePeriod, locale: string) { + if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale); const days = periodDays[timePeriod]; if (!days) return data; - const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]); + const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale); const now = dayjs(); const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD"))); const result = [...data]; @@ -51,7 +56,7 @@ function padTimeGrid(data: Record[], timePeriod: BeszelTimePeri return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime()); } -export function padLiveTimeGrid(data: Record[], pointCount = 60) { +export function padLiveTimeGrid(data: Record[], pointCount = 60, locale = "en-US") { if (data.length === 0) return data; const end = dayjs( @@ -76,7 +81,7 @@ export function padLiveTimeGrid(data: Record[], pointCount = 60 const timestamp = end.subtract(pointCount - index - 1, "second"); return ( pointsBySecond.get(timestamp.valueOf()) ?? { - time: timestamp.format(timeFormats["1m"]), + time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale), rawTime: timestamp.toISOString(), } ); @@ -162,17 +167,18 @@ export const useSystemChartData = ( systemStats: BeszelSystemStatsRecord[] | undefined, mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record, timePeriod: BeszelTimePeriod = "1h", + locale = "en-US", ) => useMemo(() => { if (!systemStats) return []; - const { fmt, ordered } = prepareRecords(systemStats, timePeriod); + const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale); const mapped = ordered.map((r) => ({ time: fmt(r.created), rawTime: r.created, ...mapFn(r.stats), })); - return padTimeGrid(mapped, timePeriod); - }, [systemStats, mapFn, timePeriod]); + return padTimeGrid(mapped, timePeriod, locale); + }, [systemStats, mapFn, timePeriod, locale]); export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => { const prevRef = useRef([]); @@ -212,10 +218,11 @@ export const useDiskChartData = ( efsPaths: string[], rootSeriesName: string, timePeriod: BeszelTimePeriod = "1h", + locale = "en-US", ) => useMemo( - () => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod), - [systemStats, efsPaths, rootSeriesName, timePeriod], + () => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale), + [systemStats, efsPaths, rootSeriesName, timePeriod, locale], ); export const buildDiskChartData = ( @@ -223,9 +230,10 @@ export const buildDiskChartData = ( efsPaths: string[], rootSeriesName: string, timePeriod: BeszelTimePeriod = "1h", + locale = "en-US", ) => { if (!systemStats?.length) return []; - const { fmt, ordered } = prepareRecords(systemStats, timePeriod); + const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale); const mapped = ordered.map((record) => { const point: Record = { time: fmt(record.created), @@ -238,7 +246,7 @@ export const buildDiskChartData = ( } return point; }); - return padTimeGrid(mapped, timePeriod); + return padTimeGrid(mapped, timePeriod, locale); }; export const useDockerChartData = ( @@ -246,12 +254,13 @@ export const useDockerChartData = ( containerNames: string[], metric: "cpu" | "memory" | "network", timePeriod: BeszelTimePeriod = "1h", + locale = "en-US", ) => useMemo(() => { if (!containerStats?.length) return []; const extract = defaultContainerExtractors[metric]; if (!extract) return []; - const { fmt, ordered } = prepareRecords(containerStats, timePeriod); + const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale); const mapped = ordered.map((record) => { const point: Record = { time: fmt(record.created), rawTime: record.created }; const byName = new Map(record.stats.map((c) => [c.n, c])); @@ -260,5 +269,5 @@ export const useDockerChartData = ( } return point; }); - return padTimeGrid(mapped, timePeriod); - }, [containerStats, containerNames, metric, timePeriod]); + return padTimeGrid(mapped, timePeriod, locale); + }, [containerStats, containerNames, metric, timePeriod, locale]); diff --git a/packages/widgets/src/beszel/_shared/disk-usage.module.css b/packages/widgets/src/beszel/_shared/disk-usage.module.css new file mode 100644 index 000000000..68cfb396e --- /dev/null +++ b/packages/widgets/src/beszel/_shared/disk-usage.module.css @@ -0,0 +1,11 @@ +.target { + min-width: 24px; + min-height: 24px; +} + +@media (pointer: coarse) { + .target { + min-width: 44px; + min-height: 44px; + } +} diff --git a/packages/widgets/src/beszel/_shared/disk-usage.tsx b/packages/widgets/src/beszel/_shared/disk-usage.tsx index 9aa2a14ad..ce7ec95f8 100644 --- a/packages/widgets/src/beszel/_shared/disk-usage.tsx +++ b/packages/widgets/src/beszel/_shared/disk-usage.tsx @@ -1,8 +1,12 @@ -import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core"; +import { useEffect, useState } from "react"; +import { Box, Group, Popover, Progress, Stack, Text, UnstyledButton } from "@mantine/core"; + +import { useScopedI18n } from "@homarr/translation/client"; import type { BeszelSystemRow } from "./types"; import { thresholdColor } from "./colors"; import { formatPercent, getProgressTrackSize } from "./format"; +import classes from "./disk-usage.module.css"; interface DiskUsageProps { system: BeszelSystemRow; @@ -17,33 +21,44 @@ const mountLabel = (path: string) => const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`; export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => { + const t = useScopedI18n("common"); + const [opened, setOpened] = useState(false); const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/"); + const filesystemUsageLabel = t("filesystemUsage", { count: filesystems.length + 1 }); const trackSize = getProgressTrackSize(progressSize); const dotSize = progressSize === "xs" ? 2 : 3; const dotGap = 2; + useEffect(() => { + if (filesystems.length === 0) setOpened(false); + }, [filesystems.length]); + return ( {formatPercent(system.disk)} - - - {filesystems.length > 0 ? ( + {filesystems.length > 0 ? ( + + setOpened(true)} + onMouseLeave={() => setOpened(false)} + onBlur={() => setOpened(false)} + onClick={() => setOpened(true)} style={{ display: "flex", alignItems: "center", flex: 1, - minWidth: 24, cursor: "pointer", }} > @@ -74,32 +89,32 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = - ) : ( - - - - - - )} - - - - {[["/", system.disk] as const, ...filesystems].map(([path, value]) => ( - - - {mountLabel(path)} - - - - {formatPercent(value)} + + + + {[["/", system.disk] as const, ...filesystems].map(([path, value]) => ( + + + {mountLabel(path)} - - - - ))} - - - + + + {formatPercent(value)} + + + + + ))} + + + + ) : ( + + + + + + )} ); }; diff --git a/packages/widgets/src/beszel/_shared/error-indicator.tsx b/packages/widgets/src/beszel/_shared/error-indicator.tsx deleted file mode 100644 index 7d539baac..000000000 --- a/packages/widgets/src/beszel/_shared/error-indicator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { Tooltip } from "@mantine/core"; -import { IconAlertTriangle } from "@tabler/icons-react"; - -interface IntegrationResult { - integrationId: string; - integrationName?: string; - error?: string; -} - -interface BeszelIntegrationErrorIndicatorProps { - results: IntegrationResult[]; -} - -export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) { - const failed = results.filter((r) => "error" in r && r.error); - if (failed.length === 0) return null; - const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", "); - return ( - - - - ); -} diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx index 48706d4ad..c2042a4a9 100644 --- a/packages/widgets/src/beszel/_shared/stats-view.tsx +++ b/packages/widgets/src/beszel/_shared/stats-view.tsx @@ -6,8 +6,9 @@ import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; +import { getUsableWidgetQueryData } from "../../common/query-state"; import { containerColors } from "./colors"; import type { BeszelTimePeriod } from "./chart"; import { @@ -19,16 +20,23 @@ import { useSystemChartData, } from "./chart"; import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format"; +import type { BeszelTooltipLayer } from "./tooltip"; import { makeTooltipProps } from "./tooltip"; import { useLiveStats } from "./use-live-stats"; const CHART_HEIGHT = 180; const MB = 1024 * 1024; -const tooltipPercent = makeTooltipProps(formatPercent); -const tooltipGB = makeTooltipProps(formatGB, true); -const tooltipRate = makeTooltipProps(formatByteRate, true); -const tooltipPercentTotal = makeTooltipProps(formatPercent, true); -const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true); +const makeTooltipSet = (layer: BeszelTooltipLayer) => ({ + percent: makeTooltipProps(formatPercent, false, layer), + gigabytes: makeTooltipProps(formatGB, true, layer), + rate: makeTooltipProps(formatByteRate, true, layer), + percentTotal: makeTooltipProps(formatPercent, true, layer), + bytesTotal: makeTooltipProps(formatStorageBytes, true, layer), +}); +const tooltipSets = { + background: makeTooltipSet("background"), + modal: makeTooltipSet("modal"), +}; const ChartSkeleton = () => ( @@ -58,6 +66,7 @@ interface BeszelStatsViewProps { visibility: BeszelStatsVisibility; columns: 1 | 2; onSwitchToHistorical?: () => void; + tooltipLayer?: BeszelTooltipLayer; } const whenVisible = (visible: boolean, data: T | undefined) => (visible ? data : undefined); @@ -69,15 +78,19 @@ export function BeszelStatsView({ visibility, columns, onSwitchToHistorical, + tooltipLayer = "background", }: BeszelStatsViewProps) { const t = useScopedI18n("widget.beszelSystemStats"); + const locale = useCurrentIntlLocale(); const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork; const isLive = timePeriod === "1m"; + const tooltips = tooltipSets[tooltipLayer]; - const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery( + const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery( { integrationIds, systemId, timePeriod, includeDocker: showDocker }, { refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" }, ); + const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery); const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== ""); const activeStats: @@ -125,10 +138,25 @@ export function BeszelStatsView({ const systemStats = activeStats?.systemStats; const containerStats = activeStats?.containerStats; - const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod); - const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod); - const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod); - const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod); + const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod, locale); + const memoryData = useSystemChartData( + whenVisible(visibility.memory, systemStats), + mappers.memory, + timePeriod, + locale, + ); + const diskIOData = useSystemChartData( + whenVisible(visibility.diskIO, systemStats), + mappers.diskIO, + timePeriod, + locale, + ); + const networkData = useSystemChartData( + whenVisible(visibility.network, systemStats), + mappers.network, + timePeriod, + locale, + ); const efsPaths = useMemo(() => { const paths = new Set(); @@ -138,7 +166,13 @@ export function BeszelStatsView({ return [...paths]; }, [systemStats]); const rootSeriesName = t("chart.disk.series"); - const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod); + const diskData = useDiskChartData( + whenVisible(visibility.disk, systemStats), + efsPaths, + rootSeriesName, + timePeriod, + locale, + ); const diskSeries = useMemo( () => [ { name: rootSeriesName, color: "grape.6" }, @@ -161,22 +195,23 @@ export function BeszelStatsView({ containerNames, "cpu", timePeriod, + locale, ); const dockerMemoryData = useDockerChartData( whenVisible(visibility.dockerMemory, containerStats), containerNames, "memory", timePeriod, + locale, ); const dockerNetworkData = useDockerChartData( whenVisible(visibility.dockerNetwork, containerStats), containerNames, "network", timePeriod, + locale, ); - if (historicalError) throw historicalError; - if (isLive && liveError) { return (
@@ -198,7 +233,7 @@ export function BeszelStatsView({ if (!activeStats) { const visibleChartCount = Object.values(visibility).filter(Boolean).length; return ( - + {Array.from({ length: visibleChartCount }, (_, index) => ( ))} @@ -212,7 +247,7 @@ export function BeszelStatsView({ - {activeStats.error} + {t("error.internalServerError")}
@@ -231,7 +266,7 @@ export function BeszelStatsView({ series: series.cpu, yAxisFormatter: chartAxisFormatters.percent, yAxisDomain: CPU_Y_AXIS_DOMAIN, - tooltipProps: tooltipPercent, + tooltipProps: tooltips.percent, }} /> )} @@ -245,7 +280,7 @@ export function BeszelStatsView({ type: "stacked", series: series.memory, yAxisFormatter: chartAxisFormatters.gb, - tooltipProps: tooltipGB, + tooltipProps: tooltips.gigabytes, }} /> )} @@ -259,7 +294,7 @@ export function BeszelStatsView({ type: "stacked", series: diskSeries, yAxisFormatter: chartAxisFormatters.gb, - tooltipProps: tooltipGB, + tooltipProps: tooltips.gigabytes, }} /> )} @@ -272,7 +307,7 @@ export function BeszelStatsView({ data: diskIOData, series: series.diskIO, yAxisFormatter: chartAxisFormatters.rate, - tooltipProps: tooltipRate, + tooltipProps: tooltips.rate, }} /> )} @@ -285,7 +320,7 @@ export function BeszelStatsView({ data: networkData, series: series.network, yAxisFormatter: chartAxisFormatters.rate, - tooltipProps: tooltipRate, + tooltipProps: tooltips.rate, }} /> )} @@ -301,7 +336,7 @@ export function BeszelStatsView({ type: "stacked", series: containerSeries, yAxisFormatter: chartAxisFormatters.percent, - tooltipProps: tooltipPercentTotal, + tooltipProps: tooltips.percentTotal, }} /> )} @@ -315,7 +350,7 @@ export function BeszelStatsView({ type: "stacked", series: containerSeries, yAxisFormatter: chartAxisFormatters.bytes, - tooltipProps: tooltipBytesTotal, + tooltipProps: tooltips.bytesTotal, }} /> )} @@ -328,7 +363,7 @@ export function BeszelStatsView({ data: dockerNetworkData, series: containerSeries, yAxisFormatter: chartAxisFormatters.rate, - tooltipProps: tooltipRate, + tooltipProps: tooltips.rate, }} /> )} diff --git a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx index fda3146d2..bc13d498b 100644 --- a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx +++ b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx @@ -50,6 +50,7 @@ export const BeszelSystemStatsModal = createModal(( timePeriod={timePeriod} visibility={allCharts} columns={2} + tooltipLayer="modal" onSwitchToHistorical={() => setTimePeriod("1h")} />
diff --git a/packages/widgets/src/beszel/_shared/tooltip.tsx b/packages/widgets/src/beszel/_shared/tooltip.tsx index b43ea1765..f6f7b35c6 100644 --- a/packages/widgets/src/beszel/_shared/tooltip.tsx +++ b/packages/widgets/src/beszel/_shared/tooltip.tsx @@ -3,10 +3,9 @@ import type { CSSProperties } from "react"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useRef, useState } from "react"; -import dayjs from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; -dayjs.extend(localizedFormat); +import { formatLocalizedDate } from "../../common/locale"; const styles: Record = { wrapper: { @@ -53,11 +52,15 @@ interface PortalTooltipProps { payload?: TooltipPayloadItem[]; formatter: (value: number) => string; showTotal?: boolean; + layer: BeszelTooltipLayer; } -const MARGIN = 14; +export type BeszelTooltipLayer = "background" | "modal"; -const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => { +const MARGIN = 14; +const PortalTooltipContent = ({ active, label, payload, formatter, showTotal, layer }: PortalTooltipProps) => { + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("common"); const mouseRef = useRef({ x: 0, y: 0 }); const [pos, setPos] = useState({ x: 0, y: 0 }); const rafRef = useRef(0); @@ -70,7 +73,6 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: const el = tooltipRef.current; const mx = mouseRef.current.x; const my = mouseRef.current.y; - if (!el) { setPos({ x: mx + MARGIN, y: my }); return; @@ -113,14 +115,17 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: if (!sorted.length) return null; const rawTime = sorted[0]?.payload?.rawTime as string | undefined; - const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label; + const tooltipLabel = rawTime + ? formatLocalizedDate(rawTime, locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) + : label; const total = sorted.reduce((sum, p) => sum + p.value, 0); const portalStyle: CSSProperties = { position: "fixed", left: pos.x, top: pos.y, - zIndex: 10000, + zIndex: + layer === "modal" ? "var(--homarr-z-index-modal-tooltip)" : "var(--homarr-z-index-widget-background-tooltip)", pointerEvents: "none", }; @@ -137,7 +142,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: ))} {showTotal && sorted.length > 1 && (
- Total + {t("total")} {formatter(total)}
)} @@ -148,7 +153,11 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({ +export const makeTooltipProps = ( + formatter: (v: number) => string, + showTotal = false, + layer: BeszelTooltipLayer = "background", +) => ({ content: (props: any) => ( string, showTotal = f payload={props.payload} formatter={formatter} showTotal={showTotal} + layer={layer} /> ), wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 }, diff --git a/packages/widgets/src/bookmarks/bookmark.module.css b/packages/widgets/src/bookmarks/bookmark.module.css index c37b78242..807b90e48 100644 --- a/packages/widgets/src/bookmarks/bookmark.module.css +++ b/packages/widgets/src/bookmarks/bookmark.module.css @@ -2,6 +2,12 @@ background-color: var(--mantine-color-primaryColor-light-hover); } +.bookmarkButton:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; + border-radius: var(--mantine-radius-sm); +} + [data-mantine-color-scheme="light"] .card-grid { background-color: var(--mantine-color-gray-1); } @@ -13,3 +19,7 @@ .card:hover > div > div.bookmarkIcon { background-color: var(--mantine-color-iconColor-filled-hover); } + +.bookmarkButton:focus-visible .bookmarkIcon { + background-color: var(--mantine-color-iconColor-filled-hover); +} diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index e6b7eb7c0..13e211f53 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -1,6 +1,19 @@ "use client"; -import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core"; +import { useMemo } from "react"; +import { + Anchor, + Box, + Card, + Flex, + Group, + ScrollArea, + SimpleGrid, + Stack, + Text, + Title, + UnstyledButton, +} from "@mantine/core"; import combineClasses from "clsx"; import type { RouterOutputs } from "@homarr/api"; @@ -10,67 +23,138 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { MaskedOrNormalImage } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; +import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { getUsableWidgetQueryData } from "../common/query-state"; import classes from "./bookmark.module.css"; -export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) { +type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; + +export interface CompactBookmarkLayout { + columns: number; + hideHostname: boolean; + hideTitle: boolean; + minimumItemSize: number; +} + +export function getCompactBookmarkLayout( + width: number, + height: number, + itemCount: number, + layout: BookmarkLayout, +): CompactBookmarkLayout { + const count = Math.max(1, itemCount); + const isGrid = layout === "grid" || layout === "gridHorizontal"; + const targetWidth = layout === "gridHorizontal" ? 180 : 104; + const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1; + const rows = Math.max(1, Math.ceil(count / columns)); + const cellWidth = width / columns; + const cellHeight = height / rows; + const usesHorizontalItems = layout === "column" || layout === "gridHorizontal"; + + return { + columns, + hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96), + hideTitle: cellWidth < 64 || cellHeight < 40, + minimumItemSize: usesHorizontalItems ? 48 : 96, + }; +} + +export default function BookmarksWidget({ + options, + itemId, + width, + height, + 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)); - }, - }); + const data = + getUsableWidgetQueryData( + clientApi.app.byIds.useQuery(options.items, { + select(apps) { + return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); + }, + }), + ) ?? []; useRegisterSpotlightContextResults( `bookmark-${itemId}`, - data - .filter((app) => app.href !== null) - .map((app) => ({ - id: app.id, - name: app.name, - icon: app.iconUrl, - interaction() { - return { - type: "link", - // We checked above that app.href is defined - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - href: app.href!, - newTab: false, - }; - }, - })), + data.flatMap((app) => { + const href = getSafeAppHref(app.href); + return href + ? [ + { + id: app.id, + name: app.name, + icon: app.iconUrl, + interaction() { + return { + type: "link", + href, + newTab: false, + }; + }, + }, + ] + : []; + }), [data], ); + const compactLayout = useMemo( + () => getCompactBookmarkLayout(width, height, data.length, options.layout), + [width, height, data.length, options.layout], + ); + const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon); + const compactHideHostname = + options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon)); + return ( - + {options.title.length > 0 && ( - + <Title order={4} px="0.25rem" lineClamp={1}> {options.title} )} - {(options.layout === "grid" || options.layout === "gridHorizontal") && ( - )} - {options.layout !== "grid" && options.layout !== "gridHorizontal" && ( - + {displayMode !== "advanced" && ( + + + {options.layout === "grid" || options.layout === "gridHorizontal" ? ( + + ) : ( + + )} + + )} ); @@ -85,6 +169,7 @@ interface FlexLayoutProps { openNewTab: boolean; withBorder: boolean; hasIconColor: boolean; + minimumItemSize: number; } const FlexLayout = ({ @@ -96,54 +181,130 @@ const FlexLayout = ({ openNewTab, withBorder, hasIconColor, + minimumItemSize, }: FlexLayoutProps) => { const board = useRequiredBoard(); return ( - - {data.map((app) => ( -
- - + {data.map((app) => { + const href = getSafeAppHref(app.href); + return ( +
+ - {direction === "row" ? ( - - ) : ( - - )} - - -
- ))} + + {direction === "row" ? ( + + ) : ( + + )} + +
+
+ ); + })}
); }; +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 => { + const safeHref = getSafeApplicationUrl(href); + return safeHref ? new URL(safeHref).hostname : undefined; +}; + +const AdvancedBookmarksLayout = ({ + data, + width, + openNewTab, + withBorder, + hasIconColor, +}: AdvancedBookmarksLayoutProps) => { + const board = useRequiredBoard(); + + return ( + + + {data.map((app) => { + const href = getSafeAppHref(app.href); + return ( + + + + + + + {app.name} + + + {getBookmarkHostname(app.href)} + + {app.description && ( + + {app.description} + + )} + + + + + ); + })} + + + ); +}; + interface GridLayoutProps { data: RouterOutputs["app"]["byIds"]; hideTitle: boolean; @@ -153,6 +314,8 @@ interface GridLayoutProps { withBorder: boolean; itemDirection: "horizontal" | "vertical"; hasIconColor: boolean; + columns: number; + minimumItemHeight: number; } const GridLayout = ({ @@ -164,48 +327,61 @@ const GridLayout = ({ withBorder, itemDirection, hasIconColor, + columns, + minimumItemHeight, }: GridLayoutProps) => { const board = useRequiredBoard(); return ( - - {data.map((app) => ( - - + {data.map((app) => { + const href = getSafeAppHref(app.href); + return ( + - {itemDirection === "horizontal" ? ( - - ) : ( - - )} - - - ))} - + + {itemDirection === "horizontal" ? ( + + ) : ( + + )} + + + ); + })} + ); }; @@ -225,7 +401,7 @@ const VerticalItem = ({ return ( {!hideTitle && ( - + {app.name} )} @@ -246,8 +422,8 @@ const VerticalItem = ({ /> )} {!hideHostname && ( - - {app.href ? new URL(app.href).hostname : undefined} + + {getBookmarkHostname(app.href)} )} @@ -285,7 +461,7 @@ const HorizontalItem = ({ )} {!(hideTitle && hideHostname) && ( <> - + {!hideTitle && ( {app.name} @@ -293,8 +469,8 @@ const HorizontalItem = ({ )} {!hideHostname && ( - - {app.href ? new URL(app.href).hostname : undefined} + + {getBookmarkHostname(app.href)} )} diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 81a38e970..85fc442cf 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -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(), diff --git a/packages/widgets/src/bookmarks/layout.spec.ts b/packages/widgets/src/bookmarks/layout.spec.ts new file mode 100644 index 000000000..5ff6eeb33 --- /dev/null +++ b/packages/widgets/src/bookmarks/layout.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; + +import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } 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("uses container geometry to keep compact cards legible", () => { + expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({ + columns: 2, + hideHostname: false, + hideTitle: false, + minimumItemSize: 48, + }); + + expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({ + columns: 4, + hideHostname: true, + hideTitle: true, + minimumItemSize: 96, + }); + }); + + test("does not crash on malformed bookmark URLs", () => { + expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev"); + expect(getBookmarkHostname("not a URL")).toBeUndefined(); + expect(getBookmarkHostname(null)).toBeUndefined(); + }); +}); diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx index 992da9d71..95187b3e3 100644 --- a/packages/widgets/src/calendar/calendar-event-list.tsx +++ b/packages/widgets/src/calendar/calendar-event-list.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { Badge, Box, @@ -16,122 +17,173 @@ import dayjs from "dayjs"; import { isNullOrWhitespace } from "@homarr/common"; import type { CalendarEvent } from "@homarr/integrations/types"; -import { useI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; +import { groupEventsByDate } from "./calendar-events"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { formatLocalizedTime } from "../common/locale"; import classes from "./calendar-event-list.module.css"; interface CalendarEventListProps { events: CalendarEvent[]; + fillHeight?: boolean; + groupByDate?: boolean; + locale?: string; } -export const CalendarEventList = ({ events }: CalendarEventListProps) => { - const { colorScheme } = useMantineColorScheme(); - const t = useI18n(); +export const CalendarEventList = ({ + events, + fillHeight = false, + groupByDate = false, + locale, +}: CalendarEventListProps) => { + const headingIdPrefix = useId(); + const currentLocale = useCurrentIntlLocale(); + const effectiveLocale = locale ?? currentLocale; + const dateFormatter = new Intl.DateTimeFormat(effectiveLocale, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null; + return ( - - {events.map((event, eventIndex) => ( - - {event.image !== null && ( - - - {event.image.badge !== undefined && ( - - {event.image.badge.content} - - )} - - )} - - - - {event.subTitle !== null && ( - - {event.subTitle} - - )} - - {event.title} - - - {event.metadata?.type === "radarr" && ( - - - {t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)} - - - )} - - - - {isAllDay(event) ? ( - - {t("widget.calendar.duration.allDay")} - - ) : ( - <> - - {dayjs(event.startDate).format("HH:mm")} - - - {event.endDate !== null && ( - <> - -{" "} - - {dayjs(event.endDate).format("HH:mm")} - - - )} - - )} - - - - {event.location !== null && ( - - - - {event.location} - - - )} - - {!isNullOrWhitespace(event.description) && ( - - {event.description} + + {groups ? ( + groups.map(([date, groupedEvents]) => { + const headingId = `${headingIdPrefix}-${date}`; + return ( + + + {dateFormatter.format(groupedEvents[0]?.startDate)} + + + + + ); + }) + ) : ( + + )} + + + ); +}; + +const CalendarEventRows = ({ events, locale }: Pick & { locale: string }) => { + const { colorScheme } = useMantineColorScheme(); + const t = useI18n(); + return ( + <> + {events.map((event, eventIndex) => ( + + {event.image !== null && ( + + + {event.image.badge !== undefined && ( + + {event.image.badge.content} + + )} + + )} + + + + {event.subTitle !== null && ( + + {event.subTitle} + + )} + + {event.title} + + + {event.metadata?.type === "radarr" && ( + + + {t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)} + + )} - {event.links.length > 0 && ( - - {event.links - .filter((link) => link.href) - .map((link) => ( + + + {isAllDay(event) ? ( + + {t("widget.calendar.duration.allDay")} + + ) : ( + <> + + {formatLocalizedTime(event.startDate, locale)} + + + {event.endDate !== null && ( + <> + -{" "} + + {formatLocalizedTime(event.endDate, locale)} + + + )} + + )} + + + + {event.location !== null && ( + + + + {event.location} + + + )} + + {!isNullOrWhitespace(event.description) && ( + + {event.description} + + )} + + {event.links.length > 0 && ( + + {event.links + .filter((link) => link.href) + .map((link) => { + const href = getSafeApplicationUrl(link.href.toString()); + return ( - ))} - - )} - - - ))} - - + ); + })} + + )} +
+ + ))} + ); }; diff --git a/packages/widgets/src/calendar/calendar-events.ts b/packages/widgets/src/calendar/calendar-events.ts new file mode 100644 index 000000000..19ddd3bd5 --- /dev/null +++ b/packages/widgets/src/calendar/calendar-events.ts @@ -0,0 +1,80 @@ +import dayjs from "dayjs"; + +import type { CalendarEvent } from "@homarr/integrations/types"; + +export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD"); + +export const moveCalendarMonth = (month: Date, amount: number): Date => + dayjs(month).startOf("month").add(amount, "month").toDate(); + +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 => { + const result = new Map(); + 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; +}; diff --git a/packages/widgets/src/calendar/calendar.spec.ts b/packages/widgets/src/calendar/calendar.spec.ts index 0d655adc6..d42d6f151 100644 --- a/packages/widgets/src/calendar/calendar.spec.ts +++ b/packages/widgets/src/calendar/calendar.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import type { CalendarEvent } from "@homarr/integrations/types"; -import { splitEvents } from "./component"; +import { getCalendarAgendaEvents, groupEventsByDate, moveCalendarMonth, 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,52 @@ 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]); + }); +}); + +describe("moveCalendarMonth", () => { + test("uses the first day so short months do not skip", () => { + expect(moveCalendarMonth(new Date(2025, 0, 31), 1)).toEqual(new Date(2025, 1, 1)); + expect(moveCalendarMonth(new Date(2025, 2, 31), -1)).toEqual(new Date(2025, 1, 1)); + }); +}); + const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({ title: "Test", subTitle: null, diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 6d309ce42..5cc0cfd58 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -1,26 +1,62 @@ "use client"; import { useMemo, useState } from "react"; -import { useParams } from "next/navigation"; -import { useMantineTheme } from "@mantine/core"; +import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; +import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { getQueryKey } from "@trpc/react-query"; import dayjs from "dayjs"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { CalendarEvent } from "@homarr/integrations/types"; import { useSettings } from "@homarr/settings"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; +import actionTargetClasses from "../common/action-target.module.css"; import type { WidgetComponentProps } from "../definition"; +import { getUsableWidgetQueryData } from "../common/query-state"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { CalendarDay } from "./calender-day"; +import { + getCalendarAgendaEvents, + groupEventsByDate, + moveCalendarMonth, + 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 [month, setMonth] = useState(() => new Date()); + const runtimeInput = { + integrationIds: props.integrationIds, + month: month.getMonth(), + year: month.getFullYear(), + releaseType: props.options.releaseType, + showUnmonitored: props.options.showUnmonitored, + }; + useWidgetRuntimeQueries( + props.widgetRuntimeRef, + props.integrationIds.length > 0 + ? [getQueryKey(clientApi.widget.calendar.findAllEvents, runtimeInput, "query")] + : [], + ); if (props.integrationIds.length === 0) { - return ; + return ( + + ); } return ; @@ -31,7 +67,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(), @@ -39,24 +75,52 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: releaseType: options.releaseType, showUnmonitored: options.showUnmonitored, }; - const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input); + const calendarQuery = clientApi.widget.calendar.findAllEvents.useQuery(input); + const data = getUsableWidgetQueryData(calendarQuery); + const { isPending } = calendarQuery; const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); + const failedIntegrations = + data?.flatMap(({ integration, error }) => + error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], + ) ?? []; - return ; + return ( + + ); }; interface CalendarBaseProps { isEditMode: boolean; events: CalendarEvent[]; + failedIntegrations: { integrationId: string; integrationName: string; error: string }[]; + isPending: boolean; month: Date; setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; + displayMode?: WidgetComponentProps<"calendar">["displayMode"]; } -const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => { - const params = useParams(); - const locale = params.locale as string; +const CalendarBase = ({ + isEditMode, + events, + failedIntegrations, + isPending, + month, + setMonth, + options, + displayMode, +}: CalendarBaseProps) => { + const locale = useCurrentIntlLocale(); const { firstDayOfWeek } = useSettings(); const board = useRequiredBoard(); const mantineTheme = useMantineTheme(); @@ -64,125 +128,197 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar const { ref, width, height } = useElementSize(); const isSmall = width < 256; - const normalizedEvents = useMemo(() => splitEvents(events), [events]); + const normalizedEvents = useMemo( + () => (displayMode === "advanced" ? [] : splitEvents(events)), + [displayMode, 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( + () => (displayMode === "advanced" ? getCalendarAgendaEvents(events, month, options.releaseType) : []), + [displayMode, events, month, options.releaseType], + ); + + if (displayMode === "advanced") { + return ( + + ); + } return ( - 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()); + + {failedIntegrations.length > 0 && ( + + + + )} + 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="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 ( - - ); - }} - /> + return ( + + ); + }} + /> + ); }; -/** - * 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; - } +interface CalendarAgendaProps { + events: CalendarEvent[]; + failedIntegrations: CalendarBaseProps["failedIntegrations"]; + isPending: boolean; + isEditMode: boolean; + locale: string; + month: Date; + setMonth: (date: Date) => void; +} - if (dayjs(event.startDate).isSame(event.endDate, "day")) { - splitEvents.push(event); - continue; - } +const CalendarAgenda = ({ + events, + failedIntegrations, + isPending, + isEditMode, + locale, + month, + setMonth, +}: CalendarAgendaProps) => { + const t = useScopedI18n("widget.calendar.advanced"); + const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month); - if (dayjs(event.startDate).isAfter(event.endDate)) { - // Invalid event, skip it - continue; - } + return ( + + + + + setMonth(moveCalendarMonth(month, -1))} + > + + + + + + {monthLabel} + + + {isPending ? t("loading") : t("eventCount", { count: events.length })} + + + + setMonth(moveCalendarMonth(month, 1))} + > + + + + + {failedIntegrations.length > 0 && } + - // 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; + + {isPending ? ( +
+ +
+ ) : events.length > 0 ? ( + + ) : ( + + + {t("noEvents")} + + + )} +
+
+ ); }; diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts index d03b2f610..471c50f17 100644 --- a/packages/widgets/src/calendar/index.ts +++ b/packages/widgets/src/calendar/index.ts @@ -4,11 +4,14 @@ import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("calendar", { icon: IconCalendar, + supportsAdvancedFocus: true, + queryKey: [["widget", "calendar", "findAllEvents"]], + queryMatcher: matchesWidgetRuntimeQuery, createOptions() { return optionsBuilder.from((factory) => ({ releaseType: factory.multiSelect({ diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 09445ace2..bc9cd910e 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -1,14 +1,18 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Stack, Text, Title } from "@mantine/core"; +import { Box, 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"; @@ -16,7 +20,7 @@ dayjs.extend(advancedFormat); dayjs.extend(utc); dayjs.extend(timezones); -export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) { +export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) { const secondsFormat = options.showSeconds ? ":ss" : ""; const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`; const dateFormat = options.dateFormat; @@ -26,7 +30,8 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl const time = useCurrentTime(options); 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 ( @@ -36,6 +41,7 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl latitude={options.weatherLocation.latitude} longitude={options.weatherLocation.longitude} isFahrenheit={options.isWeatherFormatFahrenheit} + showDetails={displayMode === "advanced"} /> )} @@ -45,13 +51,20 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl )} - {zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)} + <time dateTime={zonedTime?.toISOString()}> + {zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)} + </time> {options.showDate && ( {zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)} )} + {displayMode === "advanced" && ( + + {zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`} + + )} ); @@ -61,12 +74,26 @@ interface ClockWeatherCornerProps { latitude: number; longitude: number; isFahrenheit: boolean; + showDetails: boolean; } -const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => { - const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude }); +const ClockWeatherCorner = ({ latitude, longitude, 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 ( + + {isInitialWidgetQueryPending(weatherQuery) ? ( + + ) : ( + + )} + + ); + } const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature; const unit = isFahrenheit ? "°F" : "°C"; @@ -78,6 +105,13 @@ const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherC {Math.round(temp)} {unit} + {showDetails && ( + + {Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "} + {isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")} + + )} + ); }; diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index 3fe152b73..45b040009 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -1,12 +1,29 @@ import { IconClock } from "@tabler/icons-react"; import dayjs from "dayjs"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("clock", { icon: IconClock, - queryKey: [["widget", "weather"]], + supportsAdvancedFocus: true, + queryKey: [["widget", "weather", "atLocation"]], + queryMatcher: ({ input }, scope) => { + const location = scope.options.weatherLocation; + if ( + scope.options.showWeather !== true || + location === null || + typeof location !== "object" || + !("latitude" in location) || + !("longitude" in location) + ) { + return false; + } + return widgetQueryInputMatches(input, { + latitude: location.latitude, + longitude: location.longitude, + }); + }, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/common/action-target.module.css b/packages/widgets/src/common/action-target.module.css new file mode 100644 index 000000000..28f6cc672 --- /dev/null +++ b/packages/widgets/src/common/action-target.module.css @@ -0,0 +1,11 @@ +.root { + min-width: 24px; + min-height: 24px; +} + +@media (any-pointer: coarse) { + .root { + min-width: 44px; + min-height: 44px; + } +} diff --git a/packages/widgets/src/common/application-url.spec.ts b/packages/widgets/src/common/application-url.spec.ts new file mode 100644 index 000000000..a990c53b6 --- /dev/null +++ b/packages/widgets/src/common/application-url.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import { getSafeApplicationUrl, getSafeAppHref } from "./application-url"; + +describe("getSafeApplicationUrl", () => { + test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])( + "rejects unsafe or non-absolute URL %s", + (value) => expect(getSafeApplicationUrl(value)).toBeUndefined(), + ); + + test("rejects credential-bearing URLs", () => { + expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined(); + expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined(); + }); + + test("allows absolute HTTP and HTTPS URLs", () => { + expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path"); + expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result"); + }); + + test("resolves feed-relative URLs only against a safe HTTP(S) base", () => { + expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe( + "https://example.com/story", + ); + }); + + test.each([undefined, null, ""])("rejects missing URL %s", (value) => { + expect(getSafeApplicationUrl(value)).toBeUndefined(); + }); +}); + +describe("getSafeAppHref", () => { + test.each(["#section", "/boards/home", "./relative", "vscode://file/example.ts", "https://example.com/path"])( + "allows configured app link %s", + (value) => expect(getSafeAppHref(value)).toBe(value), + ); + + test.each([ + "javascript:alert(1)", + "data:text/html,test", + "file:///etc/passwd", + "//example.com/path", + "not a URL", + "https://user:password@example.com/path", + ])("rejects unsafe app link %s", (value) => expect(getSafeAppHref(value)).toBeUndefined()); +}); diff --git a/packages/widgets/src/common/application-url.ts b/packages/widgets/src/common/application-url.ts new file mode 100644 index 000000000..b43d335f3 --- /dev/null +++ b/packages/widgets/src/common/application-url.ts @@ -0,0 +1 @@ +export { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common"; diff --git a/packages/widgets/src/common/integration-error-indicator.tsx b/packages/widgets/src/common/integration-error-indicator.tsx new file mode 100644 index 000000000..6e9f27dd4 --- /dev/null +++ b/packages/widgets/src/common/integration-error-indicator.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { ActionIcon, Tooltip } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +interface IntegrationResult { + integrationId: string; + integrationName?: string; + error?: string; +} + +interface IntegrationErrorIndicatorProps { + results: readonly IntegrationResult[]; +} + +export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) { + const t = useScopedI18n("common"); + const failed = results.filter((result) => Boolean(result.error)); + if (failed.length === 0) return null; + + const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", "); + const label = `${t("error")}: ${names}`; + return ( + + + + + + ); +} diff --git a/packages/widgets/src/common/locale.spec.ts b/packages/widgets/src/common/locale.spec.ts new file mode 100644 index 000000000..9cae44936 --- /dev/null +++ b/packages/widgets/src/common/locale.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale"; + +const sample = new Date("2026-01-02T13:05:09.000Z"); + +describe("localized widget formatting", () => { + test.each([ + ["en-US", /Jan/], + ["fr-FR", /janv/i], + ["de-DE", /Jan/], + ["he-IL", /[\u0590-\u05ff]/], + ])("formats dates for %s", (locale, expected) => { + expect( + formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }), + ).toMatch(expected); + }); + + test("supports explicit 12-hour and 24-hour clocks", () => { + expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM"); + expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05"); + }); + + test("keeps date-only values on the requested calendar day", () => { + expect(formatLocalizedDate("2026-08-03", "en-US", { month: "long", day: "numeric" })).toBe("August 3"); + }); + + test("localizes compact numbers", () => { + expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M"); + expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2"); + expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio."); + }); + + test("normalizes binary status for translated accessibility labels", () => { + expect(getBinaryStatusKey("enabled")).toBe("enabled"); + expect(getBinaryStatusKey("disabled")).toBe("disabled"); + expect(getBinaryStatusKey()).toBe("unknown"); + }); +}); diff --git a/packages/widgets/src/common/locale.ts b/packages/widgets/src/common/locale.ts new file mode 100644 index 000000000..3d1c6bf3f --- /dev/null +++ b/packages/widgets/src/common/locale.ts @@ -0,0 +1,72 @@ +export type LocalizedDateValue = Date | number | string; + +const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/; + +interface LocalizedTimeOptions { + hour12?: boolean; + includeSeconds?: boolean; + timeZone?: string; +} + +const toValidDate = (value: LocalizedDateValue): Date | undefined => { + const date = + value instanceof Date + ? value + : new Date(typeof value === "string" && dateOnlyPattern.test(value) ? `${value}T00:00:00Z` : value); + return Number.isNaN(date.getTime()) ? undefined : date; +}; + +export const formatLocalizedDate = ( + value: LocalizedDateValue, + locale: string, + options: Intl.DateTimeFormatOptions, + fallback = "?", +): string => { + const date = toValidDate(value); + if (!date) return fallback; + + const normalizedOptions = + typeof value === "string" && dateOnlyPattern.test(value) && options.timeZone === undefined + ? { ...options, timeZone: "UTC" } + : options; + return new Intl.DateTimeFormat(locale, normalizedOptions).format(date); +}; + +export const formatLocalizedTime = ( + value: LocalizedDateValue, + locale: string, + { hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {}, + fallback = "?", +): string => + formatLocalizedDate( + value, + locale, + { + hour: "numeric", + minute: "2-digit", + second: includeSeconds ? "2-digit" : undefined, + hour12, + timeZone, + }, + fallback, + ); + +export const formatLocalizedDateTime = ( + value: LocalizedDateValue, + locale: string, + options: Pick = {}, + fallback = "?", +): string => + formatLocalizedDate( + value, + locale, + { dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone }, + fallback, + ); + +export const formatLocalizedCompactNumber = (value: number, locale: string): string => + new Intl.NumberFormat(locale, { notation: "compact" }).format(value); + +export type BinaryStatusKey = "enabled" | "disabled" | "unknown"; + +export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown"; diff --git a/packages/widgets/src/common/query-state-indicator.spec.ts b/packages/widgets/src/common/query-state-indicator.spec.ts new file mode 100644 index 000000000..86da6f64b --- /dev/null +++ b/packages/widgets/src/common/query-state-indicator.spec.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator"; + +vi.mock("@homarr/translation/client", () => ({ + useScopedI18n: () => (key: string, values?: { widget?: string }) => { + if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`; + if (key === "loading") return "Loading widget data"; + return key; + }, +})); + +const matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, +}); + +describe("WidgetQueryErrorIndicator", () => { + let host: HTMLDivElement | undefined; + let root: ReturnType | undefined; + + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + host?.remove(); + vi.unstubAllGlobals(); + }); + + test("uses a localized label without exposing the raw failure", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("matchMedia", matchMedia); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const secret = "http://admin:password@private.local/path?token=secret"; + + await act(async () => + root?.render( + createElement( + MantineProvider, + null, + createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }), + ), + ), + ); + + expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull(); + expect(host.textContent).not.toMatch(/password|private\.local|token=secret/); + }); + + test("announces the loading state to assistive technology", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("matchMedia", matchMedia); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + await act(async () => root?.render(createElement(MantineProvider, null, createElement(WidgetQueryLoadingState)))); + + expect(host.querySelector("output")?.textContent).toContain("Loading widget data"); + }); +}); diff --git a/packages/widgets/src/common/query-state-indicator.tsx b/packages/widgets/src/common/query-state-indicator.tsx new file mode 100644 index 000000000..dbd2c5a69 --- /dev/null +++ b/packages/widgets/src/common/query-state-indicator.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +interface WidgetQueryErrorIndicatorProps { + error: unknown; + label: string; +} + +/** Localized warning for stale or optional-query failures. Never renders the raw error. */ +export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) { + const t = useScopedI18n("common.widgetQuery"); + if (!error) return null; + + const accessibleLabel = t("stale", { widget: label }); + return ( + + + + + + ); +} + +export function WidgetQueryLoadingState() { + const t = useScopedI18n("common.widgetQuery"); + return ( +
+ + {t("loading")} +
+ ); +} diff --git a/packages/widgets/src/common/query-state.spec.ts b/packages/widgets/src/common/query-state.spec.ts new file mode 100644 index 000000000..c90c0ae50 --- /dev/null +++ b/packages/widgets/src/common/query-state.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; + +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "./query-state"; + +describe("getUsableWidgetQueryData", () => { + test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => { + expect(getUsableWidgetQueryData({ data, error: null })).toBe(data); + }); + + test("preserves stale cached data when a refetch fails", () => { + const data = [{ id: "healthy" }]; + expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data); + }); + + test("throws a terminal failure when no usable data exists", () => { + const error = new Error("offline"); + expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error); + }); + + test("keeps the initial pending state when neither data nor an error exists", () => { + expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined(); + }); +}); + +describe("isInitialWidgetQueryPending", () => { + test("only marks the unresolved first request as initial pending", () => { + expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true); + expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false); + expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false); + }); +}); diff --git a/packages/widgets/src/common/query-state.ts b/packages/widgets/src/common/query-state.ts new file mode 100644 index 000000000..a83066c0b --- /dev/null +++ b/packages/widgets/src/common/query-state.ts @@ -0,0 +1,18 @@ +interface WidgetQueryState { + data: unknown; + error: unknown; +} + +interface PendingWidgetQueryState extends WidgetQueryState { + isPending: boolean; +} + +/** Throws terminal query failures while preserving successful empty values and stale cached data. */ +export const getUsableWidgetQueryData = (query: TQuery): TQuery["data"] => { + if (query.error && query.data === undefined) throw query.error; + return query.data; +}; + +/** True only before the first result; successful empty values and cached data are both ready to render. */ +export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean => + isPending && data === undefined && !error; diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index 46940f56e..83449f190 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -1,16 +1,17 @@ "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"; import type { WidgetComponentProps } from "../definition"; +import { getUsableWidgetQueryData } from "../common/query-state"; 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, height }: WidgetComponentProps<"coolify">) { const t = useScopedI18n("widget.coolify"); if (integrationIds.length === 0) { @@ -21,29 +22,54 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget ); } - return ; + return ; } interface CoolifyContentProps { integrationIds: string[]; options: WidgetComponentProps<"coolify">["options"]; width: number; + height: number; } -function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) { - const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds }); +function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) { + const t = useScopedI18n("common"); + const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({ + integrationIds, + }); + const instancesData = getUsableWidgetQueryData(instancesQuery) ?? []; + const { isPending } = instancesQuery; - const isTiny = width < 256; + const isTiny = width < 256 || height < 144; + const hideFooter = height < 112; const [firstInstance] = instancesData; const widgetKey = createWidgetKey(integrationIds); + if (isPending) { + return ( + + + {t("action.loading")} + + + ); + } + if (instancesData.length === 1 && firstInstance) { - return ; + return ( + + ); } return ( - + = 760 ? 2 : 1} spacing="sm" p="xs"> {instancesData.map((instance) => ( ))} - + ); } diff --git a/packages/widgets/src/coolify/coolify-utils.spec.ts b/packages/widgets/src/coolify/coolify-utils.spec.ts new file mode 100644 index 000000000..0b9345861 --- /dev/null +++ b/packages/widgets/src/coolify/coolify-utils.spec.ts @@ -0,0 +1,29 @@ +import dayjs from "dayjs"; +import frenchLocale from "dayjs/locale/fr"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { formatRelativeTime, getResourceTimestamp } from "./coolify-utils"; + +describe("Coolify time formatting", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-02T12:00:00Z")); + dayjs.locale(frenchLocale); + }); + + afterEach(() => { + dayjs.locale("en"); + vi.useRealTimers(); + }); + + it("uses the active Day.js locale for past and future timestamps", () => { + expect(formatRelativeTime("2026-08-02T11:00:00Z")).toBe("il y a une heure"); + expect(formatRelativeTime("2026-08-02T13:00:00Z")).toBe("dans une heure"); + }); + + it("omits missing, invalid, and currently running timestamps", () => { + expect(formatRelativeTime(undefined)).toBeUndefined(); + expect(formatRelativeTime("invalid")).toBeUndefined(); + expect(getResourceTimestamp({ status: "running", updated_at: "2026-08-02T11:00:00Z" }, "service")).toBeUndefined(); + }); +}); diff --git a/packages/widgets/src/coolify/coolify-utils.ts b/packages/widgets/src/coolify/coolify-utils.ts index 7bf4f50cb..89343af93 100644 --- a/packages/widgets/src/coolify/coolify-utils.ts +++ b/packages/widgets/src/coolify/coolify-utils.ts @@ -1,9 +1,14 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + import type { CoolifyApplicationWithContext, CoolifyServer, CoolifyServiceWithContext, } from "@homarr/integrations/types"; +dayjs.extend(relativeTime); + export function parseStatus(status: string): string { const firstPart = status.split(":")[0]?.toLowerCase(); if (!firstPart) return "unknown"; @@ -42,26 +47,8 @@ export function getBadgeColor(running: number, total: number): string { export function formatRelativeTime(dateString: string | null | undefined): string | undefined { if (!dateString) return undefined; - const date = new Date(dateString); - if (isNaN(date.getTime())) return undefined; - - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHour / 24); - - if (diffDay > 0) { - const remainingHours = diffHour % 24; - return remainingHours > 0 ? `${diffDay}d ${remainingHours}h ago` : `${diffDay}d ago`; - } - if (diffHour > 0) { - const remainingMin = diffMin % 60; - return remainingMin > 0 ? `${diffHour}h ${remainingMin}m ago` : `${diffHour}h ago`; - } - if (diffMin > 0) return `${diffMin}m ago`; - return "just now"; + const date = dayjs(dateString); + return date.isValid() ? date.fromNow() : undefined; } export function getResourceTimestamp( @@ -116,7 +103,7 @@ export function buildServerResourceCounts( } export function createWidgetKey(integrationIds: string[]): string { - return integrationIds.slice().sort().join("-"); + return integrationIds.toSorted().join("-"); } export function createStorageKey(widgetKey: string, integrationId: string, type: "sections" | "show-ip"): string { diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts index cebf9de05..5aad13a33 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("coolify", { + supportsAdvancedFocus: false, icon: IconCloud, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx index 0f4522dc4..a085f0ef5 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -1,12 +1,13 @@ "use client"; -import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core"; +import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { useTimeAgo } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; import { ApplicationsSection } from "./applications-section"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils"; import { ServersSection } from "./servers-section"; import { ServicesSection } from "./services-section"; @@ -18,10 +19,12 @@ interface InstanceCardProps { options: CoolifyOptions; isTiny: boolean; widgetKey: string; + hideFooter: boolean; } -export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) { +export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter }: InstanceCardProps) { const t = useScopedI18n("widget.coolify"); + const tCommon = useScopedI18n("common"); const cardKey = `${widgetKey}-${instance.integrationId}`; const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${cardKey}`, @@ -31,14 +34,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC key: `coolify-sections-${cardKey}`, defaultValue: ["applications"], }); - const serverResourceCounts = buildServerResourceCounts( instance.instanceInfo.servers, instance.instanceInfo.applications, instance.instanceInfo.services, ); - const baseUrl = instance.integrationUrl.replace(/\/+$/, ""); + const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? ""; const relativeTime = useTimeAgo(instance.updatedAt); const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length; @@ -48,6 +50,23 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC const runningServices = instance.instanceInfo.services.filter( (s) => parseStatus(s.status ?? "") === "running", ).length; + const healthyResources = + (options.showServers ? onlineServers : 0) + + (options.showApplications ? runningApps : 0) + + (options.showServices ? runningServices : 0); + const totalResources = + (options.showServers ? instance.instanceInfo.servers.length : 0) + + (options.showApplications ? instance.instanceInfo.applications.length : 0) + + (options.showServices ? instance.instanceInfo.services.length : 0); + const resourceSummary = [ + options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null, + options.showApplications + ? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}` + : null, + options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null, + ] + .filter(Boolean) + .join(" · "); return ( @@ -59,17 +78,33 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC > Coolify - + {instance.integrationName} - {options.showServers && ( + {isTiny && totalResources > 0 ? ( + + + {healthyResources}/{totalResources} + + + ) : null} + {!isTiny && options.showServers && ( {onlineServers}/{instance.instanceInfo.servers.length} )} - {options.showApplications && ( + {!isTiny && options.showApplications && ( )} - {options.showServices && ( + {!isTiny && options.showServices && ( - - - v{instance.instanceInfo.version} - - - {t("footer.updated", { when: relativeTime })} - - + {!hideFooter && ( + + + v{instance.instanceInfo.version} + + + {t("footer.updated", { when: relativeTime })} + + + )} ); } diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index 9f6cfe1ae..b13c629c1 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -1,8 +1,19 @@ "use client"; -import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core"; -import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react"; +import { ActionIcon, Anchor, Group, Stack, Text } from "@mantine/core"; +import { + IconCircleCheckFilled, + IconCircleXFilled, + IconFileText, + IconHelpCircleFilled, + IconLink, + IconLoader2, +} from "@tabler/icons-react"; +import { useScopedI18n } from "@homarr/translation/client"; + +import actionTargetClasses from "../common/action-target.module.css"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils"; interface ResourceRowProps { @@ -24,52 +35,103 @@ interface ResourceRowProps { } export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) { + const t = useScopedI18n("widget.coolify"); const status = parseStatus(item.status ?? ""); const statusColor = getStatusColor(status); + const isTransitioning = status === "starting" || status === "restarting"; + const StatusIcon = + status === "running" + ? IconCircleCheckFilled + : isTransitioning + ? IconLoader2 + : status === "unknown" + ? IconHelpCircleFilled + : IconCircleXFilled; + const statusLabel = + status === "running" + ? t("status.running") + : status === "stopped" + ? t("status.stopped") + : status === "exited" + ? t("status.exited") + : status === "starting" + ? t("status.starting") + : status === "restarting" + ? t("status.restarting") + : t("status.unknown"); - const resourceUrl = + const resourceUrl = getSafeApplicationUrl( item.projectUuid && item.environmentUuid ? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}` - : undefined; + : undefined, + ); - const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined; + const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined); + const publicUrl = getSafeApplicationUrl(cleanFqdn(item.fqdn)); + const resourceTimestamp = getResourceTimestamp(item, resourceType); return ( - + {resourceUrl ? ( - + {item.name} ) : ( - + {item.name} )} - {cleanFqdn(item.fqdn) && ( - + {publicUrl && ( + )} - {resourceUrl && ( - - - - )} - {logsUrl && ( - + {!isTiny && logsUrl && ( + )} - - {item.projectName ?? "-"} / {item.environmentName ?? "-"} - - {getResourceTimestamp(item, resourceType) && ( + {!isTiny && ( + + {item.projectName ?? "-"} / {item.environmentName ?? "-"} + + )} + {!isTiny && resourceTimestamp && ( - {getResourceTimestamp(item, resourceType)} + {resourceTimestamp} )} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 543df1c67..2e79bfdf0 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -1,11 +1,13 @@ "use client"; -import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core"; -import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react"; +import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; +import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react"; import type { CoolifyServer } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; +import actionTargetClasses from "../common/action-target.module.css"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getBadgeColor } from "./coolify-utils"; interface ServersSectionProps { @@ -31,27 +33,28 @@ export function ServersSection({ return ( - }> - + + } style={{ flex: 1, minWidth: 0 }}> {tCommon("servers")} {onlineServers} / {servers.length} - { - event.stopPropagation(); - onToggleIp(); - }} - > - {showIp ? : } - - - + + + {showIp ? : } + + {servers.length > 0 ? ( @@ -90,13 +93,28 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) const t = useScopedI18n("widget.coolify"); const isBuildServer = server.settings?.is_build_server === true; const isOnline = server.is_reachable !== false; - const serverUrl = `${baseUrl}/server/${server.uuid}`; + const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`); + const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled; return ( - - + + {server.name} {isBuildServer ? ( @@ -105,14 +123,11 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) ) : ( - ({counts.apps} apps / {counts.services} svcs) + ({t("server.resourceCounts", { applications: counts.apps, services: counts.services })}) )} - - - {showIp ? server.ip : "***.***.***.***"} diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx index ae44b998f..fc3455b2d 100644 --- a/packages/widgets/src/coolify/single-instance-layout.tsx +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -7,6 +7,7 @@ import { useTimeAgo } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; import { ApplicationsSection } from "./applications-section"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { buildServerResourceCounts } from "./coolify-utils"; import { ServersSection } from "./servers-section"; import { ServicesSection } from "./services-section"; @@ -18,9 +19,10 @@ interface SingleInstanceLayoutProps { options: CoolifyOptions; isTiny: boolean; widgetKey: string; + hideFooter: boolean; } -export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) { +export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hideFooter }: SingleInstanceLayoutProps) { const t = useScopedI18n("widget.coolify"); const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${widgetKey}`, @@ -30,15 +32,14 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S key: `coolify-sections-${widgetKey}`, defaultValue: ["applications"], }); - const serverResourceCounts = buildServerResourceCounts( instance.instanceInfo.servers, instance.instanceInfo.applications, instance.instanceInfo.services, ); - const baseUrl = instance.integrationUrl.replace(/\/+$/, ""); - const displayUrl = baseUrl.replace(/^https?:\/\//, ""); + const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? ""; + const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—"; const relativeTime = useTimeAgo(instance.updatedAt); return ( @@ -51,7 +52,16 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S oolify - + {displayUrl} @@ -75,21 +85,23 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S )} - - - Coolify + {!hideFooter && ( + + + Coolify + + v{instance.instanceInfo.version} + + - v{instance.instanceInfo.version} + {t("footer.updated", { when: relativeTime })} - - {t("footer.updated", { when: relativeTime })} - - + )} ); diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx index 797daa158..05e8775af 100644 --- a/packages/widgets/src/custom-api/component.tsx +++ b/packages/widgets/src/custom-api/component.tsx @@ -87,7 +87,6 @@ export default function CustomApiWidget({ /> ); } - function Unavailable({ message, danger = false, diff --git a/packages/widgets/src/custom-api/custom-jsx-display.spec.ts b/packages/widgets/src/custom-api/custom-jsx-display.spec.ts index 3615d2c09..51e8ba437 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.spec.ts +++ b/packages/widgets/src/custom-api/custom-jsx-display.spec.ts @@ -415,7 +415,6 @@ describe("CustomJsxDisplay", () => { }); }); }); - describe("Custom JSX component registry", () => { it.each([ ["@mantine/core", MantineCore], diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index de35c430b..7037c7272 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -77,7 +77,6 @@ export default function CustomJsxDisplay({ data }: { data: Record ); } - function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) { return ( left.loading === right.loading && diff --git a/packages/widgets/src/definition.spec.ts b/packages/widgets/src/definition.spec.ts new file mode 100644 index 000000000..215c884ef --- /dev/null +++ b/packages/widgets/src/definition.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { definition as appDefinition } from "./app"; +import { definition as beszelSystemGridDefinition } from "./beszel-system-grid"; +import { definition as beszelSystemTableDefinition } from "./beszel-system-table"; +import { definition as customApiDefinition } from "./custom-api"; +import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition"; +import { definition as downloadsDefinition } from "./downloads"; + +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", "getStats"]], + [["widget", "mediaServer", "getCurrentStreams"]], + ] as const; + + expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys); + }); +}); + +describe("supportsAdvancedFocus", () => { + test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition, customApiDefinition])( + "keeps non-enhanced widgets compact", + (widgetDefinition) => { + expect(supportsAdvancedFocus(widgetDefinition)).toBe(false); + }, + ); + + test("requires an explicit opt-in", () => { + expect(supportsAdvancedFocus({})).toBe(false); + expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true); + }); +}); diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 741541dfc..6ef443999 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -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; - setOptions: (partial: Partial>) => 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; + +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; + runtimeQueries: readonly NormalizedWidgetQuery[]; +} + +export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean; + +export interface WidgetContextActionProps { + options: Record; + setOptions: (partial: Record) => void; integrationIds: string[]; context: WidgetContextMenuContext; - widgetStateRef: React.MutableRefObject | null>; + widgetRuntimeRef: WidgetRuntimeRef; } const createWithDynamicImport = @@ -69,8 +102,10 @@ export const createWidgetDefinition = >; - contextActions?: (props: Omit, "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) => + 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 => + value !== null && typeof value === "object" && !Array.isArray(value); export interface WidgetProps { options: inferOptionsFromCreator>; @@ -103,9 +176,11 @@ export interface WidgetProps { export type WidgetComponentProps = WidgetProps & { boardId: string | undefined; // undefined when in preview mode isEditMode: boolean; + displayMode?: "compact" | "advanced"; setOptions: ({ newOptions }: { newOptions: Partial>> }) => void; width: number; height: number; + widgetRuntimeRef?: WidgetRuntimeRef; widgetStateRef?: React.MutableRefObject | null>; removeItem?: () => void; }; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index f46c3d746..13977a5a0 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -33,6 +33,8 @@ import { MaskedOrNormalImage } from "@homarr/ui"; import type { widgetKind } from "."; import type { WidgetComponentProps } from "../../definition"; +import { getUsableWidgetQueryData } from "../../common/query-state"; +import actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; import TimerModal from "./TimerModal"; @@ -44,6 +46,7 @@ export default function DnsHoleControlsWidget({ integrationIds, isEditMode, width, + height, }: WidgetComponentProps) { const board = useRequiredBoard(); // DnsHole integrations with interaction permissions @@ -51,18 +54,26 @@ export default function DnsHoleControlsWidget({ .map(({ id }) => id) .filter((id) => integrationIds.includes(id)); - const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({ - integrationIds, - }); + const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds }); + const summaries = getUsableWidgetQueryData(summaryQuery) ?? []; + const { isPending: isSummaryPending } = summaryQuery; 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 +97,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 +114,30 @@ export default function DnsHoleControlsWidget({ // Timer modal setup const [selectedIntegrationIds, setSelectedIntegrationIds] = useState([]); + const [bulkPending, setBulkPending] = useState(false); + const [bulkFailureCount, setBulkFailureCount] = useState(0); const [opened, { close, open }] = useDisclosure(false); const controlAllButtonsVisible = options.showToggleAllButtons && 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); + }; + + if (isSummaryPending) { + return ( + + + {t("common.action.loading")} + + + ); + } return (
- } - sections={[ - { - value: Number(summary.percent.toFixed(1)), - color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green", - }, - ]} - /> - ))} - - {firewallsInterfacesData - .filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall)) - .map(({ summary }) => ( - - - }> - {t("widget.firewall.widget.interfaces.title")} - - - - {Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? ( - calculateBandwidth(summary).data.map(({ name, receive, transmit }) => ( - - - - {name} - - - - - - {formatBitsPerSec(transmit, 2)} - - - - - - {formatBitsPerSec(receive, 2)} - - - - )) - ) : ( - No data available - )} - - - - - ))} + + {activeFirewall && firewallHasError(activeFirewall) && ( + + + {t("common.error")} + + + )} + !error)} + selectedFirewall={activeFirewall} + isTiny={isTiny} + /> + + + )} + + = 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 = firewallHasError(firewallId); + const metadata = firewallMetadata.get(firewallId); + + return ( + + ); + })} + + ); } -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[]; + interfacesLoaded: boolean; + interfacesError: boolean; + hasError: boolean; + isAdvanced: boolean; + ringSize: number; + showInterfaces: boolean; + accordionValue: string | null; + setAccordionValue: (value: string | null) => void; + errorLabel: string; + noDataLabel: string; + loadingLabel: string; + errorBadgeLabel: 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, + interfacesLoaded, + interfacesError, + hasError, + isAdvanced, + ringSize, + showInterfaces, + accordionValue, + setAccordionValue, + errorLabel, + noDataLabel, + loadingLabel, + errorBadgeLabel, + interfacesLabel, + t, +}: FirewallPanelProps) => ( + + + {isAdvanced && ( + + + + {name} + + {kind && ( + + {kind} + + )} + + + {hasError && ( + + + {t("common.error")} + + + )} + + {formatVersion(version ?? "", t("widget.firewall.versionUnknown"))} + + + + )} - if (data.length > 1) { - const firstData = data[0]; - const secondData = data[1]; + + {cpu !== undefined && ( + + )} + {memory !== undefined && ( + + )} + - if (firstData && secondData) { - const time1 = new Date(firstData.timestamp); - const time2 = new Date(secondData.timestamp); - const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000; + {showInterfaces && ( + + )} + + +); - 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; + size: number; + label: string; + t: TranslationFunction; } + +const MetricRing = ({ value, icon: Icon, size, 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}`); + const showIcon = size >= 64; + const showStatus = size >= 96; + + return ( + + {safeValue.toFixed(1)}% + {showIcon && } + {showStatus && {statusLabel}} +
+ } + 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[]; + hasResult: boolean; + hasError: boolean; + isAdvanced: boolean; + accordionValue: string | null; + setAccordionValue: (value: string | null) => void; + errorLabel: string; + errorBadgeLabel: string; + noDataLabel: string; + loadingLabel: string; + label: string; +} + +const InterfacesPanel = ({ + summary, + hasResult, + hasError, + isAdvanced, + accordionValue, + setAccordionValue, + errorLabel, + errorBadgeLabel, + noDataLabel, + loadingLabel, + label, +}: InterfacesPanelProps) => { + const bandwidth = calculateBandwidth(summary).data; + + return ( + + + }> + + {label} + {hasError && ( + + {errorBadgeLabel} + + )} + + + + + {bandwidth.map(({ name, receive, transmit }) => ( + + + {name} + + + + + {formatBitsPerSec(transmit, 2)} + + + + + + {formatBitsPerSec(receive, 2)} + + + + ))} + {hasError ? ( + + {errorLabel} + + ) : bandwidth.length === 0 ? ( + + {hasResult ? noDataLabel : loadingLabel} + + ) : null} + + + + + ); +}; diff --git a/packages/widgets/src/firewall/failure-state.spec.ts b/packages/widgets/src/firewall/failure-state.spec.ts new file mode 100644 index 000000000..eb4743ccc --- /dev/null +++ b/packages/widgets/src/firewall/failure-state.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { hasFirewallPartialFailure, hasTotalFirewallFailure } from "./component"; + +const failedQuery = { + isPending: false, + isFetching: false, + isError: false, + data: [{ error: "offline" }], +}; + +describe("firewall failure state", () => { + it("rejects only after every dimension fails", () => { + expect(hasTotalFirewallFailure([failedQuery, failedQuery, failedQuery, failedQuery])).toBe(true); + expect( + hasTotalFirewallFailure([ + failedQuery, + failedQuery, + failedQuery, + { ...failedQuery, data: [{ error: undefined }] }, + ]), + ).toBe(false); + }); + + it("lets an error-only persisted cache recover while it refetches", () => { + expect(hasTotalFirewallFailure([failedQuery, failedQuery, { ...failedQuery, isFetching: true }, failedQuery])).toBe( + false, + ); + }); + + it("preserves stale cached data when a query-wide refetch fails", () => { + const staleQuery = { + ...failedQuery, + isError: true, + data: [{ integration: { id: "firewall-1" }, error: undefined }], + }; + + expect(hasTotalFirewallFailure([staleQuery, failedQuery, failedQuery, failedQuery])).toBe(false); + }); + + it("surfaces query-wide and integration-specific partial failures", () => { + const successfulQuery = { + isPending: false, + isFetching: false, + isError: false, + data: [{ integration: { id: "firewall-1" }, error: undefined }], + }; + + expect(hasFirewallPartialFailure("firewall-1", [successfulQuery])).toBe(false); + expect(hasFirewallPartialFailure("firewall-1", [{ ...successfulQuery, isError: true }])).toBe(true); + expect( + hasFirewallPartialFailure("firewall-1", [ + { ...successfulQuery, data: [{ integration: { id: "firewall-1" }, error: "offline" }] }, + ]), + ).toBe(true); + }); +}); diff --git a/packages/widgets/src/firewall/firewall-menu.tsx b/packages/widgets/src/firewall/firewall-menu.tsx index e96662dfb..6b7a26239 100644 --- a/packages/widgets/src/firewall/firewall-menu.tsx +++ b/packages/widgets/src/firewall/firewall-menu.tsx @@ -10,16 +10,18 @@ interface FirewallMenuProps { } export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall }: FirewallMenuProps) => ( - +