diff --git a/apps/docs/docs/widgets/app/index.mdx b/apps/docs/docs/widgets/app/index.mdx index ec62d862e..39b12e64a 100644 --- a/apps/docs/docs/widgets/app/index.mdx +++ b/apps/docs/docs/widgets/app/index.mdx @@ -1,30 +1,27 @@ --- -title: 'App' -description: 'Embeds an app into the board.' +title: "App" +description: "Embeds an app into the board." hide_title: true --- -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 { appWidget } from '.'; +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 { appWidget } from "."; - - + 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. +In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode. ### Screenshots -import githubApp from './img/github.png'; -import jellyseerrApp from './img/jellyseerr-description-tooltip.png'; -import piHoleApp from './img/pi-hole-ping-icon.png'; +import githubApp from "./img/github.png"; +import jellyseerrApp from "./img/jellyseerr-description-tooltip.png"; +import piHoleApp from "./img/pi-hole-ping-icon.png"; -
+
app github with-status-check app pi-hole with-status-check status-check-icon app jellyseerr with-description-tooltip @@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png'; ### Configuration + diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index c3104ad5b..c79629113 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -1,29 +1,26 @@ --- -title: 'Bookmarks' -description: 'Displays multiple app links' +title: "Bookmarks" +description: "Displays multiple app links" hide_title: true --- -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 { bookmarksWidget } from '.'; +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 { 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. +In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode. ### 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 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";
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png'; bookmarks grid with-title bookmarks vertical-list all-elements -
+
### Adding the widget ### Configuration + 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 6a12a528d..9f3e19da7 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -48,9 +48,9 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children } const settings = useSettings(); if (!session || !settings.enableRightClickOnWidgets) return <>{children}; - // App widgets are links — let the browser's native context menu (open in new tab, copy link) - // show in view mode. In edit mode keep the Homarr menu so items can still be edited/removed. - if (item.kind === "app" && !isEditMode) 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 ( diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index 74a891d07..38f5fd596 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -301,6 +301,8 @@ interface UseCssVariableConfiguration { isDynamic: boolean; } +const minimumResizeWidthDelta = 20; + /** * 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 @@ -329,13 +331,13 @@ const useCssVariableConfiguration = ({ const currentHeight = wrapperRef.current.clientHeight; const prevSize = prevSizeRef.current; - // Only skip recalculation when the width change is insignificant (< 20px) + // 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) < 20 && + Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta && (!isDynamic || currentHeight === prevSize.height) ) { return; diff --git a/packages/auth/events.ts b/packages/auth/events.ts index 1f7098e72..0a729fd52 100644 --- a/packages/auth/events.ts +++ b/packages/auth/events.ts @@ -32,22 +32,15 @@ export const createSignInEventHandler = (db: Database): Exclude { - if (typeof oidcGroups === "string") return [oidcGroups]; - if (Array.isArray(oidcGroups) && oidcGroups.every((group): group is string => typeof group === "string")) { - return oidcGroups; - } - if (oidcGroups !== undefined) { - logger.warn(`Invalid OIDC groups claim "${groupsKey}"`); - } - return undefined; - })(); - if (!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT && dbUser.provider === "oidc" && normalizedOidcGroups !== undefined) { - logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(normalizedOidcGroups)}`); - await synchronizeGroupsWithExternalForUserAsync(db, user.id, normalizedOidcGroups); + if ( + !env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT && + dbUser.provider === "oidc" && + profile && + groupsKey in profile && + Array.isArray(profile[groupsKey]) + ) { + logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`); + await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]); } // In ldap-authroization we return the groups from ldap, it's not typed. diff --git a/packages/auth/test/events.spec.ts b/packages/auth/test/events.spec.ts index a41f13744..c94df69b8 100644 --- a/packages/auth/test/events.spec.ts +++ b/packages/auth/test/events.spec.ts @@ -152,48 +152,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => { }); expect(dbGroupMembers?.groupId).toBe("1"); }); - test("should add membership when the groups claim is a single string", async () => { - // Arrange - const db = createDb(); - await createUserAsync(db, "oidc"); - await createGroupAsync(db); - const eventHandler = createSignInEventHandler(db); - - // Act - await eventHandler?.({ - user: { id: "1", name: "test" }, - profile: { preferred_username: "test", someRandomGroupsKey: "test" }, - account: null, - }); - - // Assert - const dbGroupMembers = await db.query.groupMembers.findFirst({ - where: eq(groupMembers.userId, "1"), - }); - expect(dbGroupMembers?.groupId).toBe("1"); - }); - test("should not change memberships for an invalid groups claim", async () => { - // Arrange - const db = createDb(); - await createUserAsync(db, "oidc"); - await createGroupAsync(db); - await db.insert(groupMembers).values({ userId: "1", groupId: "1" }); - const eventHandler = createSignInEventHandler(db); - - // Act - await eventHandler?.({ - user: { id: "1", name: "test" }, - profile: { preferred_username: "test", someRandomGroupsKey: 42 }, - account: null, - }); - - // Assert - const dbGroupMembers = await db.query.groupMembers.findMany({ - where: eq(groupMembers.userId, "1"), - columns: { groupId: true }, - }); - expect(dbGroupMembers).toEqual([{ groupId: "1" }]); - }); test("should remove group membership", async () => { // Arrange const db = createDb(); diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index d3edb6c5e..a9599e216 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -1,4 +1,4 @@ -import { ParseError, ResponseError } from "@homarr/common/server"; +import { ResponseError } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; @@ -163,20 +163,7 @@ export class BeszelIntegration extends Integration { throw new ResponseError(response); } - let data: BeszelAuthResponse; - try { - data = (await response.json()) as BeszelAuthResponse; - } catch (error) { - logger.warn("Failed to parse Beszel auth response JSON", { - integrationId: this.integration.id, - error: error instanceof Error ? error.message : String(error), - }); - throw new ErrorWithMetadata( - "Beszel authentication response was not valid JSON", - { integrationId: this.integration.id }, - { cause: error instanceof Error ? error : new Error(String(error)) }, - ); - } + const data = (await response.json()) as BeszelAuthResponse; const expiresAt = parseTokenExpiration(data.token) ?? undefined; // A token that has already lapsed by the time it reaches us means our clock and Beszel's @@ -621,19 +608,7 @@ export class BeszelIntegration extends Integration { return TestConnectionError.StatusResult({ status: response.status, url: response.url }); } - let data: BeszelAuthResponse; - try { - data = (await response.json()) as BeszelAuthResponse; - } catch (error) { - logger.warn("Failed to parse Beszel testing response JSON", { - error: error instanceof Error ? error.message : String(error), - }); - return TestConnectionError.ParseResult( - new ParseError("Beszel testing response was not valid JSON", { - cause: error instanceof Error ? error : new Error(String(error)), - }), - ); - } + const data = (await response.json()) as BeszelAuthResponse; if (!data.token) { return TestConnectionError.UnauthorizedResult(401); } diff --git a/packages/integrations/src/unraid/unraid-integration.ts b/packages/integrations/src/unraid/unraid-integration.ts index cbfcb3fc6..006282f3b 100644 --- a/packages/integrations/src/unraid/unraid-integration.ts +++ b/packages/integrations/src/unraid/unraid-integration.ts @@ -42,7 +42,10 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit // 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. - const cpuUtilizationNormalized = cpuCount > 0 ? cpuUtilization / cpuCount : 0; + let cpuUtilizationNormalized = 0; + if (cpuCount > 0) { + cpuUtilizationNormalized = cpuUtilization / cpuCount; + } const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0); const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);