Compare commits

...
Author SHA1 Message Date
ajnart 7e420bc087 refactor: share mobile breakpoint handling 2026-08-27 09:15:33 +02:00
ajnart 322d4a7fc4 fix: keep mobile header logo condensed 2026-08-27 07:26:34 +02:00
ajnart 63e1bc7f6c fix: improve mobile header and board menus 2026-08-27 07:24:40 +02:00
Bergasha 1b2f1bd720 Merge pull request #6714 from homarr-labs/fix/v2-container-scale-fix
fix(V2 container): widgets clipping and label overlap
2026-08-27 14:39:16 +10:00
Bergasha c82965067c Floor containerContentScale above zero
logicalWidth/viewportHeight can go non-positive for a narrow container
on a heavily zoomed-out board, where outerCardInset (which grows as
canvasScale shrinks) can exceed the container's own un-inset size.
That non-positive scale then feeds into a division (Infinity/NaN into
--board-canvas-ui-scale) and is used as zoom directly (invalid or
collapsed content) - flooring it well above zero avoids both.
2026-08-27 14:17:17 +10:00
Bergasha 8abc9cae6c Fix unused logicalHeight variable left over from CodeRabbit fix
Removing it in favor of viewportHeight for the vertical fit ratio left
the declaration itself dead code, which the CI lint step flags as an
error rather than a warning.
2026-08-27 13:56:45 +10:00
Bergasha becc6a71a9 Address CodeRabbit review on container scale fix PR
- Guard canvasScale before dividing by it in outerCardInset, matching
  calculateBoardUiScale's own fallback.
- Fix containerContentScale's vertical fit ratio for scrollable
  containers: it was derived from the full (all-rows) content height,
  which drifts toward 1 as more rows are added regardless of the fixed
  inset, under-scaling relative to what the visible viewport actually
  needs and clipping the bottom of the visible rows. Use
  viewportHeight/viewportRowCount instead, which describe the real
  visible card size in both the scrollable and non-scrollable case.
2026-08-27 13:46:34 +10:00
Bergasha 78a4f0e513 Fix container widgets clipping and label overlap
A container's own visible card is inset from its allocated board cell
by the board's standard per-item gap, but items inside it are
positioned in a coordinate space sized to the un-inset column/row
count - so without correcting for that, items (not just the grid's
own box) spilled past the container's right/bottom edge by that
inset amount, worse the busier/more zoomed-out the board is.

Scales the container's inner grid content down to fit the actual card
size, using the same zoom mechanism the board's own top-level canvas
uses for its whole-board zoom, just one level deeper. Corrects
--board-canvas-ui-scale to compensate so icon/text/custom-CSS sizing
inside the container isn't affected - computed as a plain JS number
rather than a CSS calc() referencing the property's own inherited
value, since custom properties don't have a sequential/temporal order
within the same element and doing so is a circular reference that
silently invalidates the whole property for every descendant.

Also fixes the collapsible container's label bar poking above the
card's rounded top border via a negative offset - it now sits flush
inside it.
2026-08-27 13:34:57 +10:00
Bergasha 70e1b29913 Merge pull request #6713 from homarr-labs/fix/v2-system-resource-widget
fix(v2 system resources widget): overflow and initial chart render
2026-08-27 11:09:18 +10:00
Bergasha 73de83b48f Address CodeRabbit review on system resources widget PR
- Render the chart tooltip via a portal so it isn't clipped by the
  card's overflow:hidden (needed to stop the chart line itself
  bleeding past its rounded corners).
- Move chartColumns' width breakpoints into a lookup table and
  memoize the computed value, instead of an inline ternary.
2026-08-27 10:46:04 +10:00
Bergasha 26d12d9fad Fix system resources widget overflow and initial chart flicker
The chart card and its outer widget shell both had overflow:visible,
letting chart lines bleed past their rounded borders into neighboring
widgets. Compact-mode charts also stretched to fill the widget's full
resized height instead of staying compact like other widgets.

Additionally, the history used to build the chart needs 2+ points to
render a line - it started empty and only got its first real point on
the next poll (up to 30s later), so the chart would flash between a
single-value fallback and the actual graph. Both the initial render
and the first state update now seed with the current value duplicated,
so a flat line renders immediately with no flicker.
2026-08-27 09:07:05 +10:00
Thomas f0eddda573 fix: preserve assistant CSS and daily quota history (#6705)
* fix: route custom css through review form

* feat(workshop): retain daily assistant quotas
2026-08-26 14:48:08 +02:00
Thomas b2c72d54c2 fix: refine configurable header defaults (#6707) 2026-08-26 14:29:51 +02:00
35 changed files with 400 additions and 113 deletions

No files matched your search

+4 -2
View File
@@ -38,8 +38,10 @@ its server; changing the browser clock cannot bypass them.
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
train models.
We would rather become millionaires in a more ethical manner.
+3
View File
@@ -72,6 +72,9 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
collection for a daily usage breakdown; regular users cannot list or view its records.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
+1 -1
View File
@@ -4387,7 +4387,7 @@
"enum": ["input", "icon"]
},
"logoDisplay": {
"default": "logo",
"default": "logoAndText",
"type": "string",
"enum": ["logo", "logoAndText"]
},
@@ -111,7 +111,7 @@ export const createBoardLayout = <TParams extends Params>({
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
@@ -12,6 +12,7 @@ import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { useIsMobile } from "@homarr/ui/hooks";
interface UserProfileAvatarForm {
user: RouterOutputs["user"]["getById"];
@@ -25,7 +26,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
},
});
const [opened, { toggle }] = useDisclosure(false);
const isMobile = useMediaQuery("(max-width: 48em)");
const isMobile = useIsMobile();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tManageAvatar = useI18n("user.action.manageAvatar");
@@ -89,8 +89,9 @@ const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T
};
};
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (!toolCall.toolName.startsWith("customWidget_")) return null;
const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
const isCustomWidgetTool = toolCall.toolName.startsWith("customWidget_");
if (!isCustomWidgetTool && toolCall.toolName !== "configure_board_settings") return null;
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
try {
const input = JSON.parse(toolCall.input) as unknown;
@@ -114,4 +115,4 @@ const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall:
};
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
repairIconSearchInput(toolCall) ?? repairMultilineToolInput(toolCall);
@@ -151,12 +151,12 @@ Homarr concepts:
Action rules:
- Prefer read-only tools before actions.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never use ask_user to confirm a board settings or custom CSS change before configure_board_settings, or to confirm another change whose inputs are already sufficient for its native review form or mutating tool. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
- Do not retry a denied action.
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, call configure_widget afterward with kind "app" and options { appId: the returned appId }.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then immediately call configure_board_settings with only the requested proposed changes. For a custom CSS request, include the complete resulting stylesheet in changes.customCss so the native form is prefilled and previews the proposed CSS. The native form is the user's review and confirmation step, so never insert ask_user between these calls when the requested change is known. Use the form's returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before adding any widget to a board, call configure_widget with the target board, widget kind, proposed options, and any integration IDs selected from integration_all. Homarr's native widget editor applies defaults, validates the options, and restricts the picker to compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds exactly as the input to board_addItem. If configure_widget returns cancelled, do not add the widget.
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
@@ -54,7 +54,7 @@ export const assistantBoardSettingsChangesSchema = z
export const browserToolContracts = {
ask_user: {
description:
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
"Pause and ask the user one concise structured question. Use this only for missing information or a meaningful choice, never to confirm details that are already sufficient for a native review form or mutating tool. In particular, do not use ask_user before configure_app, configure_board_settings, or configure_widget when their inputs are known. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
parameters: z.object({
question: z.string().trim().min(1).max(240),
description: z.string().trim().max(400).optional(),
@@ -82,7 +82,7 @@ export const browserToolContracts = {
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
"Open Homarr's native board settings and custom CSS review form. This form is the user's confirmation step. Always call board_getBoardSettings first, then call this tool directly with only the requested proposed changes, including the complete resulting stylesheet in changes.customCss for a CSS request. Do not call ask_user between those tools when the requested changes are known. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
@@ -15,6 +15,10 @@
outline: none;
}
.results {
min-height: 0;
}
.searchInput {
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
@@ -44,3 +48,19 @@
border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-md);
}
@media (max-width: $mantine-breakpoint-sm) {
.modalContent {
height: calc(100dvh - var(--modal-y-offset) * 2);
}
.modalBody,
.content {
min-height: 0;
height: 100%;
}
.results {
flex: 1;
}
}
@@ -228,7 +228,13 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
radius="xl"
classNames={{ input: classes.searchInput }}
/>
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
<ScrollArea.Autosize
mah="min(80vh, 42rem)"
type="never"
scrollbars="y"
overscrollBehavior="contain"
className={classes.results}
>
{results}
</ScrollArea.Autosize>
</Stack>
@@ -0,0 +1,14 @@
.mobileDrawerContent {
height: min(80dvh, rem(640px));
max-height: 80dvh;
overscroll-behavior: contain;
}
.mobileDrawerBody {
padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom));
}
.mobileActionList {
display: flex;
flex-direction: column;
}
@@ -2,7 +2,7 @@
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { Drawer, 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 { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -20,6 +20,7 @@ import { getWidgetName } from "@homarr/definitions";
import { translateIfNecessary } from "@homarr/translation";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useIsMobile } from "@homarr/ui/hooks";
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
@@ -31,6 +32,9 @@ import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
import classes from "./widget-context-menu.module.css";
type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }];
interface WidgetContextMenuProps {
item: SectionItem;
@@ -134,11 +138,12 @@ export const WidgetContextMenu = ({
[item.id, persistBoard],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return [];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
const rawOptions = definition.createOptions(settings) as unknown as Record<string, ToggleOption[1]>;
return Object.entries(rawOptions).filter(
([, option]) => option.type === "switch" && !option.skipContextMenu,
) as ToggleOption[];
}, [canConfigureWidget, definition, settings]);
const widgetContextActions =
@@ -222,6 +227,18 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return;
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}, [item.id, openAdvancedFocus, sourceRef]);
const handleCloseMenu = useCallback(() => {
setMenuOpened(false);
}, []);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
@@ -239,21 +256,10 @@ export const WidgetContextMenu = ({
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
<Menu.Item closeMenuOnClick leftSection={<IconMaximize size={16} />} onClick={handleOpenAdvancedFocus}>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
@@ -319,11 +325,39 @@ export const WidgetContextMenu = ({
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
</WidgetContextMenuDropdown>
</Menu>
);
};
interface WidgetContextMenuDropdownProps {
opened: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => {
const isMobile = useIsMobile();
if (!isMobile) return <Menu.Dropdown>{children}</Menu.Dropdown>;
return (
<Drawer
opened={opened}
onClose={onClose}
position="bottom"
size="min(80dvh, 40rem)"
title={title}
classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }}
>
<div className={classes.mobileActionList} role="menu" aria-orientation="vertical" data-menu-dropdown>
{children}
</div>
</Drawer>
);
};
interface WidgetQueryStatusProps {
queryClient: QueryClient;
matchesQuery: (queryKey: QueryKey) => boolean;
@@ -67,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
top={0}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
@@ -18,7 +18,7 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -138,9 +138,56 @@ export const SectionGrid = ({
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
const logicalWidth = getLogicalGridSize(columnCount);
const logicalHeight = getLogicalGridSize(rowCount);
const viewportHeight = getLogicalGridSize(viewportRowCount);
// A container's own visible card is inset from its allocated board cell by the board's
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
// any widget, so it gets that same gap). A widget's content just fills whatever size its
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
// the same column/row counts used to allocate the *outer*, uninset cell - so without
// subtracting that gap back out here, a container's inner grid renders larger than its own
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size.
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
// corrected to compensate so icon/text sizing inside the container isn't affected.
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
// be left with a small amount of unfilled slack when width/height need different ratios to
// exactly fit - centered below so it's even on both sides rather than left-aligned.
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
// zero or negative - combinedUiScale divides by this value, and this value is also used as
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -249,7 +296,15 @@ export const SectionGrid = ({
>
<Box
className={classes.staticGrid}
style={{ width: logicalWidth, height: logicalHeight }}
style={
{
width: fullGridWidth,
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
+2 -1
View File
@@ -27,11 +27,12 @@ export const MainHeader = async ({
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const resolvedLogoWithTitle = logoWithTitle ?? <HomarrLogoWithTitle size="md" />;
return (
<ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
logoWithTitle={resolvedLogoWithTitle}
actions={actions}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
@@ -2,6 +2,15 @@
overflow: hidden;
}
.header[data-desktop-visible="false"] {
display: none;
}
.desktopContent {
width: 100%;
height: 100%;
}
.content {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -16,7 +25,10 @@
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
box-sizing: border-box;
min-width: 0;
height: 100%;
padding-inline-end: rem(10px);
overflow-x: auto;
scrollbar-width: none;
}
@@ -61,6 +73,41 @@
flex: 0 0 auto;
}
.mobileContent {
display: none;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-xs);
}
.mobileIdentity,
.mobileActions {
min-width: 0;
}
.mobileIdentity {
flex: 1 1 auto;
overflow: hidden;
}
.mobileIdentity > :last-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.mobileIdentity h2 {
overflow: hidden;
text-overflow: ellipsis;
}
.mobileActions {
flex: 0 0 auto;
}
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
@@ -108,18 +155,21 @@
}
@media (max-width: $mantine-breakpoint-sm) {
.content {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
.header[data-desktop-visible="false"] {
display: block;
}
.zone {
gap: var(--mantine-spacing-xs);
.desktopContent {
display: none;
}
.search {
width: auto;
flex: 0 0 auto;
.mobileContent {
display: flex;
}
.floatingControls,
.floatingControlsCorner {
display: none;
}
}
@@ -2,15 +2,7 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
import {
IconBrandDocker,
IconHome,
@@ -116,14 +108,15 @@ export const ConfigurableHeader = ({
return (
<>
{headerPreferences.visible ? (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-advanced-focus-background
data-app-shell-header
>
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-desktop-visible={headerPreferences.visible}
data-advanced-focus-background
data-app-shell-header
>
<div className={classes.desktopContent}>
<div className={classes.content}>
<div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null}
@@ -137,8 +130,31 @@ export const ConfigurableHeader = ({
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div>
</div>
</AppShellHeader>
) : (
</div>
<div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null}
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
</Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
{actions}
{boardEditAction}
{boardSettingsAction}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group>
</div>
</AppShellHeader>
{!headerPreferences.visible ? (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
@@ -148,7 +164,7 @@ export const ConfigurableHeader = ({
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
)}
) : null}
<LazySpotlight />
</>
);
@@ -291,7 +307,7 @@ const HeaderItem = ({
<Tooltip label={board.name}>
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
<Avatar src={board.logoImageUrl} size={22} radius="sm">
<IconLayoutDashboard size={17} stroke={1.5} />
{getBoardInitial(board.name)}
</Avatar>
</HeaderButton>
</Tooltip>
@@ -299,14 +315,7 @@ const HeaderItem = ({
}
if (item.id === "logo") {
return (
<HeaderLogo
display={logoDisplay}
logo={logo}
logoWithTitle={logoWithTitle}
label={label("logo")}
/>
);
return <HeaderLogo display={logoDisplay} logo={logo} logoWithTitle={logoWithTitle} label={label("logo")} />;
}
if (item.id === "boardEdit") return boardEditAction;
@@ -416,3 +425,5 @@ const HeaderItem = ({
</TourTarget>
);
};
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
+2 -2
View File
@@ -22,12 +22,12 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const isHeaderVisible = hasHeader && headerPreferences.visible;
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
return (
<AppShell
{...backgroundProps}
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
? {
@@ -4,7 +4,6 @@ import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
import {
IconAffiliateFilled,
IconAppsFilled,
@@ -16,6 +15,7 @@ import {
import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { useIsMobile } from "@homarr/ui/hooks";
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell";
@@ -48,7 +48,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useI18n("onboardingTour.manage");
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const isMobile = useMediaQuery("(max-width: 48em)");
const isMobile = useIsMobile();
const pathname = usePathname();
const router = useRouter();
+7 -5
View File
@@ -605,7 +605,12 @@ func (provider *homarrProvider) loadQuota(
user *core.Record,
now time.Time,
) (*core.Record, quotaSnapshot, bool, error) {
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
day := now.Format(time.DateOnly)
quota, err := app.FindFirstRecordByFilter(
"assistant_quotas",
"user = {:user} && day = {:day}",
dbx.Params{"user": user.Id, "day": day},
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, quotaSnapshot{}, false, err
}
@@ -617,10 +622,7 @@ func (provider *homarrProvider) loadQuota(
}
quota = core.NewRecord(collection)
quota.Set("user", user.Id)
changed = true
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("day", day)
quota.Set("used", 0)
changed = true
}
@@ -0,0 +1,36 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user");
quotas.addIndex("idx_assistant_quotas_user_day", true, "user, day", "");
app.save(quotas);
},
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user_day");
// The previous schema can keep only one record per user. Retain the latest UTC day when rolling back.
app
.db()
.newQuery(`
DELETE FROM assistant_quotas
WHERE id NOT IN (
SELECT (
SELECT candidate.id
FROM assistant_quotas candidate
WHERE candidate.user = quota_user.user
ORDER BY candidate.day DESC, candidate.created DESC, candidate.id DESC
LIMIT 1
)
FROM assistant_quotas quota_user
GROUP BY quota_user.user
)
`)
.execute();
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
app.save(quotas);
},
);
@@ -82,6 +82,14 @@ for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activit
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
+14 -2
View File
@@ -436,9 +436,21 @@ const resetUsage = await request("/api/ai/usage", { headers: authorSession.heade
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const resetQuota = (
const today = new Date().toISOString().slice(0, 10);
const resetQuotas = (
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
).items.find((item) => item.user === author.id);
).items.filter((item) => item.user === author.id);
const historicalQuota = resetQuotas.find((item) => item.id === authorQuota.id);
const resetQuota = resetQuotas.find((item) => item.day === today);
if (
resetQuotas.length !== 2 ||
historicalQuota?.day !== "2000-01-01" ||
historicalQuota.used !== 49 ||
!resetQuota ||
resetQuota.used !== 0
) {
throw new Error(`Daily quota history was not preserved: ${JSON.stringify(resetQuotas)}`);
}
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
@@ -1,4 +1,4 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}') NOT NULL;
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}') NOT NULL;
--> statement-breakpoint
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}');
@@ -2620,7 +2620,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logoAndText\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
@@ -1,4 +1,4 @@
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
--> statement-breakpoint
INSERT INTO "serverSetting" ("setting_key", "value")
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
@@ -2485,7 +2485,7 @@
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logoAndText\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
@@ -1,4 +1,4 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
--> statement-breakpoint
INSERT INTO `serverSetting` (`setting_key`, `value`)
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
@@ -2551,7 +2551,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logoAndText\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
+1
View File
@@ -1 +1,2 @@
export * from "./use-is-mobile";
export * from "./use-translated-mantine-react-table";
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { useMantineTheme } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
export const useIsMobile = () => {
const theme = useMantineTheme();
return useMediaQuery(`(max-width: ${theme.breakpoints.sm})`);
};
@@ -104,7 +104,7 @@ export const headerPreferencesSchema = z
version: z.literal(headerPreferencesVersion),
visible: z.boolean(),
searchDisplay: z.enum(headerSearchDisplayValues),
logoDisplay: z.enum(headerLogoDisplayValues).default("logo"),
logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"),
zones: headerZonesSchema,
})
.strict();
@@ -154,7 +154,7 @@ const migrateLegacyItems = (
version: headerPreferencesVersion,
visible,
searchDisplay: "input",
logoDisplay: "logo",
logoDisplay: "logoAndText",
zones: {
left: zones.left.map(createBuiltinHeaderItem),
center: zones.center.map(createBuiltinHeaderItem),
@@ -208,7 +208,7 @@ export const defaultHeaderPreferences = {
version: headerPreferencesVersion,
visible: true,
searchDisplay: "input",
logoDisplay: "logo",
logoDisplay: "logoAndText",
zones: {
left: [createBuiltinHeaderItem("logo")],
center: [createBuiltinHeaderItem("search")],
@@ -53,7 +53,7 @@ export const CommonChart = ({
ref={ref}
h={"100%"}
pos={"relative"}
style={{ overflow: "visible", border: `1px solid ${borderColor}` }}
style={{ overflow: "hidden", border: `1px solid ${borderColor}` }}
p={0}
bg={backgroundColor}
radius={board.itemRadius}
@@ -127,7 +127,12 @@ export const CommonChart = ({
},
}}
tooltipAnimationDuration={200}
tooltipProps={tooltipProps}
tooltipProps={{
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden
// (needed to stop the chart line itself bleeding past its rounded corners).
portal: typeof document !== "undefined" ? document.body : undefined,
...tooltipProps,
}}
withTooltip={height >= 64}
yAxisProps={yAxisProps}
fillOpacity={chartType === "area" ? 0.3 : undefined}
@@ -19,6 +19,15 @@ const COMPACT_HISTORY_SIZE = 15;
const ADVANCED_HISTORY_SIZE = 60;
const ALL_SYSTEM_CHARTS = ["cpu", "memory", "gpu", "network"] as const;
const ADVANCED_CHART_COLUMN_BREAKPOINTS = [
{ minWidth: 1100, columns: 4 },
{ minWidth: 560, columns: 2 },
{ minWidth: 0, columns: 1 },
] as const;
const getAdvancedChartColumns = (width: number): number =>
ADVANCED_CHART_COLUMN_BREAKPOINTS.find(({ minWidth }) => width >= minWidth)?.columns ?? 1;
type SystemChart = WidgetComponentProps<"systemResources">["options"]["visibleCharts"][number];
type ChartItem = {
@@ -110,14 +119,13 @@ export default function SystemResources({
lastUpdatedAt.current = dataUpdatedAt;
setHistoryByIntegration((previous) =>
Object.fromEntries(
data.map((entry) => [
entry.integrationId,
appendBoundedHistory(
previous[entry.integrationId] ?? [],
toChartItem(entry.healthInfo),
ADVANCED_HISTORY_SIZE,
),
]),
data.map((entry) => {
const currentItem = toChartItem(entry.healthInfo);
// Seed with the current item when there's no history yet, so the first real
// update lands at 2 points instead of dropping back to the single-value fallback.
const seed = previous[entry.integrationId] ?? [currentItem];
return [entry.integrationId, appendBoundedHistory(seed, currentItem, ADVANCED_HISTORY_SIZE)];
}),
),
);
}, [dataUpdatedAt, data]);
@@ -126,7 +134,9 @@ export default function SystemResources({
const panelWidth = width / panelColumns;
const renderCharts = (entry: (typeof data)[number], availableWidth: number, availableHeight: number) => {
const currentItem = toChartItem(entry.healthInfo);
const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem];
// Duplicate the first point so the chart has two points to draw a line from
// immediately, instead of showing the single-value fallback until the next poll.
const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem, currentItem];
const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE);
return (
@@ -225,20 +235,27 @@ const SystemCharts = ({
const showNetwork = visibleCharts.includes("network");
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length;
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
const chartColumns = useMemo(
() => (isAdvanced ? getAdvancedChartColumns(width) : 1),
[isAdvanced, width],
);
const compactChartGap = 8;
const compactRowHeight = 56;
const chartHeight = isAdvanced
? 180
: `calc((100% - ${Math.max(0, chartCount - 1) * compactChartGap}px) / ${Math.max(1, chartCount)})`;
? 110
: Math.max(
28,
Math.min(compactRowHeight, (height - Math.max(0, chartCount - 1) * compactChartGap) / Math.max(1, chartCount)),
);
return (
<Stack gap={isAdvanced ? "xs" : compactChartGap} h={isAdvanced ? "auto" : height} miw={0}>
<Stack gap={isAdvanced ? "xs" : compactChartGap} h="auto" miw={0}>
{showTitle && (
<Text size="sm" fw={600} truncate="end">
{integrationName}
</Text>
)}
<SimpleGrid cols={chartColumns} spacing={isAdvanced ? "xs" : compactChartGap} style={{ flex: 1, minHeight: 0 }}>
<SimpleGrid cols={chartColumns} spacing={isAdvanced ? "xs" : compactChartGap}>
{chartCount === 0 && (
<Center h="100%">
<Text size="sm" c="dimmed">
@@ -11,7 +11,6 @@ import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import classes from "./widget-card-shell.module.css";
const getOverflow = (kind: WidgetKind, hasCustomCssClasses: boolean): CSSProperties => {
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" };
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" };
if (hasCustomCssClasses) return {};
return { overflowX: "hidden", overflowY: "auto" };