Compare commits

...
Author SHA1 Message Date
ajnart 2d410b43f2 fix(settings): move board access control out of the nested form
The unified board settings form wrapped BoardAccessSettings (which renders
its own <form> via the user/group access forms) inside the outer board
settings <form>. Nested <form> elements are invalid HTML and cause React
hydration to fail (error #418), so the access save button's onSubmit never
runs and clicking 'Save group permission' only reloads the page without
persisting.

Fix: keep the six settings sections in the outer <form>, and render the
Access control and Danger zone sections outside it as siblings. This restores
the independent mutation/forms for access permissions, matching the original
intent of the unified settings form.

Closes #6653
2026-08-22 12:19:35 +02:00
Thomas aca3b5d861 fix: correct Unraid CPU, link context menus, and board resizing (#6510)
Fix Unraid logical CPU averaging, preserve native context menus for app and bookmark widgets in view mode, and prevent scrollbar-width GridStack resize oscillation.
2026-08-22 10:53:26 +02:00
6 changed files with 92 additions and 60 deletions

No files matched your search

+13 -15
View File
@@ -1,30 +1,27 @@
--- ---
title: 'App' title: "App"
description: 'Embeds an app into the board.' description: "Embeds an app into the board."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { appWidget } from '.'; import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
<WidgetHeader
widget={appWidget}
categories={['Links', 'Apps']}
/>
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
It also supports a status check to indicate whether Homarr can reach the application or website. It also supports a status check to indicate whether Homarr can reach the application or website.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots ### Screenshots
import githubApp from './img/github.png'; import githubApp from "./img/github.png";
import jellyseerrApp from './img/jellyseerr-description-tooltip.png'; import jellyseerrApp from "./img/jellyseerr-description-tooltip.png";
import piHoleApp from './img/pi-hole-ping-icon.png'; import piHoleApp from "./img/pi-hole-ping-icon.png";
<div className='flex gap-1'> <div className="flex gap-1">
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} /> <img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} /> <img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} /> <img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
@@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png';
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={appWidget.configuration} /> <WidgetConfig configuration={appWidget.configuration} />
+14 -16
View File
@@ -1,29 +1,26 @@
--- ---
title: 'Bookmarks' title: "Bookmarks"
description: 'Displays multiple app links' description: "Displays multiple app links"
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { bookmarksWidget } from '.'; import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
<WidgetHeader
widget={bookmarksWidget}
categories={['Links', 'Apps']}
/>
The bookmarks widget allows you to display multiple app links in a single widget. 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. You can add links to your favorite applications, websites, or any other resources you want quick access to.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots ### Screenshots
import gridIconsOnly from './img/grid-icons-only.png'; import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from './img/grid-with-title.png'; import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from './img/vertical-list-all.png'; import verticalListAll from "./img/vertical-list-all.png";
import vericalListSimple from './img/vertical-list-simple.png'; import vericalListSimple from "./img/vertical-list-simple.png";
<div className='flex gap-1'> <div className='flex gap-1'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png';
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} /> <img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} /> <img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
</div>
</div>
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={bookmarksWidget.configuration} /> <WidgetConfig configuration={bookmarksWidget.configuration} />
@@ -178,35 +178,37 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const isPending = savePartialSettings.isPending || saveLayouts.isPending; const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <Stack gap="xl">
<Stack gap="xl"> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<GeneralSettingsContent board={board} form={form} /> <Stack gap="xl">
<LayoutSettingsContent form={form} /> <GeneralSettingsContent board={board} form={form} />
<BackgroundSettingsContent form={form} /> <LayoutSettingsContent form={form} />
<ColorSettingsContent form={form} /> <BackgroundSettingsContent form={form} />
<CustomCssSettingsContent form={form} /> <ColorSettingsContent form={form} />
<BehaviorSettingsContent form={form} /> <CustomCssSettingsContent form={form} />
<BehaviorSettingsContent form={form} />
{hasFullAccess && ( {form.isDirty() && (
<SectionCard title={tSection("access.title")}> <UnsavedChangesBar>
<BoardAccessSettings board={board} initialPermissions={permissions} /> <Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
</SectionCard> {t("common.action.discard")}
)} </Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />} {hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
)}
{form.isDirty() && ( {hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
<UnsavedChangesBar> </Stack>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
); );
}; };
@@ -44,9 +44,13 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings(); const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>; if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
if (isLinkWidget && !isEditMode) return <>{children}</>;
return ( return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}> <WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
@@ -301,6 +301,8 @@ interface UseCssVariableConfiguration {
isDynamic: boolean; isDynamic: boolean;
} }
const minimumResizeWidthDelta = 20;
/** /**
* This hook is used to configure the css variables for the gridstack * This hook is used to configure the css variables for the gridstack
* Those css variables are used to define the size of the gridstack items * Those css variables are used to define the size of the gridstack items
@@ -319,9 +321,30 @@ const useCssVariableConfiguration = ({
columnCount, columnCount,
isDynamic, isDynamic,
}: UseCssVariableConfiguration) => { }: UseCssVariableConfiguration) => {
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
const onResize = useCallback(() => { const onResize = useCallback(() => {
if (!wrapperRef.current) return; if (!wrapperRef.current) return;
if (!gridRef.current) return; if (!gridRef.current) return;
const currentWidth = wrapperRef.current.clientWidth;
const currentHeight = wrapperRef.current.clientHeight;
const prevSize = prevSizeRef.current;
// Only skip recalculation when the width change is insignificant
// and, for dynamic sections, the height is unchanged. This prevents infinite
// feedback loops when the vertical scrollbar appears/disappears, causing
// clientWidth to oscillate by ~15px, while still reacting to height changes.
if (
prevSize !== null &&
Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta &&
(!isDynamic || currentHeight === prevSize.height)
) {
return;
}
prevSizeRef.current = { width: currentWidth, height: currentHeight };
handleResizeChange( handleResizeChange(
wrapperRef.current, wrapperRef.current,
gridRef.current, gridRef.current,
@@ -37,9 +37,16 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
const systemInfo = await this.getSystemInformationAsync(); const systemInfo = await this.getSystemInformationAsync();
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0); const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.info.cpu.cores; const cpuCount = systemInfo.metrics.cpu.cpus.length;
// Each entry in cpus[] is one logical CPU (thread). Dividing the summed per-thread
// utilization by the thread count (not physical cores) gives the correct average and
// avoids over-reporting on SMT systems. Guard against zero to avoid Infinity/NaN.
let cpuUtilizationNormalized = 0;
if (cpuCount > 0) {
cpuUtilizationNormalized = cpuUtilization / cpuCount;
}
// We use "info" object instead of the stats since this is the exact amount the kernel sees, which is what Unraid displays.
const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0); const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0);
const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100); const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);
const uptime = dayjs(systemInfo.info.os.uptime); const uptime = dayjs(systemInfo.info.os.uptime);
@@ -47,7 +54,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
return { return {
version: systemInfo.info.os.release, version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand, cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilization / cpuCount, cpuUtilization: cpuUtilizationNormalized,
memUsedInBytes: usedMemory, memUsedInBytes: usedMemory,
memAvailableInBytes: totalMemory - usedMemory, memAvailableInBytes: totalMemory - usedMemory,
uptime: dayjs().diff(uptime, "seconds"), uptime: dayjs().diff(uptime, "seconds"),