Compare commits

..
Author SHA1 Message Date
ajnart 1f8d12c19e fix(widgets): use readable onboarding sizes 2026-08-29 06:30:19 +02:00
Bergasha 10ba298233 fix(board): beszel & calendar scale fix (#6737)
* fix(calendar): add cell spacing and scale day content with widget size

* fix(beszel): stop chart panels from leaving blank grid columns and size charts to fit the widget

* fix(widgets): address CodeRabbit review on calendar/beszel scaling PR

- beszel: account for the SimpleGrid row gap and the Stack gap between
  the controls row and the grid in the available-height budget, so a
  two-row layout no longer overflows the widget.
- calendar: memoize the day-cell scale lookup instead of recomputing
  it on every render.
- calendar: don't render the notification indicator row when none of
  a day's events have an indicatorColor, which was shifting the date
  number up with no visible dot to show for it.

Skipped one finding: the claim that .day's absolute inset:0 covers
monthCell's padding. It doesn't - the containing block for an
absolutely positioned element is the padding edge of its positioned
ancestor per the CSS spec, so the padding correctly insets .day. This
was also visually verified in the browser while building the fix.

* fix(beszel): don't treat availableHeight=0 as omitted in computeChartHeight

A falsy check on availableHeight also caught the legitimate value 0
(widget shorter than its own padding+controls), falling back to the
full 180px height instead of clamping to MIN_CHART_HEIGHT - the exact
overflow this height budget was meant to prevent.
2026-08-29 11:02:10 +10:00
Bergasha 7855278ed1 fix(board): give AppShell root a viewport-height floor (#6736)
* fix(board): give AppShell root a viewport-height floor when a background is set

The board canvas sizes itself to its content, and AppShell runs in
'static' mode so <main> doesn't force a 100dvh minimum either. That
means a board's background image only covers as much height as the
content needs - collapsing a container (or any board with short
content) leaves flat page background showing below it instead of the
background continuing to the bottom of the viewport, looking like a
big empty void.

Root-caused via a local repro: the grid/reflow system was already
shrinking the collapsed container's own space correctly (verified the
live-measured canvas height matched exactly, not the stale debug
attribute) - the void was purely a background-sizing issue on the
AppShell root, unrelated to the collapse/layout logic itself.

* docs(boards): document background minimum-height behavior
2026-08-29 09:26:15 +10:00
Thomas aa179f1ec0 fix: decouple responsive layout roles from breakpoints
* fix: rework responsive layout breakpoints

* fix: keep widget edit overlays visible

* fix: inset widget edit action

* fix: use valid widget inset token
2026-08-28 17:11:51 +02:00
Bergasha f455e71be5 fix(board): remove per-widget refresh indicator (#6731)
The small refresh-circle overlay that appeared in a widget's top-right
corner during a background refetch wasn't adding useful signal and
just added visual noise. Drop the isWidgetRefreshing detection, the
indicator markup/CSS, and the now-dead query-key/runtime-query
plumbing that only existed to power it.

Updated the lazy-widgets e2e test to drop its assertions on the now
-removed [data-widget-refreshing] marker; the behavior it was really
verifying (cached content stays visible during a background refetch,
which completes cleanly) is still covered by the existing network
-level checks in the same test.
2026-08-28 17:36:41 +10:00
Thomas f221a5b4d3 fix: respect mobile header and widget interactions (#6730) 2026-08-28 08:31:41 +02:00
Bergasha c18fa8cf77 fix(widgets): replace system-resources chart tooltip with Mantine (#6729)
* fix(widgets): replace system-resources chart tooltip with Mantine Tooltip.Floating
2026-08-28 15:08:22 +10:00
Bergasha 5f572f1b92 fix(board): reserve and scale space for collapsible container header (#6727) 2026-08-28 10:59:48 +10:00
Bergasha a7aa89fafe feat(board+clock): fix item radius zoom, add clock day/night weather colour (#6726) 2026-08-28 10:22:48 +10:00
Thomas e723cbe6dd Merge pull request #6725 from homarr-labs/codex/fix-demo-read-only
fix(cli): make demo mode writable
2026-08-27 22:45:49 +02:00
ajnart 411e75e799 fix(cli): make demo mode writable 2026-08-27 22:41:23 +02:00
Thomas 0553d60a58 fix: prevent widget move registry crash (#6717) 2026-08-27 14:40:00 +02:00
Thomas 7da2e10064 fix: address release v2 review findings (#6708) 2026-08-27 13:59:42 +02:00
Thomas a002b91add feat: reduce disruptive UI confirmations (#6688)
* feat: reduce disruptive UI confirmations

* fix: keep destructive confirmations red
2026-08-27 12:01:36 +02:00
87 changed files with 1206 additions and 1259 deletions

No files matched your search

+13 -7
View File
@@ -320,8 +320,12 @@ jobs:
publish-preview: publish-preview:
name: Publish multi-platform preview image name: Publish multi-platform preview image
needs: [preview-amd64, preview-arm64] needs: [container, preview-amd64, preview-arm64]
if: | if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
( (
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -375,9 +379,6 @@ jobs:
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: | run: |
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}") tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
fi
docker buildx imagetools create "${tags[@]}" \ docker buildx imagetools create "${tags[@]}" \
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \ "${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
"${PREVIEW_IMAGE}@${ARM64_DIGEST}" "${PREVIEW_IMAGE}@${ARM64_DIGEST}"
@@ -386,9 +387,6 @@ jobs:
if: "!env.ACT" if: "!env.ACT"
run: | run: |
tags=("${PREVIEW_TAG}") tags=("${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=("pr-6545")
fi
for tag in "${tags[@]}"; do for tag in "${tags[@]}"; do
image="${PREVIEW_IMAGE}:${tag}" image="${PREVIEW_IMAGE}:${tag}"
manifest="$(docker buildx imagetools inspect "$image" --raw)" manifest="$(docker buildx imagetools inspect "$image" --raw)"
@@ -435,6 +433,14 @@ jobs:
core.info(`No package version found with tag "${tag}"`); core.info(`No package version found with tag "${tag}"`);
return; return;
} }
const targetTags = target.metadata?.container?.tags ?? [];
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
if (extraTags.length > 0) {
core.setFailed(
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
);
return;
}
await github.rest.packages.deletePackageVersionForOrg({ await github.rest.packages.deletePackageVersionForOrg({
package_type: "container", package_type: "container",
package_name: packageName, package_name: packageName,
+3 -1
View File
@@ -113,7 +113,9 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Set desktop and mobile home boards for the current user - Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior - Configure global board defaults such as public home boards and status behavior
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
+12 -8
View File
@@ -133,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling. The complete canvas always fits the available width and never adds horizontal board scrolling.
- **Mobile** starts at `0px` and uses three columns on new boards. - **Mobile** starts at `0px` and uses three columns on new boards.
- **Base** is the widest layout and starts at `768px` on new boards. - **Base** is the main layout and starts at `768px` on new boards.
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
their breakpoints. unused breakpoint above Mobile.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails. available breakpoint, and you can then change the breakpoint, columns, and rails.
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions. becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
Duplicate breakpoints are normalized without discarding saved positions.
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept. Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
@@ -182,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### Breakpoint #### Breakpoint
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. `0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
eligible layout with the highest breakpoint, regardless of its role.
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
fallback to select a layout. fallback to select a layout.
@@ -271,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the
### Background ### Background
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
#### Background image URL #### Background image URL
Homarr can display a background image behind apps and widgets. Homarr can display a background image behind apps and widgets.
@@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards.
You can set the default color scheme for users without their own color scheme. You can set the default color scheme for users without their own color scheme.
## Branding
Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings.
### Identity
- **Application name** replaces the default Homarr name in page titles and branded surfaces.
- **Logo** is used in the header and other application branding.
- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo.
### Colors and corners
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
### Sign-in page
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page.
## Culture ## Culture
### Default language ### Default language
+2
View File
@@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page - **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni - **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply.
### Configuration ### Configuration
<WidgetConfig configuration={iframeWidget.configuration} /> <WidgetConfig configuration={iframeWidget.configuration} />
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from "."; import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} /> <WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
{ {
integration: overseerrIntegration, integration: overseerrIntegration,
}, },
{
integration: seerrIntegration,
},
]} ]}
/> />
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon. - 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. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations. - A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide advancedView={false} /> <WidgetInteractionGuide advancedView={false} />
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from "."; import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} /> <WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
{ {
integration: overseerrIntegration, integration: overseerrIntegration,
}, },
{
integration: seerrIntegration,
},
]} ]}
/> />
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
### Interactions ### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible. - A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
<WidgetInteractionGuide advancedView={false} /> <WidgetInteractionGuide advancedView={false} />
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours. including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected. The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+1
View File
@@ -72,6 +72,7 @@
"@rexa-developer/tiks": "catalog:", "@rexa-developer/tiks": "catalog:",
"@scalar/api-reference-react": "catalog:", "@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:", "@tabler/icons-react": "catalog:",
"@tanstack/query-sync-storage-persister": "catalog:",
"@tanstack/react-devtools": "catalog:", "@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-devtools": "catalog:",
@@ -1,20 +1,17 @@
import { dehydrate, QueryClient } from "@tanstack/react-query"; import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client"; import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { import {
createSessionQueryPersister, createSessionQueryPersistence,
getQueryPersistenceStorageKey, getQueryPersistenceStorageKey,
queryPersistenceBuster, queryPersistenceBuster,
queryPersistenceMaxDataBytes,
shouldPersistDashboardQuery, shouldPersistDashboardQuery,
} from "./query-persistence"; } from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => { const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data, { updatedAt }); queryClient.setQueryData(queryKey, data);
const query = queryClient.getQueryCache().find({ queryKey }); const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present"); if (!query) throw new Error("Expected query to be present");
return { query, queryClient }; return { query, queryClient };
@@ -37,314 +34,98 @@ const createMemoryStorage = (): Storage => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
window.sessionStorage.clear();
}); });
describe("widget query persistence", () => { describe("dashboard query persistence", () => {
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => { test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]); const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" }); const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], { const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
value: "x".repeat(queryPersistenceMaxDataBytes + 1), const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
apiKey: "secret",
}); });
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const pendingClient = new QueryClient(); const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, { const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]), queryFn: () => Promise.resolve([]),
}); });
const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []);
failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" });
expect(shouldPersistDashboardQuery(widget.query)).toBe(true); expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true); expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false); expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false); expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false); expect(shouldPersistDashboardQuery(pending)).toBe(false);
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
}); });
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => { test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
const userAKey = getQueryPersistenceStorageKey("user-a");
window.sessionStorage.setItem(userAKey, "a");
createSessionQueryPersister("user-a").removeClient();
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
});
test("evicts every other scope so signing out cannot leave data behind", () => {
const userAKey = getQueryPersistenceStorageKey("user-a");
const anonymousKey = getQueryPersistenceStorageKey(null);
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(anonymousKey, "anonymous");
window.sessionStorage.setItem("unrelated", "kept");
createSessionQueryPersister("user-b");
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
});
test("never restores another session scope", async () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
userAPersister.flush();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
});
test("stops persisting once the provider tears the cache down", async () => {
const storage = createMemoryStorage();
const persister = createSessionQueryPersister("user-a", storage);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
persister.stop();
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("removes the cache instead of storing an empty one", () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
window.sessionStorage.setItem(key, "stale");
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const scope = "user-a"; const storage = createMemoryStorage();
const key = getQueryPersistenceStorageKey(scope); const userA = createSessionQueryPersistence("user-a", storage);
const persister = createSessionQueryPersister(scope); const userB = createSessionQueryPersistence("user-b", storage);
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"), startsAt: new Date("2026-08-22T12:00:00.000Z"),
}); });
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" }); const cachedQuery = queryClient.getQueryCache().find({
expect(shouldPersistDashboardQuery(query)).toBe(true); queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
});
if (!cachedQuery) throw new Error("Expected cached query to be present");
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
queryClient.getMutationCache().build(
queryClient,
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
{
context: undefined,
data: undefined,
error: null,
failureCount: 0,
failureReason: null,
isPaused: true,
status: "pending",
submittedAt: Date.now(),
variables: { apiKey: "secret" },
},
);
const persistedClient: PersistedClient = { const persistedClient: PersistedClient = {
timestamp: Date.now(), timestamp: Date.now(),
buster: queryPersistenceBuster, buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), clientState: dehydrate(queryClient, userA.dehydrateOptions),
}; };
persister.persistClient(persistedClient); expect(persistedClient.clientState.mutations).toHaveLength(0);
persister.flush();
const restored = await persister.restoreClient(); await userA.persister.persistClient(persistedClient);
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined; await vi.advanceTimersByTimeAsync(1_000);
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
const restored = await userA.persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
expect(restored?.clientState.queries[0]?.state.status).toBe("success"); expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull(); expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true); expect(await userB.persister.restoreClient()).toBeUndefined();
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
window.sessionStorage.setItem(key, "not-valid-superjson"); await userA.persister.removeClient();
expect(persister.restoreClient()).toBeUndefined(); expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
expect(window.sessionStorage.getItem(key)).toBeNull();
}); });
test("sanitizes structurally valid but unsafe stored state before hydration", async () => { test("treats unavailable session storage as an empty cache", async () => {
const scope = "user-a"; const blockedStorage = createMemoryStorage();
const key = getQueryPersistenceStorageKey(scope); blockedStorage.getItem = () => {
const { queryClient } = createSuccessfulQuery(
[["widget", "calendar", "findAllEvents"], { type: "query" }],
[{ title: "safe" }],
);
const validQuery = dehydrate(queryClient).queries[0];
if (!validQuery) throw new Error("Expected a dehydrated query");
window.sessionStorage.setItem(
key,
stringify({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [{ state: { status: "pending" } }],
queries: [
{
...validQuery,
state: {
...validQuery.state,
error: new Error("private refresh error"),
fetchStatus: "fetching",
status: "error",
},
},
{
...validQuery,
queryHash: "unrelated",
queryKey: [["board", "getBoardByName"], { type: "query" }],
},
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
{
...validQuery,
queryHash: "missing-timestamp",
state: { ...validQuery.state, dataUpdatedAt: undefined },
},
],
},
}),
);
const restored = await createSessionQueryPersister(scope).restoreClient();
expect(restored?.clientState.mutations).toEqual([]);
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
});
test.each([
["expired", 1, queryPersistenceBuster],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
const scope = "user-a";
const persister = createSessionQueryPersister(scope);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
persister.flush();
const restoredClient = new QueryClient();
await persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: queryPersistenceBuster,
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry writes the cache after evicting the oldest query", async () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
const storage = createMemoryStorage();
const setItem = storage.setItem.bind(storage);
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
const client = parse<PersistedClient>(value);
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
setItem(key, value);
});
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient(persistedClient);
persister.flush();
expect(setItemSpy).toHaveBeenCalledTimes(2);
const restored = await persister.restoreClient();
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
});
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
const setItem = vi.spyOn(storage, "setItem");
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(latest.queryClient),
});
expect(setItem).not.toHaveBeenCalled();
persister.flush();
expect(setItem).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(0);
expect(setItem).toHaveBeenCalledOnce();
const restored = await persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
throw new DOMException("blocked", "SecurityError");
},
removeItem: () => {
throw new DOMException("blocked", "SecurityError");
},
setItem: () => {
throw new DOMException("blocked", "SecurityError");
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
expect(persister.restoreClient()).toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError"); throw new DOMException("blocked", "SecurityError");
}); };
blockedStorage.removeItem = () => {
throw new DOMException("blocked", "SecurityError");
};
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow(); const persistence = createSessionQueryPersistence("blocked", blockedStorage);
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined(); expect(await persistence.persister.restoreClient()).toBeUndefined();
expect(() => persistence.persister.removeClient()).not.toThrow();
}); });
}); });
@@ -1,13 +1,12 @@
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { removeOldestQuery } from "@tanstack/react-query-persist-client"; import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson"; import { parse, stringify } from "superjson";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; export const queryPersistenceBuster = "v6-dashboard-data";
export const queryPersistenceMaxDataBytes = 256 * 1024;
export const queryPersistenceBuster = "v2-session-superjson";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
@@ -16,95 +15,33 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
interface PersistableQuery { interface PersistableQuery {
queryKey: QueryKey; queryKey: QueryKey;
state: { state: { data: unknown; status: string };
data: unknown;
dataUpdatedAt: number;
};
} }
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
const textEncoder = new TextEncoder();
const budgetVerdictCache = new WeakMap<object, boolean>();
const measureFitsBudget = (data: unknown) => {
try {
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
} catch {
return false;
}
};
const fitsStorageBudget = (data: unknown) => {
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
const cached = budgetVerdictCache.get(data);
if (cached !== undefined) return cached;
const verdict = measureFitsBudget(data);
budgetVerdictCache.set(data, verdict);
return verdict;
};
const holdsDashboardData = (query: PersistableQuery) =>
query.state.data !== undefined &&
Number.isFinite(query.state.dataUpdatedAt) &&
query.state.dataUpdatedAt > 0 &&
isWidgetDataQueryKey(query.queryKey);
export const shouldPersistDashboardQuery = (query: PersistableQuery) => export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
holdsDashboardData(query) && fitsStorageBudget(query.state.data); query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; const serializePersistedClient = (client: PersistedClient) =>
stringify({
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ ...client,
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
isInvalidated: true,
status: "success",
},
});
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
return holdsDashboardData(value as unknown as PersistableQuery);
};
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
return {
timestamp: value.timestamp as number,
buster: value.buster,
clientState: { clientState: {
mutations: [], ...client.clientState,
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery), queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
}, },
}; });
};
const getSessionStorage = (): Storage | undefined => { const getSessionStorage = () => {
if (typeof window === "undefined") return undefined; if (typeof window === "undefined") return undefined;
try { try {
@@ -114,113 +51,47 @@ const getSessionStorage = (): Storage | undefined => {
} }
}; };
const removeStorageItem = (storage: Storage | undefined, key: string) => { const protectStorage = (storage: Storage | undefined) => {
try { if (!storage) return undefined;
storage?.removeItem(key);
} catch {
return undefined;
}
};
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
if (!storage) return;
try {
const foreignKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const storedKey = storage.key(index);
if (storedKey === null || storedKey === activeKey) continue;
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
}
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
} catch {
return undefined;
}
};
export const createSessionQueryPersister = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
removeForeignScopes(storage, key);
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const persistNow = (client: PersistedClient) => {
if (!storage) return;
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
let errorCount = 0;
if (candidate.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return;
}
while (candidate) {
try {
storage.setItem(key, stringify(candidate));
return;
} catch (error) {
errorCount += 1;
candidate = removeOldestQuery({
persistedClient: candidate,
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
errorCount,
});
}
}
};
const clearPersistTimer = () => {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
};
const flush = () => {
clearPersistTimer();
const client = pendingClient;
pendingClient = undefined;
if (client) persistNow(client);
};
return { return {
persistClient(client) { getItem(key: string) {
if (!storage || stopped) return;
pendingClient = client;
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
restoreClient() {
try { try {
const storedValue = storage?.getItem(key); return storage.getItem(key);
if (!storedValue) return undefined; } catch {
return null;
const client = sanitizeRestoredClient(parse<unknown>(storedValue)); }
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) { },
removeStorageItem(storage, key); setItem(key: string, value: string) {
return undefined; storage.setItem(key, value);
} },
removeItem(key: string) {
return client; try {
storage.removeItem(key);
} catch { } catch {
removeStorageItem(storage, key);
return undefined; return undefined;
} }
}, },
removeClient() {
clearPersistTimer();
pendingClient = undefined;
removeStorageItem(storage, key);
},
flush,
stop() {
stopped = true;
clearPersistTimer();
pendingClient = undefined;
},
}; };
}; };
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
export const createSessionQueryPersistence = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersistence => ({
persister: createSyncStoragePersister({
storage: protectStorage(storage),
key: getQueryPersistenceStorageKey(scope),
serialize: serializePersistedClient,
deserialize: parse,
retry: removeOldestQuery,
}),
maxAge: queryCacheDefaultGcTimeMs,
buster: queryPersistenceBuster,
dehydrateOptions: {
shouldDehydrateMutation: () => false,
shouldDehydrateQuery: shouldPersistDashboardQuery,
},
});
@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { QueryClient } from "@tanstack/react-query"; import { QueryCache, QueryClient } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { import {
@@ -25,6 +25,8 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import {
dashboardSupportingQueryPolicies, dashboardSupportingQueryPolicies,
isTrpcForbiddenError,
isWidgetDataQueryKey,
queryCacheDefaultGcTimeMs, queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs, queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs, queryCacheDefaultStaleTimeMs,
@@ -36,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import { import { createSessionQueryPersistence } from "./query-persistence";
createSessionQueryPersister, import type { SessionQueryPersistence } from "./query-persistence";
queryPersistenceBuster,
queryPersistenceMaxAgeMs,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
import { createQueryRetry } from "./query-retry"; import { createQueryRetry } from "./query-retry";
const DevelopmentTools = const DevelopmentTools =
@@ -75,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession(); const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session); const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope); const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope)); const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
const handleScopeChange = useCallback(() => { const handleScopeChange = useCallback(() => {
void queryPersister.removeClient(); void queryPersistence.persister.removeClient();
reloadPage(); reloadPage();
}, [queryPersister]); }, [queryPersistence]);
return ( return (
<SessionQueryScopeGuard <SessionQueryScopeGuard
@@ -87,7 +84,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
currentScope={sessionQueryScope} currentScope={sessionQueryScope}
onScopeChange={handleScopeChange} onScopeChange={handleScopeChange}
> >
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider> <ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard> </SessionQueryScopeGuard>
); );
} }
@@ -96,8 +93,8 @@ const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({ const ScopedTRPCReactProvider = ({
children, children,
queryPersister, queryPersistence,
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => { }: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
const wsClient = useMemo( const wsClient = useMemo(
() => () =>
createWSClient({ createWSClient({
@@ -114,6 +111,13 @@ const ScopedTRPCReactProvider = ({
); );
const [queryClient] = useState(() => { const [queryClient] = useState(() => {
const client = new QueryClient({ const client = new QueryClient({
queryCache: new QueryCache({
onError(error, query) {
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
void queryPersistence.persister.removeClient();
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: queryCacheDefaultStaleTimeMs, staleTime: queryCacheDefaultStaleTimeMs,
@@ -152,15 +156,6 @@ const ScopedTRPCReactProvider = ({
return client; return client;
}); });
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
queryPersister.stop();
};
}, [queryPersister]);
useEffect(() => () => queryClient.clear(), [queryClient]); useEffect(() => () => queryClient.clear(), [queryClient]);
const trpcClient = useMemo(() => { const trpcClient = useMemo(() => {
@@ -211,15 +206,7 @@ const ScopedTRPCReactProvider = ({
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider <PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration> <ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />} {process.env.NODE_ENV === "development" && <DevelopmentTools />}
</PersistQueryClientProvider> </PersistQueryClientProvider>
@@ -40,6 +40,8 @@ interface Props {
saveSettingsAsync: () => Promise<FormValues | null>; saveSettingsAsync: () => Promise<FormValues | null>;
} }
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const tLayout = useI18n("layout"); const tLayout = useI18n("layout");
@@ -101,6 +103,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
return ( return (
<SectionCard title={tBoard("setting.section.layout.title")}> <SectionCard title={tBoard("setting.section.layout.title")}>
@@ -134,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Button> </Button>
</Group> </Group>
{form.values.layouts.map((layout, index) => { {displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
.map((candidate) => candidate.breakpoint);
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
return ( return (
<Fieldset <Fieldset
@@ -190,12 +191,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
} }
disabled={layout.role === "mobile"} disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }} styles={{ description: { color: "var(--mantine-color-text)" } }}
min={ min={layout.role === "mobile" ? 0 : 1}
layout.role === "base" max={32767}
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
: 1
}
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
/> />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
@@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonBaseProps { interface CopyAiPromptButtonProps {
rawResponse?: string | null; rawResponse?: string | null;
request?: string | null; request?: string | null;
documentationUrl?: string | null; documentationUrl?: string | null;
getDraft: () => CustomWidgetAiDraft;
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
} }
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
(
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
);
export const CopyAiPromptButton = ({ export const CopyAiPromptButton = ({
rawResponse, rawResponse,
draft,
getDraft, getDraft,
diagnostics,
getDiagnostics, getDiagnostics,
request, request,
documentationUrl, documentationUrl,
@@ -35,23 +28,14 @@ export const CopyAiPromptButton = ({
const t = useI18n("customWidget"); const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant(); const assistant = useOptionalHomarrAssistant();
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const handleAssistant = () => { const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt( const prompt = buildCustomWidgetAssistantPrompt(
undefined, undefined,
rawResponse, rawResponse,
readDraft(), getDraft(),
request, request,
documentationUrl, documentationUrl,
readDiagnostics(), getDiagnostics(),
); );
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return; if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
@@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({
const value = buildCustomWidgetAiPrompt( const value = buildCustomWidgetAiPrompt(
undefined, undefined,
rawResponse, rawResponse,
readDraft(), getDraft(),
request, request,
documentationUrl, documentationUrl,
readDiagnostics(), getDiagnostics(),
); );
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
showSuccessNotification({ showSuccessNotification({
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface ConfigMapsTableComponentProps { interface ConfigMapsTableComponentProps {
@@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery( const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialConfigMaps),
initialData: initialConfigMaps,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error"; import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery( const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialIngresses),
initialData: initialIngresses,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -0,0 +1,18 @@
interface QueryWithStatus {
state: {
status: string;
};
}
const getErrorRefetchInterval = (query: QueryWithStatus) => {
if (query.state.status === "error") return 30_000;
return false;
};
export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({
initialData,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: getErrorRefetchInterval,
});
@@ -15,6 +15,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface NamespacesTableComponentProps { interface NamespacesTableComponentProps {
@@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery( const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialNamespaces),
initialData: initialNamespaces,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error"; import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery( const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialNodes),
initialData: initialNodes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface PodsTableComponentProps { interface PodsTableComponentProps {
@@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
const { data } = clientApi.kubernetes.pods.getPods.useQuery( const { data } = clientApi.kubernetes.pods.getPods.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialPods),
initialData: initialPods,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface SecretsTableComponentProps { interface SecretsTableComponentProps {
@@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery( const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialSecrets),
initialData: initialSecrets,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -14,6 +14,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface ServicesTableComponentProps { interface ServicesTableComponentProps {
@@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
const { data } = clientApi.kubernetes.services.getServices.useQuery( const { data } = clientApi.kubernetes.services.getServices.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialServices),
initialData: initialServices,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface VolumesTableComponentProps { interface VolumesTableComponentProps {
@@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery( const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
{ contextId }, { contextId },
{ createKubernetesResourceQueryOptions(initialVolumes),
initialData: initialVolumes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
); );
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
@@ -11,16 +11,17 @@ import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps { interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null; preview: BoardPreviewData | null;
label: string; label: string;
fitFullLayout?: boolean; previewRowLimit?: number;
className?: string; className?: string;
} }
const maxThumbnailRows = 12; const compactBoardLayoutThumbnailRows = 12;
export const maxBoardLayoutThumbnailRows = 48;
export const BoardLayoutThumbnail = ({ export const BoardLayoutThumbnail = ({
preview, preview,
label, label,
fitFullLayout = false, previewRowLimit = compactBoardLayoutThumbnailRows,
className, className,
}: BoardLayoutThumbnailProps) => { }: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" "); const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
@@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({
} }
const elements = projectBoardLayout(preview, layout, layout); const elements = projectBoardLayout(preview, layout, layout);
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows; const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
const roots = preview.sections const roots = preview.sections
.filter((section) => section.kind === "empty") .filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id)); .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
@@ -94,9 +95,21 @@ export const BoardLayoutThumbnail = ({
}} }}
> >
{laneElements.map((element) => { {laneElements.map((element) => {
const renderedWidth = Math.min(element.width, columnCount - element.xOffset);
const renderedHeight = Math.min(element.height, rowCount - element.yOffset);
const nestedItems = const nestedItems =
element.type === "section" element.type === "section"
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id) ? elements.filter(
(candidate) =>
candidate.type === "item" &&
candidate.sectionId === element.id &&
candidate.xOffset >= 0 &&
candidate.xOffset < renderedWidth &&
candidate.yOffset >= 0 &&
candidate.yOffset < renderedHeight &&
candidate.width > 0 &&
candidate.height > 0,
)
: []; : [];
return ( return (
<span <span
@@ -104,16 +117,16 @@ export const BoardLayoutThumbnail = ({
aria-hidden aria-hidden
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`} className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
style={{ style={{
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`, gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`, gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
}} }}
> >
{element.type === "section" ? ( {element.type === "section" ? (
<span <span
className={classes.containerContents} className={classes.containerContents}
style={{ style={{
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
}} }}
> >
{nestedItems.map((nestedItem) => ( {nestedItems.map((nestedItem) => (
@@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({
style={{ style={{
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min( gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
nestedItem.width, nestedItem.width,
element.width - nestedItem.xOffset, renderedWidth - nestedItem.xOffset,
)}`, )}`,
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min( gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
nestedItem.height, nestedItem.height,
element.height - nestedItem.yOffset, renderedHeight - nestedItem.yOffset,
)}`, )}`,
}} }}
> >
@@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
import classes from "./board-switcher.module.css"; import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c"; export const boardSwitcherHotkey = "shift+c";
const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const;
export interface BoardSwitcherControls { export interface BoardSwitcherControls {
open: () => void; open: () => void;
@@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
data: boards = [], data: boards = [],
isPending, isPending,
isError, isError,
} = clientApi.board.getManageOverview.useQuery( } = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
{ fullPreview: true }, enabled: isOpen,
{ });
enabled: isOpen,
},
);
const switcherBoards = useMemo( const switcherBoards = useMemo(
() => [ () => boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
[boards, currentBoard?.id], [boards, currentBoard?.id],
); );
const filteredBoards = useMemo(() => { const filteredBoards = useMemo(() => {
@@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setSearch(""); setSearch("");
setActiveIndex(0); setActiveIndex(0);
}, []); }, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
const spotlightAction = useMemo( const spotlightAction = useMemo(
() => ({ () => ({
@@ -341,7 +336,7 @@ const getBoardSwitcherResults = ({
<BoardLayoutThumbnail <BoardLayoutThumbnail
preview={board.preview} preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })} label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
fitFullLayout previewRowLimit={boardSwitcherPreview.previewRowLimit}
/> />
</Card.Section> </Card.Section>
<Card.Section withBorder px="sm" py={6}> <Card.Section withBorder px="sm" py={6}>
@@ -1,21 +1,7 @@
.refreshIndicator {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 3;
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
pointer-events: none;
}
.settingsButton { .settingsButton {
position: absolute; position: absolute;
top: -34px; top: calc(var(--mantine-spacing-sm) / 2);
right: 14px; right: calc(var(--mantine-spacing-sm) / 2);
z-index: 201; z-index: 201;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@@ -66,7 +52,6 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.settingsButton { .settingsButton {
right: 4px;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import { Box, Button, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary"; import type { FallbackProps } from "react-error-boundary";
@@ -19,7 +19,6 @@ import { WidgetError } from "@homarr/widgets/errors";
import { import {
createWidgetRuntimeState, createWidgetRuntimeState,
getWidgetQueryKeys, getWidgetQueryKeys,
getWidgetRuntimeQueries,
supportsAdvancedFocus as definitionSupportsAdvancedFocus, supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition"; } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
@@ -40,7 +39,6 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu"; import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery"; import { removeWidgetDataQueries } from "./widget-query-recovery";
import { matchesWidgetItemQuery } from "./widget-query-scope";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), { const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false, ssr: false,
@@ -158,24 +156,6 @@ const LoadedBoardItemContent = ({
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }), () => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
[definition, item, settings], [definition, item, settings],
); );
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetRefreshing =
useIsFetching({
predicate: (query) =>
query.state.data !== undefined &&
matchesWidgetItemQuery(
query.queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options: menuItem.options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
}) > 0;
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null); const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null); const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus(); const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
@@ -343,11 +323,6 @@ const LoadedBoardItemContent = ({
/> />
</ErrorBoundary> </ErrorBoundary>
</Box> </Box>
{isWidgetRefreshing && (
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
<Loader size="xs" />
</Box>
)}
</WidgetCardShell> </WidgetCardShell>
); );
@@ -361,6 +336,7 @@ const LoadedBoardItemContent = ({
widgetStateRef={widgetStateRef} widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef} widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef} sourceRef={sourceRef}
disabled={isAdvanced}
> >
<Box <Box
ref={sourceRef} ref={sourceRef}
@@ -10,6 +10,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui"; import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition"; import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context"; import { useSectionContext } from "../sections/section-context";
@@ -21,7 +22,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
interface BoardItemMenuProps { interface BoardItemMenuProps {
item: SectionItem; item: SectionItem;
definition: WidgetDefinition; definition: WidgetDefinition;
previewDimensions: { width: number; height: number; scale?: number }; previewDimensions: WidgetPreviewDimensions;
resetErrorBoundary?: () => void; resetErrorBoundary?: () => void;
} }
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout"; import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd"; import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry"; import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements"; import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions"; import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
@@ -30,6 +31,7 @@ interface InnerProps extends OpenItemMoveModalInput {
board: Board; board: Board;
currentLayoutId: string; currentLayoutId: string;
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void; commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>;
} }
interface MoveTarget { interface MoveTarget {
@@ -57,8 +59,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const tLandmark = useI18n("board.landmark"); const tLandmark = useI18n("board.landmark");
const tContainer = useI18n("section.container"); const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize"); const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps; const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps;
const registeredGridEditors = useRegisteredGridEditors();
const minimumSize = useMemo( const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry), () => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry], [board, currentLayoutId, entry],
@@ -235,6 +236,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
export const useOpenItemMoveModal = () => { export const useOpenItemMoveModal = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const registeredGridEditors = useRegisteredGridEditors();
const { commitSectionGrids } = useGridLayoutActions(); const { commitSectionGrids } = useGridLayoutActions();
const { openModal } = useModalAction(ItemMoveModal); const { openModal } = useModalAction(ItemMoveModal);
@@ -245,9 +247,10 @@ export const useOpenItemMoveModal = () => {
board, board,
currentLayoutId, currentLayoutId,
commitSectionGrids, commitSectionGrids,
registeredGridEditors,
}); });
}, },
[board, commitSectionGrids, currentLayoutId, openModal], [board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
); );
}; };
@@ -25,6 +25,7 @@ import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definit
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation"; import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context"; import { useAdvancedFocus } from "../advanced-focus/context";
@@ -39,10 +40,11 @@ type ToggleOption = [key: string, option: { type: string; skipContextMenu?: bool
interface WidgetContextMenuProps { interface WidgetContextMenuProps {
item: SectionItem; item: SectionItem;
definition: WidgetDefinition; definition: WidgetDefinition;
previewDimensions: { width: number; height: number; scale?: number }; previewDimensions: WidgetPreviewDimensions;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef; widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>; sourceRef: RefObject<HTMLElement | null>;
disabled?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -52,6 +54,7 @@ export const WidgetContextMenu = ({
previewDimensions, previewDimensions,
widgetRuntimeRef, widgetRuntimeRef,
sourceRef, sourceRef,
disabled = false,
children, children,
}: WidgetContextMenuProps) => { }: WidgetContextMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
@@ -161,6 +164,7 @@ export const WidgetContextMenu = ({
}) ?? []; }) ?? [];
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
setMenuOpened(false);
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -229,6 +233,7 @@ export const WidgetContextMenu = ({
const handleOpenAdvancedFocus = useCallback(() => { const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return; if (!sourceRef.current) return;
setMenuOpened(false);
openAdvancedFocus(item.id, sourceRef.current, { openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget: restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current, sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
@@ -255,7 +260,7 @@ export const WidgetContextMenu = ({
opened={menuOpened} opened={menuOpened}
onChange={setMenuOpened} onChange={setMenuOpened}
> >
<Menu.ContextMenu>{children}</Menu.ContextMenu> <Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}> <WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
{canOpenAdvancedFocus && ( {canOpenAdvancedFocus && (
<> <>
@@ -49,12 +49,14 @@
} }
.widgetIcon { .widgetIcon {
width: var(--mantine-font-size-sm);
height: var(--mantine-font-size-sm);
flex-shrink: 0; flex-shrink: 0;
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
} }
.name { .name {
font-size: 11px; font-size: var(--mantine-font-size-xs);
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
@@ -94,7 +96,7 @@
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: var(--chip-background); background-color: var(--chip-background);
font-size: 10px; font-size: var(--mantine-font-size-xxs);
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -2,7 +2,8 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
overflow: clip; overflow-x: clip;
overflow-y: visible;
} }
.viewport[data-board-hydrated="false"] { .viewport[data-board-hydrated="false"] {
@@ -45,4 +46,13 @@
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1)); --mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1)); --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)); --mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
* above, just never ported over to radius. */
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
} }
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover, .board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within { .board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: 10; z-index: var(--homarr-z-index-widget-focus);
} }
.board-grid-entry[data-grid-preview="true"] { .board-grid-entry[data-grid-preview="true"] {
@@ -3,6 +3,22 @@
overflow: visible; overflow: visible;
} }
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
while growing during a drag happening anywhere else on the board - any container (not just
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
inside that section's DOM tree, so the stray value cascades straight through and silently
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
unset, not merely inherited. Reset it here so every container can never inherit a stray
value from anywhere above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
.viewport[data-section-kind="container"] {
--board-grid-drag-height: initial;
}
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always /* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity, mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
@@ -11,17 +27,14 @@
.viewport.scrollableViewport { .viewport.scrollableViewport {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped /* The grid content inside has a fixed pixel width matching this viewport's full
section's own GridPreviewLayer effect correctly avoids setting it on itself (see outer width (needed for the drag/resize math), so there's no room to reserve
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an for a scrollbar without it eating into - or overlapping - the content on
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself whichever side it renders. Hiding the native scrollbar track entirely (wheel/
while growing during a drag happening anywhere else on the board - a nested scrollable touch/drag scrolling still works) avoids that instead of trying to reserve
container sits inside that section's DOM tree, so the stray value cascades straight space for it. */
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in scrollbar-width: none;
section-grid.tsx, which only kicks in when the property is entirely unset, not merely -ms-overflow-style: none;
inherited. Reset it here so this element can never inherit a stray value from anywhere
above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
--board-grid-drag-height: initial; --board-grid-drag-height: initial;
} }
@@ -148,8 +148,25 @@ export const SectionGrid = ({
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset. // 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 effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0; const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
// A collapsible container's toggle bar (see the `containerToggle` Button in
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
// not like the grid's own un-compensated logical-pixel math, so it needs the same
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
// all, the header bar covers the first row of the container's content instead of sitting
// above it. Only the vertical axis is affected - the toggle spans the full width already.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible
? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale
: 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset; const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset; const viewportHeight = Math.max(
1,
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
);
// Items are positioned in a coordinate space sized to the *un-inset* column/row count // 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 // (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size. // deliberately smaller by outerCardInset to match the container's actual visible card size.
@@ -283,6 +300,7 @@ export const SectionGrid = ({
{ {
width: logicalWidth, width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`, height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`, "--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties } as CSSProperties
} }
@@ -317,6 +335,9 @@ export const SectionGrid = ({
); );
}; };
// Matches the collapsible container toggle's h={24} in container-section.tsx.
const CONTAINER_HEADER_HEIGHT = 24;
const INTERACTIVE_GRID_SELECTOR = const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]'; 'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]); const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
@@ -2,7 +2,7 @@
overflow: hidden; overflow: hidden;
} }
.header[data-desktop-visible="false"] { .header[data-visible="false"] {
display: none; display: none;
} }
@@ -73,39 +73,38 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.userMenu {
display: flex;
padding-inline: rem(6px);
}
.mobileContent { .mobileContent {
display: none; display: none;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs); gap: var(--mantine-spacing-xs);
width: 100%; width: 100%;
height: 100%; height: 100%;
padding-inline: var(--mantine-spacing-xs); padding-inline: var(--mantine-spacing-xs);
} }
.mobileIdentity, .mobileZone {
.mobileActions {
min-width: 0; min-width: 0;
overflow-x: auto;
scrollbar-width: none;
} }
.mobileIdentity { .mobileZone::-webkit-scrollbar {
flex: 1 1 auto; display: none;
overflow: hidden;
} }
.mobileIdentity > :last-child { .mobileZone[data-zone="left"] {
flex: 1 1 auto; justify-content: flex-start;
min-width: 0;
overflow: hidden;
} }
.mobileIdentity h2 { .mobileZone[data-zone="right"] {
overflow: hidden; justify-content: flex-end;
text-overflow: ellipsis; max-width: 72vw;
}
.mobileActions {
flex: 0 0 auto;
} }
.floatingControls { .floatingControls {
@@ -155,21 +154,12 @@
} }
@media (max-width: $mantine-breakpoint-sm) { @media (max-width: $mantine-breakpoint-sm) {
.header[data-desktop-visible="false"] {
display: block;
}
.desktopContent { .desktopContent {
display: none; display: none;
} }
.mobileContent { .mobileContent {
display: flex; display: grid;
}
.floatingControls,
.floatingControlsCorner {
display: none;
} }
} }
@@ -3,15 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } 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 { import { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconReplace,
IconRobot,
IconSettings,
IconSunMoon,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -81,7 +73,10 @@ export const ConfigurableHeader = ({
return ( return (
<BoardSwitcher> <BoardSwitcher>
{(boardSwitcher) => { {(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) => const renderItems = (
items: HeaderItem[],
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
) =>
items.map((item) => ( items.map((item) => (
<HeaderItem <HeaderItem
key={getHeaderItemKey(item)} key={getHeaderItemKey(item)}
@@ -95,8 +90,8 @@ export const ConfigurableHeader = ({
isAdmin={isAdmin} isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled} isDockerEnabled={isDockerEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined} board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay} searchDisplay={display.search}
logoDisplay={headerPreferences.logoDisplay} logoDisplay={display.logo}
boardSwitcher={boardSwitcher} boardSwitcher={boardSwitcher}
assistant={assistant} assistant={assistant}
openDockerModal={openDockerModal} openDockerModal={openDockerModal}
@@ -106,13 +101,19 @@ export const ConfigurableHeader = ({
/> />
)); ));
const desktopDisplay = {
search: headerPreferences.searchDisplay,
logo: headerPreferences.logoDisplay,
};
const mobileDisplay = { search: "icon", logo: "logo" } as const;
return ( return (
<> <>
<AppShellHeader <AppShellHeader
maw="100vw" maw="100vw"
zIndex="var(--homarr-z-index-board-header)" zIndex="var(--homarr-z-index-board-header)"
className={classes.header} className={classes.header}
data-desktop-visible={headerPreferences.visible} data-visible={headerPreferences.visible}
data-advanced-focus-background data-advanced-focus-background
data-app-shell-header data-app-shell-header
> >
@@ -120,37 +121,26 @@ export const ConfigurableHeader = ({
<div className={classes.content}> <div className={classes.content}>
<div className={classes.zone} data-zone="left"> <div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left)} {renderItems(headerPreferences.zones.left, desktopDisplay)}
</div> </div>
<div className={classes.zone} data-zone="center"> <div className={classes.zone} data-zone="center">
{renderItems(headerPreferences.zones.center)} {renderItems(headerPreferences.zones.center, desktopDisplay)}
</div> </div>
<div className={classes.zone} data-zone="right"> <div className={classes.zone} data-zone="right">
{renderItems(headerPreferences.zones.right)} {renderItems(headerPreferences.zones.right, desktopDisplay)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null} {actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div> </div>
</div> </div>
</div> </div>
<div className={classes.mobileContent}> <div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap"> <Group className={classes.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} /> {renderItems(headerPreferences.zones.left, mobileDisplay)}
</Group> </Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap"> <Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
{actions} {renderItems(headerPreferences.zones.center, mobileDisplay)}
{boardEditAction} {renderItems(headerPreferences.zones.right, mobileDisplay)}
{boardSettingsAction} {actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group> </Group>
</div> </div>
</AppShellHeader> </AppShellHeader>
@@ -298,7 +288,9 @@ const HeaderItem = ({
return ( return (
<Tooltip label={boardUnavailableLabel}> <Tooltip label={boardUnavailableLabel}>
<HeaderButton aria-label={boardUnavailableLabel} disabled> <HeaderButton aria-label={boardUnavailableLabel} disabled>
<IconLayoutDashboard size={20} stroke={1.5} /> <Avatar size={22} radius="sm">
?
</Avatar>
</HeaderButton> </HeaderButton>
</Tooltip> </Tooltip>
); );
@@ -416,14 +408,19 @@ const HeaderItem = ({
return ( return (
<TourTarget id="board-user-menu"> <TourTarget id="board-user-menu">
<UserButtonClient <div className={classes.userMenu}>
avatar={avatar} <UserButtonClient
isAdmin={isAdmin} avatar={avatar}
isDockerEnabled={isDockerEnabled} isAdmin={isAdmin}
boardSwitcher={boardSwitcher} isDockerEnabled={isDockerEnabled}
/> boardSwitcher={boardSwitcher}
/>
</div>
</TourTarget> </TourTarget>
); );
}; };
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?"; const getBoardInitial = (name: string) => {
const firstCharacter = Array.from(name.trim())[0] ?? "?";
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
};
@@ -54,11 +54,13 @@ export const LazySpotlight = () => {
}; };
}, []); }, []);
return isMounted || isPreloaded ? ( if (!isMounted && !isPreloaded) return null;
return (
<> <>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />} {isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />} {isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
<Spotlight /> {isMounted && <Spotlight />}
</> </>
) : null; );
}; };
+7 -1
View File
@@ -22,11 +22,17 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom); const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps(); const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings(); const { headerPreferences } = useSettings();
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 }; const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
return ( return (
<AppShell <AppShell
{...backgroundProps} {...backgroundProps}
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
// background image only covers as much height as the content needs, so collapsing a
// container short enough leaves flat page background showing below it instead of the
// background continuing to the bottom of the viewport.
mih={backgroundProps.bg ? "100dvh" : undefined}
header={hasHeader ? { height: headerHeight } : undefined} header={hasHeader ? { height: headerHeight } : undefined}
navbar={ navbar={
hasNavigation hasNavigation
@@ -6,6 +6,7 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
import { import {
IconBrandDocker, IconBrandDocker,
IconHome, IconHome,
IconLayoutDashboard,
IconLogin, IconLogin,
IconLogout, IconLogout,
IconReplace, IconReplace,
@@ -16,6 +17,8 @@ import {
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client"; import { signOut, useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions"; import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -46,6 +49,8 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
const t = useI18n("common.userAvatar.menu"); const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const session = useSession(); const session = useSession();
const board = useOptionalBoard();
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
const { logoutUrl } = useAuthContext(); const { logoutUrl } = useAuthContext();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
@@ -67,6 +72,15 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")} {t("homeBoard")}
</Menu.Item> </Menu.Item>
{board && boardPermissions?.hasChangeAccess && (
<Menu.Item
component={Link}
href={`/boards/${encodeURIComponent(board.name)}/settings`}
leftSection={<IconLayoutDashboard size="1rem" />}
>
{tBoard("action.settings")}
</Menu.Item>
)}
<Menu.Item <Menu.Item
leftSection={<IconReplace size="1rem" />} leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>} rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
-2
View File
@@ -163,7 +163,6 @@ describe("lazy widget application graph", () => {
}); });
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true); await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
await expect(cachedDownload).toBeVisible({ timeout: 5_000 }); await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
const refreshedDownloadsResponse = page.waitForResponse( const refreshedDownloadsResponse = page.waitForResponse(
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
@@ -171,7 +170,6 @@ describe("lazy widget application graph", () => {
releaseRefresh(); releaseRefresh();
releaseRefresh = undefined; releaseRefresh = undefined;
await refreshedDownloadsResponse; await refreshedDownloadsResponse;
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
expect(hydrationErrors).toEqual([]); expect(hydrationErrors).toEqual([]);
+20
View File
@@ -1,5 +1,16 @@
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
interface ForbiddenTrpcError {
data: { code: "FORBIDDEN" };
}
export const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => {
if (!isRecord(error) || !isRecord(error.data)) return false;
return error.data.code === "FORBIDDEN";
};
// RSC-prefetched data must survive the hydration pass without an immediate // RSC-prefetched data must survive the hydration pass without an immediate
// duplicate fetch. Widget-specific intervals still control live refreshes. // duplicate fetch. Widget-specific intervals still control live refreshes.
export const queryCacheDefaultStaleTimeMs = 30_000; export const queryCacheDefaultStaleTimeMs = 30_000;
@@ -8,6 +19,7 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5; export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const persistenceExcludedWidgetPaths = new Set(["widget.customApi.getData", "widget.customApi.queryRequest"]);
// Beszel system stats are large time-series payloads and were deliberately kept out of // Beszel system stats are large time-series payloads and were deliberately kept out of
// the persisted cache in #6289; live stats come from an SSE subscription instead. // the persisted cache in #6289; live stats come from an SSE subscription instead.
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]); const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
@@ -66,3 +78,11 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
return isWidgetDataTrpcPath(path.join(".")); return isWidgetDataTrpcPath(path.join("."));
}; };
export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
const joinedPath = path.join(".");
return isWidgetDataTrpcPath(joinedPath) && !persistenceExcludedWidgetPaths.has(joinedPath);
};
+12 -8
View File
@@ -86,9 +86,14 @@ interface BoardItemPlacementRectangle {
} }
const boardItemPlacementTails = new Map<string, Promise<void>>(); const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12; const defaultManageOverviewPreviewRows = 12;
const maxManageOverviewPreviewRows = 48;
const manageOverviewInputSchema = z const manageOverviewInputSchema = z
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() }) .object({
fullPreview: z.boolean().default(false),
previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(),
userId: z.string().optional(),
})
.optional(); .optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => { const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
@@ -311,6 +316,8 @@ export const boardRouter = createTRPCRouter({
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId); const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
const canViewAllBoards = getCanViewAllBoards(groupMemberships); const canViewAllBoards = getCanViewAllBoards(groupMemberships);
let previewRowLimit = input?.previewRowLimit ?? defaultManageOverviewPreviewRows;
if (input?.fullPreview) previewRowLimit = maxManageOverviewPreviewRows;
const dbBoards = await ctx.db.query.boards.findMany({ const dbBoards = await ctx.db.query.boards.findMany({
columns: { columns: {
@@ -388,7 +395,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, rootSectionIds), inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0), gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0), gte(itemLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), lt(itemLayouts.yOffset, previewRowLimit),
gt(itemLayouts.width, 0), gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0), gt(itemLayouts.height, 0),
), ),
@@ -413,7 +420,7 @@ export const boardRouter = createTRPCRouter({
inArray(sectionLayouts.parentSectionId, rootSectionIds), inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0), gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0), gte(sectionLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows), lt(sectionLayouts.yOffset, previewRowLimit),
gt(sectionLayouts.width, 0), gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0), gt(sectionLayouts.height, 0),
), ),
@@ -448,7 +455,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, previewContainerSectionIds), inArray(itemLayouts.sectionId, previewContainerSectionIds),
gte(itemLayouts.xOffset, 0), gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0), gte(itemLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), lt(itemLayouts.yOffset, previewRowLimit),
gt(itemLayouts.width, 0), gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0), gt(itemLayouts.height, 0),
), ),
@@ -2769,14 +2776,11 @@ export const boardLayoutsNeedRepair = (
) => { ) => {
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile"); const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base"); const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
const baseLayout = baseLayouts.at(0);
return ( return (
mobileLayouts.length !== 1 || mobileLayouts.length !== 1 ||
baseLayouts.length !== 1 || baseLayouts.length !== 1 ||
mobileLayouts.at(0)?.breakpoint !== 0 || mobileLayouts.at(0)?.breakpoint !== 0 ||
!baseLayout ||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
); );
}; };
@@ -84,15 +84,13 @@ export const getDockerReconciliationAsync = async (db: Database) => {
const appAmbiguous = const appAmbiguous =
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1)); !linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
const ambiguous = integrationAmbiguous || appAmbiguous; const ambiguous = integrationAmbiguous || appAmbiguous;
const state: DockerReconciliationState = integrationMoved const state = getReconciliationState({
? "moved" integrationMoved,
: linked linked,
? "linked" represented: Boolean(integration || app),
: integration || app recognized: Boolean(match),
? "represented" });
: match const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous);
? "newRecognized"
: "newApp";
return { return {
candidateKey: container.resourceId, candidateKey: container.resourceId,
@@ -103,11 +101,7 @@ export const getDockerReconciliationAsync = async (db: Database) => {
match, match,
urlCandidates, urlCandidates,
state, state,
nextAction: integrationAmbiguous nextAction,
? ("reviewIntegration" as const)
: appAmbiguous
? ("viewRepresentation" as const)
: nextActionForState(state),
representation: { representation: {
integration: integration integration: integration
? { ? {
@@ -147,87 +141,100 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
status: endpoint.status, status: endpoint.status,
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const), nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
})), })),
services: reconciliation.candidates.map((candidate) => ({ services: reconciliation.candidates.map((candidate) => {
key: candidate.candidateKey, const integration = candidate.representation.integration;
identity: { const app = candidate.representation.app;
endpointId: candidate.endpointId, const itemCount = integration?.itemCount;
nativeId: candidate.nativeId,
name: candidate.container.name, return {
integration: candidate.representation.integration, key: candidate.candidateKey,
app: candidate.representation.app, identity: {
}, endpointId: candidate.endpointId,
layers: [ nativeId: candidate.nativeId,
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const }, name: candidate.container.name,
{ integration,
layer: "integrationConfiguration" as const, app,
status: integrationHealthStatus(
candidate.state,
candidate.match?.kind,
Boolean(candidate.representation.integration),
),
nextAction:
candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.state === "newRecognized"
? ("setupIntegration" as const)
: ("none" as const),
}, },
{ layers: [
layer: "authentication" as const, { layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
status: "notObserved" as const, {
nextAction: candidate.representation.integration layer: "integrationConfiguration" as const,
? ("testConnection" as const) status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)),
: candidate.nextAction === "reviewIntegration" nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction),
? ("reviewIntegration" as const) },
: candidate.match {
? ("setupIntegration" as const) layer: "authentication" as const,
: ("none" as const), status: "notObserved" as const,
}, nextAction: getAuthenticationNextAction(
{ Boolean(integration),
layer: "apiRequest" as const, Boolean(candidate.match),
status: "notObserved" as const, candidate.nextAction,
nextAction: candidate.representation.integration ),
? ("openIntegrationDiagnostics" as const) },
: ("none" as const), {
}, layer: "apiRequest" as const,
{ status: "notObserved" as const,
layer: "appRepresentation" as const, nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const),
status: candidate.representation.app ? ("linked" as const) : ("missing" as const), },
nextAction: candidate.representation.app {
? ("none" as const) layer: "appRepresentation" as const,
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation" status: app ? ("linked" as const) : ("missing" as const),
? ("viewRepresentation" as const) nextAction: getAppRepresentationNextAction(
: ("createApp" as const), Boolean(app),
}, candidate.representation.signals.ambiguous,
{ candidate.nextAction,
layer: "widgetConfiguration" as const, ),
status: candidate.representation.integration },
? candidate.representation.integration.itemCount > 0 {
? ("linked" as const) layer: "widgetConfiguration" as const,
: ("unused" as const) status: getWidgetConfigurationStatus(itemCount),
: ("notApplicable" as const), nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const),
nextAction: },
candidate.representation.integration && candidate.representation.integration.itemCount === 0 {
? ("addWidget" as const) layer: "widgetQuery" as const,
: ("none" as const), status: "notObserved" as const,
}, nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const),
{ },
layer: "widgetQuery" as const, ],
status: "notObserved" as const, status: candidate.state,
nextAction: nextAction: candidate.nextAction,
candidate.representation.integration && candidate.representation.integration.itemCount > 0 };
? ("openBoard" as const) }),
: ("none" as const),
},
],
status: candidate.state,
nextAction: candidate.nextAction,
})),
}; };
}; };
const normalizeName = (value: string) => value.trim().toLowerCase(); const normalizeName = (value: string) => value.trim().toLowerCase();
interface ReconciliationStateInput {
integrationMoved: boolean;
linked: boolean;
represented: boolean;
recognized: boolean;
}
const getReconciliationState = ({
integrationMoved,
linked,
represented,
recognized,
}: ReconciliationStateInput): DockerReconciliationState => {
if (integrationMoved) return "moved";
if (linked) return "linked";
if (represented) return "represented";
if (recognized) return "newRecognized";
return "newApp";
};
const getCandidateNextAction = (
state: DockerReconciliationState,
integrationAmbiguous: boolean,
appAmbiguous: boolean,
) => {
if (integrationAmbiguous) return "reviewIntegration" as const;
if (appAmbiguous) return "viewRepresentation" as const;
return nextActionForState(state);
};
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => { const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
if (state === "newRecognized") return "setupIntegration"; if (state === "newRecognized") return "setupIntegration";
if (state === "newApp") return "createApp"; if (state === "newApp") return "createApp";
@@ -235,6 +242,42 @@ const nextActionForState = (state: DockerReconciliationState): DockerReconciliat
return "viewRepresentation"; return "viewRepresentation";
}; };
const getIntegrationConfigurationNextAction = (
state: DockerReconciliationState,
nextAction: DockerReconciliationNextAction,
) => {
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
if (state === "newRecognized") return "setupIntegration" as const;
return "none" as const;
};
const getAuthenticationNextAction = (
hasIntegration: boolean,
hasMatch: boolean,
nextAction: DockerReconciliationNextAction,
) => {
if (hasIntegration) return "testConnection" as const;
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
if (hasMatch) return "setupIntegration" as const;
return "none" as const;
};
const getAppRepresentationNextAction = (
hasApp: boolean,
ambiguous: boolean,
nextAction: DockerReconciliationNextAction,
) => {
if (hasApp) return "none" as const;
if (ambiguous && nextAction === "viewRepresentation") return "viewRepresentation" as const;
return "createApp" as const;
};
const getWidgetConfigurationStatus = (itemCount: number | undefined) => {
if (itemCount === undefined) return "notApplicable" as const;
if (itemCount > 0) return "linked" as const;
return "unused" as const;
};
const integrationHealthStatus = ( const integrationHealthStatus = (
state: DockerReconciliationState, state: DockerReconciliationState,
kind: IntegrationKind | undefined, kind: IntegrationKind | undefined,
@@ -242,5 +285,6 @@ const integrationHealthStatus = (
) => { ) => {
if (!kind) return "notApplicable" as const; if (!kind) return "notApplicable" as const;
if (state === "moved") return "changed" as const; if (state === "moved") return "changed" as const;
return hasIntegration ? ("configured" as const) : ("missing" as const); if (hasIntegration) return "configured" as const;
return "missing" as const;
}; };
@@ -14,6 +14,7 @@ import { dockerRouter } from "../../docker/docker-router";
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn()); const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true)); const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
const invalidateDockerCacheMock = vi.hoisted(() => vi.fn()); const invalidateDockerCacheMock = vi.hoisted(() => vi.fn());
const resetDockerSingletonMock = vi.hoisted(() => vi.fn());
const getDockerDataAsyncMock = vi.hoisted(() => const getDockerDataAsyncMock = vi.hoisted(() =>
vi.fn< vi.fn<
() => Promise<{ () => Promise<{
@@ -62,7 +63,7 @@ vi.mock("@homarr/docker", () => ({
}, },
}, },
], ],
reset: () => undefined, reset: resetDockerSingletonMock,
}, },
})); }));
vi.mock("@homarr/request-handler/docker", () => ({ vi.mock("@homarr/request-handler/docker", () => ({
@@ -501,6 +502,7 @@ test("invalidates the shared Docker inventory on explicit refresh", async () =>
await createAdminCaller().refreshInventory(); await createAdminCaller().refreshInventory();
expect(invalidateDockerCacheMock).toHaveBeenCalledOnce(); expect(invalidateDockerCacheMock).toHaveBeenCalledOnce();
expect(resetDockerSingletonMock).toHaveBeenCalledOnce();
}); });
test("projects persisted service layers without inventing runtime health", async () => { test("projects persisted service layers without inventing runtime health", async () => {
+4 -1
View File
@@ -50,7 +50,10 @@ export const immichRouter = createTRPCRouter({
) )
.concat(createImmichIntegrationMiddleware("query")) .concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit); if (ctx.integration.kind === "mock") {
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
return mockWidgetData.immichAlbums.slice(0, input.limit);
}
const innerHandler = immichAlbumsRequestHandler.handler( const innerHandler = immichAlbumsRequestHandler.handler(
{ ...ctx.integration, kind: "immich" }, { ...ctx.integration, kind: "immich" },
{ {
+2 -1
View File
@@ -6,7 +6,8 @@ import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness
const harnessCommandNames = new Set(["harness", "local-harness"]); const harnessCommandNames = new Set(["harness", "local-harness"]);
function isHarnessInvocation(argv: string[]): boolean { function isHarnessInvocation(argv: string[]): boolean {
return argv.slice(2).some((argument) => harnessCommandNames.has(argument)); const command = argv[2];
return command !== undefined && harnessCommandNames.has(command);
} }
async function main(): Promise<void> { async function main(): Promise<void> {
+2 -2
View File
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
} }
} }
const mobileLayout = normalizedLayouts[0]; const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile");
const baseLayout = normalizedLayouts.at(-1); const baseLayout = normalizedLayouts.find((layout) => layout.role === "base");
if (mobileLayout && baseLayout) { if (mobileLayout && baseLayout) {
await insertMissingProjectedPositionsAsync( await insertMissingProjectedPositionsAsync(
db, db,
+14 -9
View File
@@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = <
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
const orderedLayouts = const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined;
mobileLayout && baseLayout let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts);
? [ if (hasProtectedLayouts) {
mobileLayout, orderedLayouts = [
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts), mobileLayout,
baseLayout, ...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts),
] ];
: sourceLayouts.toSorted(compareBoardLayouts); }
let previousBreakpoint = -1; let previousBreakpoint = -1;
return orderedLayouts.map((layout, index) => { return orderedLayouts.map((layout, index) => {
@@ -49,7 +49,12 @@ export const normalizeBoardLayoutRoles = <
index === 0 index === 0
? 0 ? 0
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom"; let role: LayoutRole = layout.role ?? "custom";
if (!hasProtectedLayouts) {
if (index === 0) role = "mobile";
else if (index === orderedLayouts.length - 1) role = "base";
else role = "custom";
}
previousBreakpoint = breakpoint; previousBreakpoint = breakpoint;
return { ...layout, breakpoint, role }; return { ...layout, breakpoint, role };
}); });
+7 -1
View File
@@ -63,13 +63,19 @@ export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = { export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
airQuality: { width: 2, height: 1 }, airQuality: { width: 2, height: 1 },
calendar: { width: 2, height: 2 },
countdown: { width: 2, height: 1 }, countdown: { width: 2, height: 1 },
downloads: { width: 4, height: 2 },
indexerManager: { width: 2, height: 3 },
timer: { width: 2, height: 1 }, timer: { width: 2, height: 1 },
uptimeKuma: { width: 2, height: 3 }, uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 }, audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 }, paperlessNgx: { width: 2, height: 2 },
patchmon: { width: 2, height: 2 }, patchmon: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 }, mediaServer: { width: 3, height: 2 },
"mediaRequests-requestList": { width: 3, height: 2 },
"mediaRequests-requestStats": { width: 2, height: 2 },
mediaMissing: { width: 2, height: 2 },
bazarr: { width: 2, height: 2 }, bazarr: { width: 2, height: 2 },
assistant: { width: 10, height: 4 }, assistant: { width: 10, height: 4 },
}; };
@@ -49,8 +49,7 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
message: tBoard("action.create.notification.success.message", { name: values.name }), message: tBoard("action.create.notification.success.message", { name: values.name }),
}); });
const name = encodeURIComponent(result.name); const name = encodeURIComponent(result.name);
const layoutId = encodeURIComponent(result.layoutId); router.push(`/boards/${name}`);
router.push(`/boards/${name}?layout=${layoutId}&edit=true&add=true`);
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
+22 -7
View File
@@ -7,6 +7,7 @@ import { useI18n } from "@homarr/translation/client";
interface ModalFormFooterProps { interface ModalFormFooterProps {
onCancel: () => void; onCancel: () => void;
form?: string;
submitLabel?: string; submitLabel?: string;
cancelLabel?: string; cancelLabel?: string;
loading?: boolean; loading?: boolean;
@@ -18,24 +19,38 @@ const ActionButtons = ({
submitLabel, submitLabel,
cancelLabel, cancelLabel,
loading, loading,
}: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading">) => { form,
const t = useI18n(); }: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading" | "form">) => {
const t = useI18n("common.action");
return ( return (
<> <>
<Button onClick={onCancel} variant="subtle" color="gray"> <Button onClick={onCancel} variant="subtle" color="gray">
{cancelLabel ?? t("common.action.cancel")} {cancelLabel ?? t("cancel")}
</Button> </Button>
<Button type="submit" loading={loading}> <Button type="submit" form={form} loading={loading}>
{submitLabel ?? t("common.action.saveChanges")} {submitLabel ?? t("saveChanges")}
</Button> </Button>
</> </>
); );
}; };
export const ModalFormFooter = ({ onCancel, submitLabel, cancelLabel, loading, leftSection }: ModalFormFooterProps) => { export const ModalFormFooter = ({
onCancel,
form,
submitLabel,
cancelLabel,
loading,
leftSection,
}: ModalFormFooterProps) => {
const actionButtons = ( const actionButtons = (
<ActionButtons onCancel={onCancel} submitLabel={submitLabel} cancelLabel={cancelLabel} loading={loading} /> <ActionButtons
onCancel={onCancel}
form={form}
submitLabel={submitLabel}
cancelLabel={cancelLabel}
loading={loading}
/>
); );
if (leftSection) { if (leftSection) {
+22 -9
View File
@@ -2430,6 +2430,16 @@
"label": "Animate weather icon", "label": "Animate weather icon",
"description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested." "description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested."
}, },
"colorWeatherByDayNight": {
"label": "Color weather by day/night",
"description": "Tint the weather icon and temperature with a day color or a night color, based on whether it's currently day or night at the chosen location."
},
"dayWeatherColor": {
"label": "Day color"
},
"nightWeatherColor": {
"label": "Night color"
},
"worldClockCities": { "worldClockCities": {
"label": "World clock cities", "label": "World clock cities",
"description": "Add and order up to six cities for advanced view." "description": "Add and order up to six cities for advanced view."
@@ -4217,7 +4227,11 @@
}, },
"mediaRequests-requestList": { "mediaRequests-requestList": {
"name": "Media Requests List", "name": "Media Requests List",
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance", "description": "See a list of all media requests from your Seerr, Overseerr, or Jellyseerr instance",
"empty": {
"title": "No media requests yet",
"description": "Search for a movie or show to start a request."
},
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Open links in new tab" "label": "Open links in new tab"
@@ -4257,6 +4271,10 @@
"mediaRequests-requestStats": { "mediaRequests-requestStats": {
"name": "Media Requests Stats", "name": "Media Requests Stats",
"description": "Statistics about your media requests", "description": "Statistics about your media requests",
"empty": {
"title": "No request activity yet",
"description": "Stats will appear after the first media request."
},
"titles": { "titles": {
"stats": { "stats": {
"main": "Media Stats", "main": "Media Stats",
@@ -4662,12 +4680,6 @@
"noSectionsEnabled": "Enable at least one section in widget options.", "noSectionsEnabled": "Enable at least one section in widget options.",
"error": { "error": {
"internalServerError": "Failed to fetch speedtest data" "internalServerError": "Failed to fetch speedtest data"
},
"range": {
"twelveHours": "12h",
"oneDay": "24h",
"all": "All",
"lastThirty": "Last 30"
} }
}, },
"uptimeKuma": { "uptimeKuma": {
@@ -4841,7 +4853,8 @@
"memory": "MEM", "memory": "MEM",
"network": "NET", "network": "NET",
"up": "UP", "up": "UP",
"down": "DOWN" "down": "DOWN",
"memoryTooltip": "{used} / {available} ({percent}%)"
} }
}, },
"systemDisks": { "systemDisks": {
@@ -5413,7 +5426,7 @@
"title": "Layout", "title": "Layout",
"responsive": { "responsive": {
"title": "Responsive layouts", "title": "Responsive layouts",
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.", "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.",
"action": { "action": {
"add": "Add layout" "add": "Add layout"
} }
-4
View File
@@ -116,10 +116,6 @@ export const responsiveBoardLayoutsSchema = z
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" }); ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
} }
if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
}
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) { if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" }); ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
} }
@@ -16,7 +16,7 @@ export const headerBuiltinItemIds = [
"user", "user",
] as const; ] as const;
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "user"] as const; export const requiredHeaderBuiltinItemIds = ["user"] as const;
export const headerPreferenceItemIds = headerBuiltinItemIds; export const headerPreferenceItemIds = headerBuiltinItemIds;
export const headerItemIds = headerBuiltinItemIds; export const headerItemIds = headerBuiltinItemIds;
@@ -23,11 +23,16 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
const CONTROLS_ROW_HEIGHT = 36;
const CONTROLS_ROW_GAP = 16;
const STACK_PADDING = 24;
export default function BeszelSystemStatsWidget({ export default function BeszelSystemStatsWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width,
height,
boardId, boardId,
itemId, itemId,
setOptions, setOptions,
@@ -256,6 +261,10 @@ export default function BeszelSystemStatsWidget({
systemId={selectedSystem?.systemId ?? ""} systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod} timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1} columns={width > 600 ? 2 : 1}
availableHeight={Math.max(
0,
height - STACK_PADDING - (isEditMode ? 0 : CONTROLS_ROW_HEIGHT + CONTROLS_ROW_GAP),
)}
visibility={{ visibility={{
cpu: options.showCpu, cpu: options.showCpu,
memory: options.showMemory, memory: options.showMemory,
@@ -62,9 +62,20 @@ interface BeszelStatsViewProps {
timePeriod: BeszelTimePeriod; timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility; visibility: BeszelStatsVisibility;
columns: 1 | 2; columns: 1 | 2;
availableHeight?: number;
onSwitchToHistorical?: () => void; onSwitchToHistorical?: () => void;
} }
const MIN_CHART_HEIGHT = 100;
const PANEL_ROW_OVERHEAD = 28;
const GRID_ROW_GAP = 16;
const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => {
if (availableHeight === undefined || rowCount <= 0) return CHART_HEIGHT;
const perRow = (availableHeight - (rowCount - 1) * GRID_ROW_GAP) / rowCount - PANEL_ROW_OVERHEAD;
return Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, Math.floor(perRow)));
};
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined); const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({ export function BeszelStatsView({
@@ -73,6 +84,7 @@ export function BeszelStatsView({
timePeriod, timePeriod,
visibility, visibility,
columns, columns,
availableHeight,
onSwitchToHistorical, onSwitchToHistorical,
}: BeszelStatsViewProps) { }: BeszelStatsViewProps) {
const t = useI18n("widget.beszelSystemStats"); const t = useI18n("widget.beszelSystemStats");
@@ -248,14 +260,29 @@ export function BeszelStatsView({
); );
} }
const renderedPanelCount =
(visibility.cpu && cpuData.length > 0 ? 1 : 0) +
(visibility.memory && memoryData.length > 0 ? 1 : 0) +
(visibility.disk && diskData.length > 0 ? 1 : 0) +
(visibility.diskIO && diskIOData.length > 0 ? 1 : 0) +
(visibility.network && networkData.length > 0 ? 1 : 0) +
(showDocker && containerSeries.length > 0
? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) +
(visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) +
(visibility.dockerNetwork && dockerNetworkData.length > 0 ? 1 : 0)
: 0);
const effectiveColumns = Math.min(columns, Math.max(1, renderedPanelCount)) as 1 | 2;
const rowCount = Math.ceil(renderedPanelCount / effectiveColumns);
const chartHeight = computeChartHeight(availableHeight, rowCount);
return ( return (
<SimpleGrid cols={columns} spacing="md"> <SimpleGrid cols={effectiveColumns} spacing="md">
{visibility.cpu && cpuData.length > 0 && ( {visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel <BeszelChartPanel
title={t("chart.cpu.title")} title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")} subtitle={t("chart.cpu.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: cpuData, data: cpuData,
series: series.cpu, series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent, yAxisFormatter: chartAxisFormatters.percent,
@@ -269,7 +296,7 @@ export function BeszelStatsView({
title={t("chart.memory.title")} title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")} subtitle={t("chart.memory.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: memoryData, data: memoryData,
type: "stacked", type: "stacked",
series: series.memory, series: series.memory,
@@ -283,7 +310,7 @@ export function BeszelStatsView({
title={t("chart.disk.title")} title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")} subtitle={t("chart.disk.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: diskData, data: diskData,
type: "stacked", type: "stacked",
series: diskSeries, series: diskSeries,
@@ -297,7 +324,7 @@ export function BeszelStatsView({
title={t("chart.diskIO.title")} title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")} subtitle={t("chart.diskIO.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: diskIOData, data: diskIOData,
series: series.diskIO, series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate, yAxisFormatter: chartAxisFormatters.rate,
@@ -310,7 +337,7 @@ export function BeszelStatsView({
title={t("chart.network.title")} title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")} subtitle={t("chart.network.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: networkData, data: networkData,
series: series.network, series: series.network,
yAxisFormatter: chartAxisFormatters.rate, yAxisFormatter: chartAxisFormatters.rate,
@@ -325,7 +352,7 @@ export function BeszelStatsView({
title={t("chart.dockerCpu.title")} title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")} subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: dockerCpuData, data: dockerCpuData,
type: "stacked", type: "stacked",
series: containerSeries, series: containerSeries,
@@ -339,7 +366,7 @@ export function BeszelStatsView({
title={t("chart.dockerMemory.title")} title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")} subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: dockerMemoryData, data: dockerMemoryData,
type: "stacked", type: "stacked",
series: containerSeries, series: containerSeries,
@@ -353,7 +380,7 @@ export function BeszelStatsView({
title={t("chart.dockerNetwork.title")} title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")} subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: chartHeight,
data: dockerNetworkData, data: dockerNetworkData,
series: containerSeries, series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate, yAxisFormatter: chartAxisFormatters.rate,
+24 -7
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core"; import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -13,15 +14,25 @@ interface CalendarDayProps {
rootHeight: number; rootHeight: number;
} }
const fallbackDayScale = { textSize: "xs" as const, indicatorSize: 3, indicatorMarginTop: 2 };
const dayScaleTiers = [
{ minAxisSize: 700, textSize: "lg" as const, indicatorSize: 6, indicatorMarginTop: 6 },
{ minAxisSize: 450, textSize: "md" as const, indicatorSize: 5, indicatorMarginTop: 4 },
{ minAxisSize: 250, textSize: "sm" as const, indicatorSize: 4, indicatorMarginTop: 3 },
{ minAxisSize: 0, ...fallbackDayScale },
];
const getDayScale = (minAxisSize: number) =>
dayScaleTiers.find((tier) => minAxisSize >= tier.minAxisSize) ?? fallbackDayScale;
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => { export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const mantineTheme = useMantineTheme(); const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius]; const actualItemRadius = mantineTheme.radius[board.itemRadius];
const minAxisSize = Math.min(rootWidth, rootHeight); const minAxisSize = Math.min(rootWidth, rootHeight);
const shouldScaleDown = minAxisSize < 350;
const shouldShowIndicators = rootHeight >= 256; const shouldShowIndicators = rootHeight >= 256;
const indicatorSize = shouldScaleDown ? 3 : 4; const { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]);
const cell = ( const cell = (
<Container <Container
@@ -39,10 +50,15 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
justifyContent: "center", justifyContent: "center",
}} }}
> >
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}> <Text ta={"center"} size={textSize} lh={1}>
{date.getDate()} {date.getDate()}
</Text> </Text>
<NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} /> <NotificationIndicator
events={events}
size={indicatorSize}
marginTop={indicatorMarginTop}
visible={shouldShowIndicators}
/>
</Container> </Container>
); );
@@ -70,19 +86,20 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
interface NotificationIndicatorProps { interface NotificationIndicatorProps {
events: CalendarEvent[]; events: CalendarEvent[];
size: number; size: number;
marginTop: number;
visible: boolean; visible: boolean;
} }
const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => { const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter( const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
(color): color is string => Boolean(color), (color): color is string => Boolean(color),
); );
if (!visible) return null; if (!visible || notificationEvents.length === 0) return null;
return ( return (
<Flex <Flex
mt={3} mt={marginTop}
w="fit-content" w="fit-content"
maw="75%" maw="75%"
h={size} h={size}
@@ -212,6 +212,7 @@ const CalendarBase = ({
monthCell: { monthCell: {
textAlign: "center", textAlign: "center",
position: "relative", position: "relative",
padding: 3,
}, },
day: { day: {
borderRadius: actualItemRadius, borderRadius: actualItemRadius,
@@ -132,6 +132,9 @@ const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "option
locationName={options.weatherLocation.name} locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit} isFahrenheit={options.isWeatherFormatFahrenheit}
animateIcon={options.animateWeatherIcon} animateIcon={options.animateWeatherIcon}
colorByDayNight={options.colorWeatherByDayNight}
dayColor={options.dayWeatherColor}
nightColor={options.nightWeatherColor}
detailed detailed
/> />
</Paper> </Paper>
+3
View File
@@ -54,6 +54,9 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
locationName={options.weatherLocation.name} locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit} isFahrenheit={options.isWeatherFormatFahrenheit}
animateIcon={options.animateWeatherIcon} animateIcon={options.animateWeatherIcon}
colorByDayNight={options.colorWeatherByDayNight}
dayColor={options.dayWeatherColor}
nightColor={options.nightWeatherColor}
detailed={false} detailed={false}
/> />
</Box> </Box>
+38
View File
@@ -12,6 +12,23 @@ import {
const timeZoneOptions = getTimeZoneOptions(); const timeZoneOptions = getTimeZoneOptions();
// Mantine theme color names, used to let users pick their own day/night weather colors.
const weatherColorOptions: string[] = [
"red",
"pink",
"grape",
"violet",
"indigo",
"blue",
"cyan",
"teal",
"green",
"lime",
"yellow",
"orange",
"gray",
];
export const { definition, componentLoader } = createWidgetDefinition("clock", { export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock, icon: IconClock,
supportsAdvancedFocus: true, supportsAdvancedFocus: true,
@@ -101,6 +118,18 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
defaultValue: false, defaultValue: false,
withDescription: true, withDescription: true,
}), }),
colorWeatherByDayNight: factory.switch({
defaultValue: false,
withDescription: true,
}),
dayWeatherColor: factory.select({
options: weatherColorOptions,
defaultValue: "orange",
}),
nightWeatherColor: factory.select({
options: weatherColorOptions,
defaultValue: "blue",
}),
worldClockCities: factory.timezoneList({ worldClockCities: factory.timezoneList({
defaultValue: defaultWorldClockCities.map((city) => ({ ...city })), defaultValue: defaultWorldClockCities.map((city) => ({ ...city })),
maxValues: maximumWorldClockCities, maxValues: maximumWorldClockCities,
@@ -131,6 +160,15 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
animateWeatherIcon: { animateWeatherIcon: {
shouldHide: (options) => !options.showWeather, shouldHide: (options) => !options.showWeather,
}, },
colorWeatherByDayNight: {
shouldHide: (options) => !options.showWeather,
},
dayWeatherColor: {
shouldHide: (options) => !options.showWeather || !options.colorWeatherByDayNight,
},
nightWeatherColor: {
shouldHide: (options) => !options.showWeather || !options.colorWeatherByDayNight,
},
}, },
); );
}, },
+14 -4
View File
@@ -14,6 +14,9 @@ interface ClockWeatherSummaryProps {
locationName: string; locationName: string;
isFahrenheit: boolean; isFahrenheit: boolean;
animateIcon: boolean; animateIcon: boolean;
colorByDayNight: boolean;
dayColor: string;
nightColor: string;
detailed: boolean; detailed: boolean;
} }
@@ -23,6 +26,9 @@ export const ClockWeatherSummary = ({
locationName, locationName,
isFahrenheit, isFahrenheit,
animateIcon, animateIcon,
colorByDayNight,
dayColor,
nightColor,
detailed, detailed,
}: ClockWeatherSummaryProps) => { }: ClockWeatherSummaryProps) => {
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -46,6 +52,10 @@ export const ClockWeatherSummary = ({
return `${Math.round(preferred)}${unit}`; return `${Math.round(preferred)}${unit}`;
}; };
const weatherColor = colorByDayNight
? `var(--mantine-color-${weather.current.isDay ? dayColor : nightColor}-5)`
: undefined;
return ( return (
<Stack gap={detailed ? 3 : 2} align={detailed ? "flex-start" : "center"}> <Stack gap={detailed ? 3 : 2} align={detailed ? "flex-start" : "center"}>
{detailed ? ( {detailed ? (
@@ -54,9 +64,9 @@ export const ClockWeatherSummary = ({
animated={animateIcon} animated={animateIcon}
code={weather.current.weatherCode} code={weather.current.weatherCode}
isDay={weather.current.isDay} isDay={weather.current.isDay}
style={zoomCompensatedSize(28)} style={{ ...zoomCompensatedSize(28), ...(weatherColor ? { color: weatherColor } : undefined) }}
/> />
<Text size="sm" fw={600}> <Text size="sm" fw={600} c={weatherColor}>
{Math.round(temperature)} {Math.round(temperature)}
{unit} {unit}
</Text> </Text>
@@ -68,9 +78,9 @@ export const ClockWeatherSummary = ({
animated={animateIcon} animated={animateIcon}
code={weather.current.weatherCode} code={weather.current.weatherCode}
isDay={weather.current.isDay} isDay={weather.current.isDay}
style={zoomCompensatedSize(26)} style={{ ...zoomCompensatedSize(26), ...(weatherColor ? { color: weatherColor } : undefined) }}
/> />
<Text size="xs" c="dimmed"> <Text size="xs" c={weatherColor ?? "dimmed"}>
{Math.round(temperature)} {Math.round(temperature)}
{unit} {unit}
</Text> </Text>
@@ -12,6 +12,11 @@ describe("getUsableWidgetQueryData", () => {
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data); expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
}); });
test("throws access-denied refetches instead of rendering stale cached data", () => {
const error = { data: { code: "FORBIDDEN" } };
expect(() => getUsableWidgetQueryData({ data: [{ id: "revoked" }], error })).toThrow(error);
});
test("throws a terminal failure when no usable data exists", () => { test("throws a terminal failure when no usable data exists", () => {
const error = new Error("offline"); const error = new Error("offline");
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error); expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
@@ -1,3 +1,5 @@
import { isTrpcForbiddenError } from "@homarr/api/query-cache";
interface WidgetQueryState { interface WidgetQueryState {
data: unknown; data: unknown;
error: unknown; error: unknown;
@@ -9,6 +11,7 @@ interface PendingWidgetQueryState extends WidgetQueryState {
/** Throws terminal query failures while preserving successful empty values and stale cached data. */ /** Throws terminal query failures while preserving successful empty values and stale cached data. */
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => { export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
if (isTrpcForbiddenError(query.error)) throw query.error;
if (query.error && query.data === undefined) throw query.error; if (query.error && query.data === undefined) throw query.error;
return query.data; return query.data;
}; };
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component"; import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component";
const permissions = { const permissions = {
allowFullScreen: true, allowFullScreen: true,
@@ -18,12 +18,6 @@ describe("iframe policy", () => {
expect(getSandboxFlags(permissions)).toContain("allow-modals"); expect(getSandboxFlags(permissions)).toContain("allow-modals");
}); });
it("accepts only HTTP protocols", () => {
expect(isSupportedProtocol("https://example.com")).toBe(true);
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
it("creates a display title without credentials, paths, or query parameters", () => { it("creates a display title without credentials, paths, or query parameters", () => {
expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com"); expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com");
}); });
+3 -29
View File
@@ -1,26 +1,21 @@
"use client"; "use client";
import { Box, Stack, Text, Title } from "@mantine/core"; import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react"; import { IconBrowserOff } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import classes from "./component.module.css"; import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) { export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n("widget.iframe"); const t = useI18n("widget.iframe");
const { embedUrl, allowScrolling, ...permissions } = options; const { embedUrl, allowScrolling, ...permissions } = options;
const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
const allowedPermissions = getAllowedPermissions(permissions); const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions); const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return <NoUrl />; if (embedUrl.trim() === "") return <NoUrl />;
if (!safeEmbedUrl) {
return <UnsupportedProtocol />;
}
return ( return (
<Stack h="100%" w="100%" gap={0}> <Stack h="100%" w="100%" gap={0}>
@@ -29,8 +24,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
loading="lazy" loading="lazy"
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined} style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe} className={classes.iframe}
src={safeEmbedUrl} src={embedUrl}
title={getFrameTitle(safeEmbedUrl)} title={getFrameTitle(embedUrl)}
allow={allowedPermissions} allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"} scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")} sandbox={sandboxFlags.join(" ")}
@@ -42,12 +37,6 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
); );
} }
const supportedProtocols = ["http", "https"];
export const isSupportedProtocol = (url: string) => {
return getSafeApplicationUrl(url) !== undefined;
};
const NoUrl = () => { const NoUrl = () => {
const t = useI18n("widget.iframe"); const t = useI18n("widget.iframe");
@@ -59,21 +48,6 @@ const NoUrl = () => {
); );
}; };
const UnsupportedProtocol = () => {
const t = useI18n("widget.iframe");
return (
<Stack align="center" justify="center" h="100%">
<IconProtocol />
<Title order={4} ta="center">
{t("error.unsupportedProtocol", {
supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
})}
</Title>
</Stack>
);
};
export const getAllowedPermissions = ( export const getAllowedPermissions = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => { ) => {
+11 -12
View File
@@ -1,7 +1,6 @@
import { IconBrowser, IconExternalLink } from "@tabler/icons-react"; import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
const createOptions = () => const createOptions = () =>
@@ -23,17 +22,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe",
supportsAdvancedFocus: false, supportsAdvancedFocus: false,
icon: IconBrowser, icon: IconBrowser,
contextActions: ({ options }) => { contextActions: ({ options }) => {
const embedUrl = getSafeApplicationUrl(options.embedUrl); const embedUrl = options.embedUrl;
return embedUrl if (typeof embedUrl !== "string" || embedUrl.trim() === "") return [];
? [
{ return [
key: "open-iframe", {
label: "widget.common.openInNewTab.label", key: "open-iframe",
icon: IconExternalLink, label: "widget.common.openInNewTab.label",
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"), icon: IconExternalLink,
}, onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
] },
: []; ];
}, },
createOptions, createOptions,
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));
@@ -0,0 +1,49 @@
"use client";
import { Button, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconMovieOff, IconSearch } from "@tabler/icons-react";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
interface MediaRequestsEmptyStateProps {
title: string;
description: string;
integrationIds: string[];
isEditMode: boolean;
}
export const MediaRequestsEmptyState = ({
title,
description,
integrationIds,
isEditMode,
}: MediaRequestsEmptyStateProps) => {
const tSearch = useI18n("search.mode.media");
return (
<Stack h="100%" align="center" justify="center" gap="xs" p="md">
<ThemeIcon variant="light" size="xl" radius="xl">
<IconMovieOff />
</ThemeIcon>
<Stack gap={2} align="center">
<Text fw={600} ta="center">
{title}
</Text>
<Text c="dimmed" size="sm" ta="center" maw="28ch">
{description}
</Text>
</Stack>
{!isEditMode ? (
<Button
variant="light"
size="compact-sm"
leftSection={<IconSearch size="var(--mantine-font-size-md)" />}
onClick={() => openMediaRequestSearch({ integrationIds })}
>
{tSearch("action.search.label")}
</Button>
) : null}
</Stack>
);
};
@@ -20,7 +20,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css"; import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { MediaRequestsEmptyState } from "../empty-state";
import classes from "./component.module.css"; import classes from "./component.module.css";
import searchClasses from "../search-button.module.css"; import searchClasses from "../search-button.module.css";
@@ -30,6 +30,7 @@ export default function MediaServerWidget({
options, options,
width, width,
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set( const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess() useIntegrationsWithInteractAccess()
.filter(({ id }) => integrationIds.includes(id)) .filter(({ id }) => integrationIds.includes(id))
@@ -48,7 +49,16 @@ export default function MediaServerWidget({
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />; if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
if (!mediaRequestData) return <WidgetEmptyState />; if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData; const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError(); if (mediaRequests.length === 0 && failedIntegrations.length === 0) {
return (
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1; const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
return ( return (
@@ -28,7 +28,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css"; import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { MediaRequestsEmptyState } from "../empty-state";
import classes from "./component.module.css"; import classes from "./component.module.css";
import searchClasses from "../search-button.module.css"; import searchClasses from "../search-button.module.css";
@@ -54,8 +54,16 @@ export default function MediaServerWidget({
requestStats.users.length === 0 && requestStats.users.length === 0 &&
requestStats.stats.length === 0 && requestStats.stats.length === 0 &&
requestStats.failedIntegrations.length === 0 requestStats.failedIntegrations.length === 0
) ) {
throw new NoIntegrationDataError(); return (
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
const data = [ const data = [
{ {
@@ -143,6 +143,7 @@
margin-inline: calc(var(--mantine-spacing-md) * -1); margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1); margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
padding-bottom: max(var(--mantine-spacing-sm), env(safe-area-inset-bottom));
border-top: 1px solid var(--mantine-color-default-border); border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -36,7 +36,7 @@ import { objectEntries } from "@homarr/common";
import { IntegrationProvider, useSession } from "@homarr/auth/client"; import { IntegrationProvider, useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals"; import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
import { SpotlightProvider } from "@homarr/spotlight"; import { SpotlightProvider } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -68,10 +68,14 @@ export interface WidgetEditModalSize {
height: number; height: number;
} }
export interface WidgetPreviewDimensions extends WidgetEditModalSize {
scale?: number;
}
interface WidgetEditPreviewResizeOptions { interface WidgetEditPreviewResizeOptions {
initialSize: WidgetEditModalSize; initialSize: WidgetEditModalSize;
maximumSize: WidgetEditModalSize; maximumSize: WidgetEditModalSize;
getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number }; getDimensions?: (size: WidgetEditModalSize) => WidgetPreviewDimensions;
} }
export interface EmbeddedIntegrationEditFormHandle { export interface EmbeddedIntegrationEditFormHandle {
@@ -99,7 +103,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
previewComponent?: ComponentType<WidgetComponentProps<TSort>>; previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
previewDimensions?: { width: number; height: number; scale?: number }; previewDimensions?: WidgetPreviewDimensions;
previewResize?: WidgetEditPreviewResizeOptions; previewResize?: WidgetEditPreviewResizeOptions;
previewWrapper?: ComponentType<PropsWithChildren>; previewWrapper?: ComponentType<PropsWithChildren>;
} }
@@ -111,7 +115,7 @@ interface WidgetEditPreviewProps {
state: WidgetEditModalState; state: WidgetEditModalState;
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
dimensions?: { width: number; height: number; scale?: number }; dimensions?: WidgetPreviewDimensions;
integrationData: IntegrationSelectOption[]; integrationData: IntegrationSelectOption[];
onChangeOptions: (newOptions: Record<string, unknown>) => void; onChangeOptions: (newOptions: Record<string, unknown>) => void;
resize?: { resize?: {
@@ -179,7 +183,9 @@ const WidgetEditPreview = ({
}, },
})); }));
let componentItemId = itemId; let componentItemId = itemId;
if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`; if (kind === "assistant" || kind === "timer") {
componentItemId = `widget-preview-${generatedPreviewId}`;
}
const isPendingCustomWidget = kind === "customApi" && !itemId; const isPendingCustomWidget = kind === "customApi" && !itemId;
const previewOpacity = (board?.opacity ?? 100) / 100; const previewOpacity = (board?.opacity ?? 100) / 100;
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => { const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {
@@ -668,16 +674,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const footer = ( const footer = (
<Box className={classes.footer}> <Box className={classes.footer}>
<Group justify="end" wrap="nowrap"> <ModalFormFooter onCancel={actions.closeModal} form={widgetFormId} loading={isSubmitting} />
<Group gap="xs" wrap="nowrap">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Box> </Box>
); );
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -21,14 +21,10 @@ export default function SpeedtestTrackerWidget({
options, options,
integrationIds, integrationIds,
width, width,
height,
displayMode = "compact",
}: WidgetComponentProps<"speedtestTracker">) { }: WidgetComponentProps<"speedtestTracker">) {
const t = useI18n("widget.speedtestTracker"); const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData; const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
const [advancedRange, setAdvancedRange] = useState("24");
const isAdvanced = displayMode === "advanced";
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]); const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
const combined = useMemo( const combined = useMemo(
@@ -36,98 +32,24 @@ export default function SpeedtestTrackerWidget({
[successfulDashboards], [successfulDashboards],
); );
const rangeHours = isAdvanced ? Number(advancedRange) : 12; const cutoff = Date.now() - 12 * 60 * 60 * 1000;
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff); const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const showLatestResult = isAdvanced || options.showLatestResult; const showLatestResult = options.showLatestResult;
const showStats = isAdvanced || options.showStats; const showStats = options.showStats;
const showRecentResults = isAdvanced || options.showRecentResults; const showRecentResults = options.showRecentResults;
const showPingGraph = isAdvanced || options.showPingGraph; const showPingGraph = options.showPingGraph;
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null); const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
const hasChart = showRecentResults && recentFiltered.length > 0; const hasChart = showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = showLatestResult && combined.latestResult && ( const latest = showLatestResult && combined.latestResult && (
<LatestResultSection <LatestResultSection result={combined.latestResult} width={width} compactSurface />
result={combined.latestResult}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
); );
const averages = showStats && combined.stats && ( const averages = showStats && combined.stats && (
<AveragesSection <AveragesSection stats={combined.stats} width={width} compactSurface />
stats={combined.stats}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
); );
if (displayMode === "advanced") {
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
<SegmentedControl
size="xs"
value={advancedRange}
onChange={setAdvancedRange}
data={[
{ value: "12", label: t("range.twelveHours") },
{ value: "24", label: t("range.oneDay") },
{ value: "0", label: t("range.lastThirty") },
]}
/>
{successfulDashboards.length === 0 ? (
<WidgetEmptyState />
) : (
<SimpleGrid cols={sourceColumns} spacing="md">
{successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
const sourceHasChart = sourceRecent.length > 0;
return (
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
<Stack gap="md">
<Text size="sm" fw={600} truncate>
{integrationName}
</Text>
{sourceHasStats && (
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
{dashboard.latestResult && (
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
)}
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
</SimpleGrid>
)}
{sourceHasChart && (
<Box h={Math.max(260, Math.min(420, height / 2))}>
<RecentResultsSection results={sourceRecent} showPingGraph />
</Box>
)}
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
</Stack>
</Card>
);
})}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
</Box>
);
}
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}> <Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat, icon: IconSpeedboat,
supportsAdvancedFocus: true,
refetchInterval: null, refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
@@ -1,4 +1,4 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react"; import { IconNetwork } from "@tabler/icons-react";
import { formatByteRate } from "@homarr/common"; import { formatByteRate } from "@homarr/common";
@@ -21,9 +21,43 @@ export const CombinedNetworkTrafficChart = ({
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
}) => { }) => {
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down })); const chartData = usageOverTime.map((usage, index) => ({
index,
up: usage.up,
down: usage.down,
}));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
const tooltipLabel = (index: number) => {
const point = usageOverTime[index];
return (
<Stack gap={2}>
<Group gap={4} wrap="nowrap">
<Box
bg="orange.5"
w={8}
h={8}
style={{ borderRadius: 99, flexShrink: 0 }}
/>
<Text size="xs">
{t("up")}: {formatByteRate(Math.round(point?.up ?? 0))}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Box
bg="yellow.5"
w={8}
h={8}
style={{ borderRadius: 99, flexShrink: 0 }}
/>
<Text size="xs">
{t("down")}: {formatByteRate(Math.round(point?.down ?? 0))}
</Text>
</Group>
</Stack>
);
};
return ( return (
<CommonChart <CommonChart
data={chartData} data={chartData}
@@ -38,28 +72,7 @@ export const CombinedNetworkTrafficChart = ({
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipProps={{ tooltipLabel={tooltipLabel}
content: ({ payload }) => {
return (
<Paper px={3} py={2} shadow="md">
<Stack gap={0}>
{payload.map((payloadData) => (
<Group key={payloadData.key} gap={4}>
<Box bg={payloadData.color} w={10} h={10} style={{ borderRadius: 99 }}></Box>
<Text c="dimmed" size="xs">
{payloadData.value === undefined ? (
<>N/A</>
) : (
formatByteRate(Math.round(Number(payloadData.value)))
)}
</Text>
</Group>
))}
</Stack>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,10 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { CSSProperties, ReactNode } from "react"; import type {
CSSProperties,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import { useState } from "react";
import type { AreaChartSeries } from "@mantine/charts"; import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core"; import {
Card,
Center,
Group,
Stack,
Text,
Tooltip,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts"; import type { YAxisProps } from "recharts";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
@@ -19,7 +33,7 @@ export const CommonChart = ({
title, title,
icon: Icon, icon: Icon,
labelDisplayMode, labelDisplayMode,
tooltipProps, tooltipLabel,
yAxisProps, yAxisProps,
lastValue, lastValue,
chartType = "line", chartType = "line",
@@ -31,7 +45,10 @@ export const CommonChart = ({
title: ReactNode; title: ReactNode;
icon: TablerIcon; icon: TablerIcon;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
tooltipProps?: TooltipProps<number, any>; // What to show on hover. Defaults to lastValue when omitted. Pass a function to reflect
// the datum under the cursor as it moves across multi-point data (e.g. memory's
// used/available at that point), instead of always showing the latest value.
tooltipLabel?: ReactNode | ((index: number) => ReactNode);
yAxisProps?: Omit<YAxisProps, "ref">; yAxisProps?: Omit<YAxisProps, "ref">;
lastValue?: string; lastValue?: string;
chartType?: "line" | "area"; chartType?: "line" | "area";
@@ -45,63 +62,80 @@ export const CommonChart = ({
const ref = useMergedRef(elementSizeRef, hoverRef); const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100; const opacity = board.opacity / 100;
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; let backgroundColor =
colorScheme === "dark"
? `rgba(57, 57, 57, ${opacity})`
: `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" }; const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] }; const chartRootStyle: CSSProperties = {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
};
if (advanced) { if (advanced) {
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; backgroundColor =
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border = cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; "1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor; chartRootStyle.backgroundColor = backgroundColor;
} }
const ChartComponent = chartType === "line" ? LineChart : AreaChart; const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showIcon =
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon"; labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText =
labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
// Track which data point the cursor is over so the tooltip can reflect that point
// instead of always showing the latest value while scrubbing across the chart.
const [hoveredIndex, setHoveredIndex] = useState(data.length - 1);
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (data.length <= 1) return;
const rect = event.currentTarget.getBoundingClientRect();
if (rect.width === 0) return;
const ratio = (event.clientX - rect.left) / rect.width;
const index = Math.round(ratio * (data.length - 1));
setHoveredIndex(Math.min(data.length - 1, Math.max(0, index)));
};
// Clamp on read rather than syncing via an effect - handles data growing/shrinking
// between renders (e.g. history resetting on integration change) without ever
// handing a stale or out-of-range index to tooltipLabel.
const clampedHoveredIndex = Math.max(
0,
Math.min(hoveredIndex, data.length - 1),
);
const resolvedTooltipLabel =
typeof tooltipLabel === "function"
? tooltipLabel(clampedHoveredIndex)
: (tooltipLabel ?? lastValue);
return ( return (
<Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}> <Tooltip.Floating
{data.length > 1 && height > 40 && !hovered && ( label={resolvedTooltipLabel}
<Group disabled={data.length <= 1 || !resolvedTooltipLabel}
pos={"absolute"} >
top={0} <Card
left={0} ref={ref}
p={8} h={"100%"}
pt={6} pos={"relative"}
gap={5} style={cardStyle}
wrap={"nowrap"} p={0}
style={{ zIndex: 2, pointerEvents: "none" }} bg={backgroundColor}
align="center" radius={board.itemRadius}
> onPointerMove={handlePointerMove}
{showIcon && ( >
<Icon {data.length > 1 && height > 40 && !hovered && (
color="var(--mantine-color-dimmed)" <Group
style={zoomCompensatedSize(height > 100 ? 20 : 14)} pos={"absolute"}
stroke={1.5} top={0}
/> left={0}
)} p={8}
{showText && ( pt={6}
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}> gap={5}
{title} wrap={"nowrap"}
</Text> style={{ zIndex: 2, pointerEvents: "none" }}
)} align="center"
{lastValue && ( >
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
{lastValue}
</Text>
)}
</Group>
)}
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
{showIcon && ( {showIcon && (
<Icon <Icon
color="var(--mantine-color-dimmed)" color="var(--mantine-color-dimmed)"
@@ -110,43 +144,85 @@ export const CommonChart = ({
/> />
)} )}
{showText && ( {showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center"> <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
{title} {title}
</Text> </Text>
)} )}
{advanced && lastValue && ( {lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center"> <Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
lineClamp={1}
>
{lastValue} {lastValue}
</Text> </Text>
)} )}
</Stack> </Group>
</Center> )}
) : ( {advanced &&
<ChartComponent data.length > 1 &&
data={data} height > 0 &&
dataKey={dataKey} height <= 40 &&
h={"100%"} lastValue &&
series={series} !hovered && (
curveType="monotone" <Center
tickLine="none" pos="absolute"
gridAxis="none" w="100%"
withXAxis={false} h="100%"
withYAxis={false} style={{ zIndex: 2, pointerEvents: "none" }}
withDots={false} >
bg={backgroundColor} <Text size="xs" fw={600}>
styles={{ root: chartRootStyle }} {lastValue}
tooltipAnimationDuration={200} </Text>
tooltipProps={{ </Center>
// 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). {data.length <= 1 ? (
portal: typeof document !== "undefined" ? document.body : undefined, <Center pos="absolute" w="100%" h="100%">
...tooltipProps, <Stack px={"xs"} align={"center"} gap={4}>
}} {showIcon && (
withTooltip={height >= 64} <Icon
yAxisProps={yAxisProps} color="var(--mantine-color-dimmed)"
fillOpacity={chartType === "area" ? 0.3 : undefined} style={zoomCompensatedSize(height > 100 ? 20 : 14)}
/> stroke={1.5}
)} />
</Card> )}
{showText && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
fw={"bold"}
ta="center"
>
{title}
</Text>
)}
{advanced && lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
{lastValue}
</Text>
)}
</Stack>
</Center>
) : (
<ChartComponent
data={data}
dataKey={dataKey}
h={"100%"}
series={series}
curveType="monotone"
tickLine="none"
gridAxis="none"
withXAxis={false}
withYAxis={false}
withDots={false}
bg={backgroundColor}
styles={{ root: chartRootStyle }}
withTooltip={false}
yAxisProps={yAxisProps}
fillOpacity={chartType === "area" ? 0.3 : undefined}
/>
)}
</Card>
</Tooltip.Floating>
); );
}; };
@@ -1,4 +1,3 @@
import { Paper, Text } from "@mantine/core";
import { IconCpu } from "@tabler/icons-react"; import { IconCpu } from "@tabler/icons-react";
import { invariantTechnicalLabels } from "@homarr/definitions"; import { invariantTechnicalLabels } from "@homarr/definitions";
@@ -27,25 +26,16 @@ export const SystemResourceCPUChart = ({
title={invariantTechnicalLabels.cpu} title={invariantTechnicalLabels.cpu}
icon={IconCpu} icon={IconCpu}
lastValue={ lastValue={
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion cpuUsageOverTime.length > 0
cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%`
: undefined
} }
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }} yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipProps={{ tooltipLabel={(index) => `${Math.round(cpuUsageOverTime[index] ?? 0)}%`}
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{value.toFixed(0)}%
</Text>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,4 +1,3 @@
import { Paper, Text } from "@mantine/core";
import { IconDeviceDesktop } from "@tabler/icons-react"; import { IconDeviceDesktop } from "@tabler/icons-react";
import { invariantTechnicalLabels } from "@homarr/definitions"; import { invariantTechnicalLabels } from "@homarr/definitions";
@@ -27,25 +26,16 @@ export const SystemResourceGPUChart = ({
title={invariantTechnicalLabels.gpu} title={invariantTechnicalLabels.gpu}
icon={IconDeviceDesktop} icon={IconDeviceDesktop}
lastValue={ lastValue={
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion gpuUsageOverTime.length > 0
gpuUsageOverTime.length > 0 ? `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` : undefined ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%`
: undefined
} }
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }} yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipProps={{ tooltipLabel={(index) => `${Math.round(gpuUsageOverTime[index] ?? 0)}%`}
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{value.toFixed(0)}%
</Text>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,4 +1,3 @@
import { Paper, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react"; import { IconBrain } from "@tabler/icons-react";
import { formatBytesPair } from "@homarr/common"; import { formatBytesPair } from "@homarr/common";
@@ -20,15 +19,33 @@ export const SystemResourceMemoryChart = ({
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
}) => { }) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = memoryUsageOverTime.map((usage, index) => ({
index,
usage,
}));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
const percentageUsed = const percentageUsed =
memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0 memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes memoryUsageOverTime[memoryUsageOverTime.length - 1]! /
totalCapacityInBytes
: undefined; : undefined;
const tooltipLabel = (index: number) => {
const used = memoryUsageOverTime[index] ?? 0;
const memory = formatBytesPair(Math.round(used), totalCapacityInBytes);
const percent =
totalCapacityInBytes > 0
? Math.round((used / totalCapacityInBytes) * 100)
: 0;
return t("memoryTooltip", {
used: memory.used,
available: memory.available,
percent,
});
};
return ( return (
<CommonChart <CommonChart
data={chartData} data={chartData}
@@ -39,22 +56,13 @@ export const SystemResourceMemoryChart = ({
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
yAxisProps={{ domain: [0, totalCapacityInBytes] }} yAxisProps={{ domain: [0, totalCapacityInBytes] }}
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined} lastValue={
percentageUsed !== undefined
? `${Math.round(percentageUsed * 100)}%`
: undefined
}
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
tooltipProps={{ tooltipLabel={tooltipLabel}
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{memory.used} / {memory.available} (
{totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%)
</Text>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,4 +1,3 @@
import { Paper, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react"; import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { formatByteRate } from "@homarr/common"; import { formatByteRate } from "@homarr/common";
@@ -39,18 +38,9 @@ export const NetworkTrafficChart = ({
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipProps={{ tooltipLabel={(index) =>
content: ({ payload }) => { formatByteRate(Math.round(usageOverTime[index] ?? 0))
const value = payload[0] ? Number(payload[0].value) : 0; }
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{formatByteRate(Math.round(value))}
</Text>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -26,7 +26,7 @@ export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26,
let animationClass = ""; let animationClass = "";
if (animated && (code !== 0 || isDay !== false)) animationClass = getAnimationClass(code); if (animated && (code !== 0 || isDay !== false)) animationClass = getAnimationClass(code);
return ( return (
<span className={`weather-anim-wrapper ${animationClass}`}> <span className={`weather-anim-wrapper ${animationClass}`} data-is-day={isDay === false ? "false" : "true"}>
<WeatherIcon code={code} isDay={isDay} size={size} style={style} /> <WeatherIcon code={code} isDay={isDay} size={size} style={style} />
</span> </span>
); );
+14
View File
@@ -189,6 +189,9 @@ catalogs:
'@tabler/icons-react': '@tabler/icons-react':
specifier: ^3.44.0 specifier: ^3.44.0
version: 3.44.0 version: 3.44.0
'@tanstack/query-sync-storage-persister':
specifier: 5.101.2
version: 5.101.2
'@tanstack/react-devtools': '@tanstack/react-devtools':
specifier: ^0.10.8 specifier: ^0.10.8
version: 0.10.9 version: 0.10.9
@@ -1133,6 +1136,9 @@ importers:
'@tabler/icons-react': '@tabler/icons-react':
specifier: 'catalog:' specifier: 'catalog:'
version: 3.44.0(react@19.2.7) version: 3.44.0(react@19.2.7)
'@tanstack/query-sync-storage-persister':
specifier: 'catalog:'
version: 5.101.2
'@tanstack/react-devtools': '@tanstack/react-devtools':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)
@@ -8846,6 +8852,9 @@ packages:
'@tanstack/query-persist-client-core@5.101.2': '@tanstack/query-persist-client-core@5.101.2':
resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==} resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==}
'@tanstack/query-sync-storage-persister@5.101.2':
resolution: {integrity: sha512-hRyLEJvkUWtfCUA3Lf5vD1CrXbHHK4pZNM/iNd7JM5GgC4CWzGIHkVkeg8zC2LV11KceKObKgjNuMSg3b6NOjA==}
'@tanstack/react-devtools@0.10.9': '@tanstack/react-devtools@0.10.9':
resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==} resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -23948,6 +23957,11 @@ snapshots:
dependencies: dependencies:
'@tanstack/query-core': 5.101.2 '@tanstack/query-core': 5.101.2
'@tanstack/query-sync-storage-persister@5.101.2':
dependencies:
'@tanstack/query-core': 5.101.2
'@tanstack/query-persist-client-core': 5.101.2
'@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': '@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)':
dependencies: dependencies:
'@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14) '@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
+1
View File
@@ -59,6 +59,7 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1 "@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11 "@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0 "@tabler/icons-react": ^3.44.0
"@tanstack/query-sync-storage-persister": 5.101.2
"@tanstack/react-devtools": ^0.10.8 "@tanstack/react-devtools": ^0.10.8
"@tanstack/react-query": ^5.101.2 "@tanstack/react-query": ^5.101.2
"@tanstack/react-query-devtools": ^5.101.2 "@tanstack/react-query-devtools": ^5.101.2
+1 -1
View File
@@ -70,7 +70,7 @@ func BuildPlan(opts Options) (*Plan, error) {
} }
p.Env = append(p.Env, opts.Env...) p.Env = append(p.Env, opts.Env...)
if opts.Demo { if opts.Demo {
p.Env = append(p.Env, "DEMO_MODE=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true") p.Env = append(p.Env, "DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
p.Label += " (demo)" p.Label += " (demo)"
} }
return p, nil return p, nil
+5 -1
View File
@@ -76,7 +76,7 @@ func (m Model) instanceTarget(row instanceRow) target {
result := target{ result := target{
label: row.container.Name, label: row.container.Name,
container: row.container.Name, container: row.container.Name,
volume: row.container.Name + "_data", volume: row.volume,
running: row.container.Running(), running: row.container.Running(),
port: row.container.HostPort(), port: row.container.HostPort(),
} }
@@ -315,6 +315,10 @@ func (m Model) deleteImage(reference string) (Model, tea.Cmd) {
// away either way, and failing halfway would leave the user to work out why. // away either way, and failing halfway would leave the user to work out why.
func (m Model) deleteData(subject target) (Model, tea.Cmd) { func (m Model) deleteData(subject target) (Model, tea.Cmd) {
container, volume := subject.container, subject.volume container, volume := subject.container, subject.volume
if volume == "" {
m.status, m.statusLevel = subject.label+" has no data volume to delete", levelWarn
return m, nil
}
m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error { m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error {
_ = docker.RemoveContext(ctx, container) _ = docker.RemoveContext(ctx, container)
return docker.RemoveVolume(ctx, volume) return docker.RemoveVolume(ctx, volume)
+4
View File
@@ -65,6 +65,10 @@ func (m Model) handleManageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m.arm(actionDeleteImage, subject) return m.arm(actionDeleteImage, subject)
case key.Matches(msg, m.keys.ManageData): case key.Matches(msg, m.keys.ManageData):
if subject.volume == "" {
m.status, m.statusLevel = subject.label+" has no data volume to delete", levelWarn
return m, nil
}
return m.arm(actionDeleteData, subject) return m.arm(actionDeleteData, subject)
case key.Matches(msg, m.keys.ManageStop): case key.Matches(msg, m.keys.ManageStop):