Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af13531adc | ||
|
|
7a5edb26c1 |
No files matched your search
@@ -320,12 +320,8 @@ jobs:
|
|||||||
|
|
||||||
publish-preview:
|
publish-preview:
|
||||||
name: Publish multi-platform preview image
|
name: Publish multi-platform preview image
|
||||||
needs: [container, preview-amd64, preview-arm64]
|
needs: [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') ||
|
||||||
@@ -379,6 +375,9 @@ 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}"
|
||||||
@@ -387,6 +386,9 @@ 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)"
|
||||||
@@ -433,14 +435,6 @@ 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,
|
||||||
|
|||||||
@@ -113,9 +113,7 @@ 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`, 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.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -133,21 +133,20 @@ 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 main layout and starts at `768px` on new boards.
|
- **Base** is the widest 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 at any
|
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
|
||||||
unused breakpoint above Mobile.
|
their breakpoints.
|
||||||
|
|
||||||
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 an
|
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
|
||||||
available breakpoint, and you can then change the breakpoint, columns, and rails.
|
free breakpoint between Mobile and Base, 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. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
|
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
|
||||||
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
|
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
|
||||||
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.
|
||||||
|
|
||||||
@@ -183,8 +182,7 @@ 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`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
|
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||||
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.
|
||||||
|
|
||||||
@@ -273,8 +271,6 @@ 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,26 +128,6 @@ 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
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ 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,7 +16,6 @@ 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"]} />
|
||||||
|
|
||||||
@@ -40,9 +39,6 @@ import mediaRequestList from "./img/list.png";
|
|||||||
{
|
{
|
||||||
integration: overseerrIntegration,
|
integration: overseerrIntegration,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
integration: seerrIntegration,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -50,7 +46,6 @@ 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,7 +16,6 @@ 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"]} />
|
||||||
|
|
||||||
@@ -32,9 +31,6 @@ Provides an overview of media requests, including totals, availability, approval
|
|||||||
{
|
{
|
||||||
integration: overseerrIntegration,
|
integration: overseerrIntegration,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
integration: seerrIntegration,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -47,7 +43,6 @@ 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.
|
||||||
|
|
||||||
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,6 @@
|
|||||||
"@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,17 +1,20 @@
|
|||||||
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 {
|
||||||
createSessionQueryPersistence,
|
createSessionQueryPersister,
|
||||||
getQueryPersistenceStorageKey,
|
getQueryPersistenceStorageKey,
|
||||||
queryPersistenceBuster,
|
queryPersistenceBuster,
|
||||||
|
queryPersistenceMaxDataBytes,
|
||||||
shouldPersistDashboardQuery,
|
shouldPersistDashboardQuery,
|
||||||
} from "./query-persistence";
|
} from "./query-persistence";
|
||||||
|
|
||||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
|
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
queryClient.setQueryData(queryKey, data);
|
queryClient.setQueryData(queryKey, data, { updatedAt });
|
||||||
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 };
|
||||||
@@ -34,98 +37,314 @@ const createMemoryStorage = (): Storage => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
window.sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dashboard query persistence", () => {
|
describe("widget query persistence", () => {
|
||||||
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
|
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
|
||||||
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
|
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { 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 ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
|
||||||
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
|
|
||||||
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
|
|
||||||
apiKey: "secret",
|
|
||||||
});
|
|
||||||
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
||||||
|
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||||
|
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
|
||||||
|
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
|
||||||
|
});
|
||||||
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(ping.query)).toBe(false);
|
|
||||||
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
|
|
||||||
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
|
|
||||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
||||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||||
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
|
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
|
||||||
vi.useFakeTimers();
|
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 storage = createMemoryStorage();
|
||||||
const userA = createSessionQueryPersistence("user-a", storage);
|
const persister = createSessionQueryPersister("user-a", storage);
|
||||||
const userB = createSessionQueryPersistence("user-b", storage);
|
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||||
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { 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();
|
||||||
|
const scope = "user-a";
|
||||||
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
|
const persister = createSessionQueryPersister(scope);
|
||||||
|
const { query, 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"),
|
||||||
});
|
});
|
||||||
const cachedQuery = queryClient.getQueryCache().find({
|
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
||||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||||
});
|
|
||||||
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, userA.dehydrateOptions),
|
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(persistedClient.clientState.mutations).toHaveLength(0);
|
persister.persistClient(persistedClient);
|
||||||
|
persister.flush();
|
||||||
await userA.persister.persistClient(persistedClient);
|
const restored = await persister.restoreClient();
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
||||||
|
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(await userB.persister.restoreClient()).toBeUndefined();
|
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||||
|
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||||
|
|
||||||
await userA.persister.removeClient();
|
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||||
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
|
expect(persister.restoreClient()).toBeUndefined();
|
||||||
|
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("treats unavailable session storage as an empty cache", async () => {
|
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
|
||||||
const blockedStorage = createMemoryStorage();
|
const scope = "user-a";
|
||||||
blockedStorage.getItem = () => {
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
throw new DOMException("blocked", "SecurityError");
|
const { queryClient } = createSuccessfulQuery(
|
||||||
};
|
[["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||||
blockedStorage.removeItem = () => {
|
[{ title: "safe" }],
|
||||||
throw new DOMException("blocked", "SecurityError");
|
);
|
||||||
};
|
const validQuery = dehydrate(queryClient).queries[0];
|
||||||
|
if (!validQuery) throw new Error("Expected a dehydrated query");
|
||||||
|
|
||||||
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
|
window.sessionStorage.setItem(
|
||||||
expect(await persistence.persister.restoreClient()).toBeUndefined();
|
key,
|
||||||
expect(() => persistence.persister.removeClient()).not.toThrow();
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||||
|
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||||
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 { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
export const queryPersistenceBuster = "v6-dashboard-data";
|
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||||
|
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||||
|
export const queryPersistenceBuster = "v2-session-superjson";
|
||||||
|
|
||||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||||
|
|
||||||
@@ -15,33 +16,95 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
|||||||
|
|
||||||
interface PersistableQuery {
|
interface PersistableQuery {
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
state: { data: unknown; status: string };
|
state: {
|
||||||
|
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) =>
|
||||||
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
|
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||||
|
|
||||||
const serializePersistedClient = (client: PersistedClient) =>
|
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
||||||
stringify({
|
|
||||||
...client,
|
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||||
|
...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: {
|
||||||
...client.clientState,
|
mutations: [],
|
||||||
queries: client.clientState.queries.map((query) => ({
|
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
||||||
...query,
|
|
||||||
state: {
|
|
||||||
...query.state,
|
|
||||||
error: null,
|
|
||||||
errorUpdateCount: 0,
|
|
||||||
errorUpdatedAt: 0,
|
|
||||||
fetchFailureCount: 0,
|
|
||||||
fetchFailureReason: null,
|
|
||||||
status: "success",
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getSessionStorage = () => {
|
const getSessionStorage = (): Storage | undefined => {
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -51,47 +114,113 @@ const getSessionStorage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const protectStorage = (storage: Storage | undefined) => {
|
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
||||||
if (!storage) return undefined;
|
try {
|
||||||
|
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 {
|
||||||
getItem(key: string) {
|
persistClient(client) {
|
||||||
try {
|
if (!storage || stopped) return;
|
||||||
return storage.getItem(key);
|
pendingClient = client;
|
||||||
} catch {
|
if (persistTimer !== undefined) return;
|
||||||
return null;
|
persistTimer = setTimeout(flush, 0);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setItem(key: string, value: string) {
|
restoreClient() {
|
||||||
storage.setItem(key, value);
|
|
||||||
},
|
|
||||||
removeItem(key: string) {
|
|
||||||
try {
|
try {
|
||||||
storage.removeItem(key);
|
const storedValue = storage?.getItem(key);
|
||||||
|
if (!storedValue) return undefined;
|
||||||
|
|
||||||
|
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
||||||
|
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
} 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 { QueryCache, QueryClient } from "@tanstack/react-query";
|
import { 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,8 +25,6 @@ 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,
|
||||||
@@ -38,8 +36,13 @@ 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 { createSessionQueryPersistence } from "./query-persistence";
|
import {
|
||||||
import type { SessionQueryPersistence } from "./query-persistence";
|
createSessionQueryPersister,
|
||||||
|
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 =
|
||||||
@@ -72,11 +75,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 [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
|
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||||
const handleScopeChange = useCallback(() => {
|
const handleScopeChange = useCallback(() => {
|
||||||
void queryPersistence.persister.removeClient();
|
void queryPersister.removeClient();
|
||||||
reloadPage();
|
reloadPage();
|
||||||
}, [queryPersistence]);
|
}, [queryPersister]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionQueryScopeGuard
|
<SessionQueryScopeGuard
|
||||||
@@ -84,7 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
|||||||
currentScope={sessionQueryScope}
|
currentScope={sessionQueryScope}
|
||||||
onScopeChange={handleScopeChange}
|
onScopeChange={handleScopeChange}
|
||||||
>
|
>
|
||||||
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
|
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||||
</SessionQueryScopeGuard>
|
</SessionQueryScopeGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,8 +96,8 @@ const reloadPage = () => window.location.reload();
|
|||||||
|
|
||||||
const ScopedTRPCReactProvider = ({
|
const ScopedTRPCReactProvider = ({
|
||||||
children,
|
children,
|
||||||
queryPersistence,
|
queryPersister,
|
||||||
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
|
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||||
const wsClient = useMemo(
|
const wsClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createWSClient({
|
createWSClient({
|
||||||
@@ -111,13 +114,6 @@ 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,
|
||||||
@@ -156,6 +152,15 @@ 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(() => {
|
||||||
@@ -206,7 +211,15 @@ const ScopedTRPCReactProvider = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
|
<PersistQueryClientProvider
|
||||||
|
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,8 +40,6 @@ 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");
|
||||||
@@ -103,9 +101,6 @@ 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")}>
|
||||||
@@ -139,9 +134,13 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{displayedLayouts.map(({ layout, index }) => {
|
{form.values.layouts.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
|
||||||
@@ -191,8 +190,12 @@ 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={layout.role === "mobile" ? 0 : 1}
|
min={
|
||||||
max={32767}
|
layout.role === "base"
|
||||||
|
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||||
|
|
||||||
interface CopyAiPromptButtonProps {
|
interface CopyAiPromptButtonBaseProps {
|
||||||
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,
|
||||||
@@ -28,14 +35,23 @@ 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,
|
||||||
getDraft(),
|
readDraft(),
|
||||||
request,
|
request,
|
||||||
documentationUrl,
|
documentationUrl,
|
||||||
getDiagnostics(),
|
readDiagnostics(),
|
||||||
);
|
);
|
||||||
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") });
|
||||||
@@ -46,10 +62,10 @@ export const CopyAiPromptButton = ({
|
|||||||
const value = buildCustomWidgetAiPrompt(
|
const value = buildCustomWidgetAiPrompt(
|
||||||
undefined,
|
undefined,
|
||||||
rawResponse,
|
rawResponse,
|
||||||
getDraft(),
|
readDraft(),
|
||||||
request,
|
request,
|
||||||
documentationUrl,
|
documentationUrl,
|
||||||
getDiagnostics(),
|
readDiagnostics(),
|
||||||
);
|
);
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
|
|||||||
+7
-3
@@ -13,8 +13,6 @@ 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 {
|
||||||
@@ -49,7 +47,13 @@ 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,7 +17,6 @@ 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);
|
||||||
|
|
||||||
@@ -85,7 +84,13 @@ 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({
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
+7
-3
@@ -15,8 +15,6 @@ 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 {
|
||||||
@@ -68,7 +66,13 @@ 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,7 +17,6 @@ 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);
|
||||||
|
|
||||||
@@ -98,7 +97,13 @@ 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,8 +13,6 @@ 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 {
|
||||||
@@ -59,7 +57,13 @@ 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,8 +13,6 @@ 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 {
|
||||||
@@ -54,7 +52,13 @@ 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,8 +14,6 @@ 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 {
|
||||||
@@ -73,7 +71,13 @@ 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,8 +13,6 @@ 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 {
|
||||||
@@ -78,7 +76,13 @@ 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,17 +11,16 @@ import classes from "./board-layout-thumbnail.module.css";
|
|||||||
interface BoardLayoutThumbnailProps {
|
interface BoardLayoutThumbnailProps {
|
||||||
preview: BoardPreviewData | null;
|
preview: BoardPreviewData | null;
|
||||||
label: string;
|
label: string;
|
||||||
previewRowLimit?: number;
|
fitFullLayout?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactBoardLayoutThumbnailRows = 12;
|
const maxThumbnailRows = 12;
|
||||||
export const maxBoardLayoutThumbnailRows = 48;
|
|
||||||
|
|
||||||
export const BoardLayoutThumbnail = ({
|
export const BoardLayoutThumbnail = ({
|
||||||
preview,
|
preview,
|
||||||
label,
|
label,
|
||||||
previewRowLimit = compactBoardLayoutThumbnailRows,
|
fitFullLayout = false,
|
||||||
className,
|
className,
|
||||||
}: BoardLayoutThumbnailProps) => {
|
}: BoardLayoutThumbnailProps) => {
|
||||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||||
@@ -35,7 +34,7 @@ export const BoardLayoutThumbnail = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elements = projectBoardLayout(preview, layout, layout);
|
const elements = projectBoardLayout(preview, layout, layout);
|
||||||
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
|
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||||
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));
|
||||||
@@ -95,21 +94,9 @@ 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(
|
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
|
||||||
(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
|
||||||
@@ -117,16 +104,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 ${renderedWidth}`,
|
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
|
||||||
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
|
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{element.type === "section" ? (
|
{element.type === "section" ? (
|
||||||
<span
|
<span
|
||||||
className={classes.containerContents}
|
className={classes.containerContents}
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
|
||||||
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
|
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nestedItems.map((nestedItem) => (
|
{nestedItems.map((nestedItem) => (
|
||||||
@@ -136,11 +123,11 @@ export const BoardLayoutThumbnail = ({
|
|||||||
style={{
|
style={{
|
||||||
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
||||||
nestedItem.width,
|
nestedItem.width,
|
||||||
renderedWidth - nestedItem.xOffset,
|
element.width - nestedItem.xOffset,
|
||||||
)}`,
|
)}`,
|
||||||
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
||||||
nestedItem.height,
|
nestedItem.height,
|
||||||
renderedHeight - nestedItem.yOffset,
|
element.height - nestedItem.yOffset,
|
||||||
)}`,
|
)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,12 +30,11 @@ 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, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
|
import { BoardLayoutThumbnail } 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;
|
||||||
@@ -62,12 +61,18 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
data: boards = [],
|
data: boards = [],
|
||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
|
} = clientApi.board.getManageOverview.useQuery(
|
||||||
enabled: isOpen,
|
{ fullPreview: true },
|
||||||
});
|
{
|
||||||
|
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(() => {
|
||||||
@@ -86,7 +91,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
}, []);
|
}, []);
|
||||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
|
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||||
|
|
||||||
const spotlightAction = useMemo(
|
const spotlightAction = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -336,7 +341,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) })}
|
||||||
previewRowLimit={boardSwitcherPreview.previewRowLimit}
|
fitFullLayout
|
||||||
/>
|
/>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
<Card.Section withBorder px="sm" py={6}>
|
<Card.Section withBorder px="sm" py={6}>
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
|
.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: calc(var(--mantine-spacing-sm) / 2);
|
top: -34px;
|
||||||
right: calc(var(--mantine-spacing-sm) / 2);
|
right: 14px;
|
||||||
z-index: 201;
|
z-index: 201;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -52,6 +66,7 @@
|
|||||||
|
|
||||||
@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, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useIsFetching, 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,6 +19,7 @@ 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";
|
||||||
@@ -39,6 +40,7 @@ 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,
|
||||||
@@ -156,6 +158,24 @@ 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();
|
||||||
@@ -323,6 +343,11 @@ const LoadedBoardItemContent = ({
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
|
{isWidgetRefreshing && (
|
||||||
|
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</WidgetCardShell>
|
</WidgetCardShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -336,7 +361,6 @@ const LoadedBoardItemContent = ({
|
|||||||
widgetStateRef={widgetStateRef}
|
widgetStateRef={widgetStateRef}
|
||||||
widgetRuntimeRef={widgetRuntimeRef}
|
widgetRuntimeRef={widgetRuntimeRef}
|
||||||
sourceRef={sourceRef}
|
sourceRef={sourceRef}
|
||||||
disabled={isAdvanced}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
ref={sourceRef}
|
ref={sourceRef}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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";
|
||||||
@@ -22,7 +21,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
|
|||||||
interface BoardItemMenuProps {
|
interface BoardItemMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
previewDimensions: WidgetPreviewDimensions;
|
previewDimensions: { width: number; height: number; scale?: number };
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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";
|
||||||
@@ -31,7 +30,6 @@ 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 {
|
||||||
@@ -59,7 +57,8 @@ 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, registeredGridEditors, sourceSectionId } = innerProps;
|
const { board, commitSectionGrids, currentLayoutId, entry, 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],
|
||||||
@@ -236,7 +235,6 @@ 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);
|
||||||
|
|
||||||
@@ -247,10 +245,9 @@ export const useOpenItemMoveModal = () => {
|
|||||||
board,
|
board,
|
||||||
currentLayoutId,
|
currentLayoutId,
|
||||||
commitSectionGrids,
|
commitSectionGrids,
|
||||||
registeredGridEditors,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
|
[board, commitSectionGrids, currentLayoutId, openModal],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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";
|
||||||
@@ -40,11 +39,10 @@ type ToggleOption = [key: string, option: { type: string; skipContextMenu?: bool
|
|||||||
interface WidgetContextMenuProps {
|
interface WidgetContextMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
previewDimensions: WidgetPreviewDimensions;
|
previewDimensions: { width: number; height: number; scale?: number };
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +52,6 @@ export const WidgetContextMenu = ({
|
|||||||
previewDimensions,
|
previewDimensions,
|
||||||
widgetRuntimeRef,
|
widgetRuntimeRef,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
disabled = false,
|
|
||||||
children,
|
children,
|
||||||
}: WidgetContextMenuProps) => {
|
}: WidgetContextMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -164,7 +161,6 @@ export const WidgetContextMenu = ({
|
|||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
const openEditModal = useCallback(() => {
|
const openEditModal = useCallback(() => {
|
||||||
setMenuOpened(false);
|
|
||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
@@ -233,7 +229,6 @@ 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,
|
||||||
@@ -260,7 +255,7 @@ export const WidgetContextMenu = ({
|
|||||||
opened={menuOpened}
|
opened={menuOpened}
|
||||||
onChange={setMenuOpened}
|
onChange={setMenuOpened}
|
||||||
>
|
>
|
||||||
<Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
|
<Menu.ContextMenu>{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,14 +49,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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: var(--mantine-font-size-xs);
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -96,7 +94,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: var(--mantine-font-size-xxs);
|
font-size: 10px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: clip;
|
overflow: clip;
|
||||||
overflow-y: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport[data-board-hydrated="false"] {
|
.viewport[data-board-hydrated="false"] {
|
||||||
@@ -46,13 +45,4 @@
|
|||||||
--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: var(--homarr-z-index-widget-focus);
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-entry[data-grid-preview="true"] {
|
.board-grid-entry[data-grid-preview="true"] {
|
||||||
|
|||||||
@@ -3,22 +3,6 @@
|
|||||||
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,
|
||||||
@@ -27,14 +11,17 @@
|
|||||||
.viewport.scrollableViewport {
|
.viewport.scrollableViewport {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
/* The grid content inside has a fixed pixel width matching this viewport's full
|
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
|
||||||
outer width (needed for the drag/resize math), so there's no room to reserve
|
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
|
||||||
for a scrollbar without it eating into - or overlapping - the content on
|
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
|
||||||
whichever side it renders. Hiding the native scrollbar track entirely (wheel/
|
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
|
||||||
touch/drag scrolling still works) avoids that instead of trying to reserve
|
while growing during a drag happening anywhere else on the board - a nested scrollable
|
||||||
space for it. */
|
container sits inside that section's DOM tree, so the stray value cascades straight
|
||||||
scrollbar-width: none;
|
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in
|
||||||
-ms-overflow-style: none;
|
section-grid.tsx, which only kicks in when the property is entirely unset, not merely
|
||||||
|
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,25 +148,8 @@ 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 = Math.max(
|
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
|
||||||
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.
|
||||||
@@ -300,7 +283,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -335,9 +317,6 @@ 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-visible="false"] {
|
.header[data-desktop-visible="false"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,38 +73,39 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileZone {
|
.mobileIdentity,
|
||||||
|
.mobileActions {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileZone::-webkit-scrollbar {
|
.mobileIdentity {
|
||||||
display: none;
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileZone[data-zone="left"] {
|
.mobileIdentity > :last-child {
|
||||||
justify-content: flex-start;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileZone[data-zone="right"] {
|
.mobileIdentity h2 {
|
||||||
justify-content: flex-end;
|
overflow: hidden;
|
||||||
max-width: 72vw;
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileActions {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floatingControls {
|
.floatingControls {
|
||||||
@@ -154,12 +155,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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: grid;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floatingControls,
|
||||||
|
.floatingControlsCorner {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
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 { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
|
import {
|
||||||
|
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";
|
||||||
@@ -73,10 +81,7 @@ export const ConfigurableHeader = ({
|
|||||||
return (
|
return (
|
||||||
<BoardSwitcher>
|
<BoardSwitcher>
|
||||||
{(boardSwitcher) => {
|
{(boardSwitcher) => {
|
||||||
const renderItems = (
|
const renderItems = (items: HeaderItem[]) =>
|
||||||
items: HeaderItem[],
|
|
||||||
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
|
|
||||||
) =>
|
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<HeaderItem
|
<HeaderItem
|
||||||
key={getHeaderItemKey(item)}
|
key={getHeaderItemKey(item)}
|
||||||
@@ -90,8 +95,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={display.search}
|
searchDisplay={headerPreferences.searchDisplay}
|
||||||
logoDisplay={display.logo}
|
logoDisplay={headerPreferences.logoDisplay}
|
||||||
boardSwitcher={boardSwitcher}
|
boardSwitcher={boardSwitcher}
|
||||||
assistant={assistant}
|
assistant={assistant}
|
||||||
openDockerModal={openDockerModal}
|
openDockerModal={openDockerModal}
|
||||||
@@ -101,19 +106,13 @@ 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-visible={headerPreferences.visible}
|
data-desktop-visible={headerPreferences.visible}
|
||||||
data-advanced-focus-background
|
data-advanced-focus-background
|
||||||
data-app-shell-header
|
data-app-shell-header
|
||||||
>
|
>
|
||||||
@@ -121,26 +120,37 @@ 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, desktopDisplay)}
|
{renderItems(headerPreferences.zones.left)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.zone} data-zone="center">
|
<div className={classes.zone} data-zone="center">
|
||||||
{renderItems(headerPreferences.zones.center, desktopDisplay)}
|
{renderItems(headerPreferences.zones.center)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.zone} data-zone="right">
|
<div className={classes.zone} data-zone="right">
|
||||||
{renderItems(headerPreferences.zones.right, desktopDisplay)}
|
{renderItems(headerPreferences.zones.right)}
|
||||||
{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.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
|
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
|
||||||
{hasNavigation ? <ClientBurger /> : null}
|
{hasNavigation ? <ClientBurger /> : null}
|
||||||
{renderItems(headerPreferences.zones.left, mobileDisplay)}
|
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
|
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
|
||||||
{renderItems(headerPreferences.zones.center, mobileDisplay)}
|
{actions}
|
||||||
{renderItems(headerPreferences.zones.right, mobileDisplay)}
|
{boardEditAction}
|
||||||
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
{boardSettingsAction}
|
||||||
|
<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>
|
||||||
@@ -288,9 +298,7 @@ const HeaderItem = ({
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={boardUnavailableLabel}>
|
<Tooltip label={boardUnavailableLabel}>
|
||||||
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
||||||
<Avatar size={22} radius="sm">
|
<IconLayoutDashboard size={20} stroke={1.5} />
|
||||||
?
|
|
||||||
</Avatar>
|
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -408,19 +416,14 @@ const HeaderItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TourTarget id="board-user-menu">
|
<TourTarget id="board-user-menu">
|
||||||
<div className={classes.userMenu}>
|
<UserButtonClient
|
||||||
<UserButtonClient
|
avatar={avatar}
|
||||||
avatar={avatar}
|
isAdmin={isAdmin}
|
||||||
isAdmin={isAdmin}
|
isDockerEnabled={isDockerEnabled}
|
||||||
isDockerEnabled={isDockerEnabled}
|
boardSwitcher={boardSwitcher}
|
||||||
boardSwitcher={boardSwitcher}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TourTarget>
|
</TourTarget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBoardInitial = (name: string) => {
|
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
|
||||||
const firstCharacter = Array.from(name.trim())[0] ?? "?";
|
|
||||||
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
|
|
||||||
};
|
|
||||||
@@ -54,13 +54,11 @@ export const LazySpotlight = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isMounted && !isPreloaded) return null;
|
return isMounted || isPreloaded ? (
|
||||||
|
|
||||||
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 />}
|
||||||
{isMounted && <Spotlight />}
|
<Spotlight />
|
||||||
</>
|
</>
|
||||||
);
|
) : null;
|
||||||
};
|
};
|
||||||
@@ -22,17 +22,11 @@ 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 : 0;
|
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 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,7 +6,6 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconLayoutDashboard,
|
|
||||||
IconLogin,
|
IconLogin,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconReplace,
|
IconReplace,
|
||||||
@@ -17,8 +16,6 @@ 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";
|
||||||
@@ -49,8 +46,6 @@ 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);
|
||||||
@@ -72,15 +67,6 @@ 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>}
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ 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(),
|
||||||
@@ -170,6 +171,7 @@ 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([]);
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
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;
|
||||||
@@ -19,7 +8,6 @@ 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"]);
|
||||||
@@ -78,11 +66,3 @@ 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);
|
|
||||||
};
|
|
||||||
@@ -86,14 +86,9 @@ interface BoardItemPlacementRectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||||
const defaultManageOverviewPreviewRows = 12;
|
const maxManageOverviewPreviewRows = 12;
|
||||||
const maxManageOverviewPreviewRows = 48;
|
|
||||||
const manageOverviewInputSchema = z
|
const manageOverviewInputSchema = z
|
||||||
.object({
|
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
|
||||||
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>) => {
|
||||||
@@ -316,8 +311,6 @@ 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: {
|
||||||
@@ -395,7 +388,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),
|
||||||
lt(itemLayouts.yOffset, previewRowLimit),
|
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -420,7 +413,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),
|
||||||
lt(sectionLayouts.yOffset, previewRowLimit),
|
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(sectionLayouts.width, 0),
|
gt(sectionLayouts.width, 0),
|
||||||
gt(sectionLayouts.height, 0),
|
gt(sectionLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -455,7 +448,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),
|
||||||
lt(itemLayouts.yOffset, previewRowLimit),
|
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -2776,11 +2769,14 @@ 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,13 +84,15 @@ 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 = getReconciliationState({
|
const state: DockerReconciliationState = integrationMoved
|
||||||
integrationMoved,
|
? "moved"
|
||||||
linked,
|
: linked
|
||||||
represented: Boolean(integration || app),
|
? "linked"
|
||||||
recognized: Boolean(match),
|
: integration || app
|
||||||
});
|
? "represented"
|
||||||
const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous);
|
: match
|
||||||
|
? "newRecognized"
|
||||||
|
: "newApp";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
candidateKey: container.resourceId,
|
candidateKey: container.resourceId,
|
||||||
@@ -101,7 +103,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
|||||||
match,
|
match,
|
||||||
urlCandidates,
|
urlCandidates,
|
||||||
state,
|
state,
|
||||||
nextAction,
|
nextAction: integrationAmbiguous
|
||||||
|
? ("reviewIntegration" as const)
|
||||||
|
: appAmbiguous
|
||||||
|
? ("viewRepresentation" as const)
|
||||||
|
: nextActionForState(state),
|
||||||
representation: {
|
representation: {
|
||||||
integration: integration
|
integration: integration
|
||||||
? {
|
? {
|
||||||
@@ -141,100 +147,87 @@ 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) => ({
|
||||||
const integration = candidate.representation.integration;
|
key: candidate.candidateKey,
|
||||||
const app = candidate.representation.app;
|
identity: {
|
||||||
const itemCount = integration?.itemCount;
|
endpointId: candidate.endpointId,
|
||||||
|
nativeId: candidate.nativeId,
|
||||||
return {
|
name: candidate.container.name,
|
||||||
key: candidate.candidateKey,
|
integration: candidate.representation.integration,
|
||||||
identity: {
|
app: candidate.representation.app,
|
||||||
endpointId: candidate.endpointId,
|
},
|
||||||
nativeId: candidate.nativeId,
|
layers: [
|
||||||
name: candidate.container.name,
|
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
|
||||||
integration,
|
{
|
||||||
app,
|
layer: "integrationConfiguration" as const,
|
||||||
|
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: "docker" as const, status: "available" as const, nextAction: "none" as const },
|
layer: "authentication" as const,
|
||||||
{
|
status: "notObserved" as const,
|
||||||
layer: "integrationConfiguration" as const,
|
nextAction: candidate.representation.integration
|
||||||
status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)),
|
? ("testConnection" as const)
|
||||||
nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction),
|
: candidate.nextAction === "reviewIntegration"
|
||||||
},
|
? ("reviewIntegration" as const)
|
||||||
{
|
: candidate.match
|
||||||
layer: "authentication" as const,
|
? ("setupIntegration" as const)
|
||||||
status: "notObserved" as const,
|
: ("none" as const),
|
||||||
nextAction: getAuthenticationNextAction(
|
},
|
||||||
Boolean(integration),
|
{
|
||||||
Boolean(candidate.match),
|
layer: "apiRequest" as const,
|
||||||
candidate.nextAction,
|
status: "notObserved" as const,
|
||||||
),
|
nextAction: candidate.representation.integration
|
||||||
},
|
? ("openIntegrationDiagnostics" as const)
|
||||||
{
|
: ("none" as const),
|
||||||
layer: "apiRequest" as const,
|
},
|
||||||
status: "notObserved" as const,
|
{
|
||||||
nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const),
|
layer: "appRepresentation" as const,
|
||||||
},
|
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
|
||||||
{
|
nextAction: candidate.representation.app
|
||||||
layer: "appRepresentation" as const,
|
? ("none" as const)
|
||||||
status: app ? ("linked" as const) : ("missing" as const),
|
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation"
|
||||||
nextAction: getAppRepresentationNextAction(
|
? ("viewRepresentation" as const)
|
||||||
Boolean(app),
|
: ("createApp" as const),
|
||||||
candidate.representation.signals.ambiguous,
|
},
|
||||||
candidate.nextAction,
|
{
|
||||||
),
|
layer: "widgetConfiguration" as const,
|
||||||
},
|
status: candidate.representation.integration
|
||||||
{
|
? candidate.representation.integration.itemCount > 0
|
||||||
layer: "widgetConfiguration" as const,
|
? ("linked" as const)
|
||||||
status: getWidgetConfigurationStatus(itemCount),
|
: ("unused" as const)
|
||||||
nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const),
|
: ("notApplicable" as const),
|
||||||
},
|
nextAction:
|
||||||
{
|
candidate.representation.integration && candidate.representation.integration.itemCount === 0
|
||||||
layer: "widgetQuery" as const,
|
? ("addWidget" as const)
|
||||||
status: "notObserved" as const,
|
: ("none" as const),
|
||||||
nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const),
|
},
|
||||||
},
|
{
|
||||||
],
|
layer: "widgetQuery" as const,
|
||||||
status: candidate.state,
|
status: "notObserved" as const,
|
||||||
nextAction: candidate.nextAction,
|
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";
|
||||||
@@ -242,42 +235,6 @@ 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,
|
||||||
@@ -285,6 +242,5 @@ 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;
|
||||||
if (hasIntegration) return "configured" as const;
|
return hasIntegration ? ("configured" as const) : ("missing" as const);
|
||||||
return "missing" as const;
|
|
||||||
};
|
};
|
||||||
@@ -14,7 +14,6 @@ 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<{
|
||||||
@@ -63,7 +62,7 @@ vi.mock("@homarr/docker", () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
reset: resetDockerSingletonMock,
|
reset: () => undefined,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("@homarr/request-handler/docker", () => ({
|
vi.mock("@homarr/request-handler/docker", () => ({
|
||||||
@@ -502,7 +501,6 @@ 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 () => {
|
||||||
|
|||||||
@@ -50,10 +50,7 @@ export const immichRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.concat(createImmichIntegrationMiddleware("query"))
|
.concat(createImmichIntegrationMiddleware("query"))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
if (ctx.integration.kind === "mock") {
|
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
|
||||||
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" },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ 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 {
|
||||||
const command = argv[2];
|
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
|
||||||
return command !== undefined && harnessCommandNames.has(command);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile");
|
const mobileLayout = normalizedLayouts[0];
|
||||||
const baseLayout = normalizedLayouts.find((layout) => layout.role === "base");
|
const baseLayout = normalizedLayouts.at(-1);
|
||||||
if (mobileLayout && baseLayout) {
|
if (mobileLayout && baseLayout) {
|
||||||
await insertMissingProjectedPositionsAsync(
|
await insertMissingProjectedPositionsAsync(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -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 hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined;
|
const orderedLayouts =
|
||||||
let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts);
|
mobileLayout && baseLayout
|
||||||
if (hasProtectedLayouts) {
|
? [
|
||||||
orderedLayouts = [
|
mobileLayout,
|
||||||
mobileLayout,
|
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
|
||||||
...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts),
|
baseLayout,
|
||||||
];
|
]
|
||||||
}
|
: sourceLayouts.toSorted(compareBoardLayouts);
|
||||||
|
|
||||||
let previousBreakpoint = -1;
|
let previousBreakpoint = -1;
|
||||||
return orderedLayouts.map((layout, index) => {
|
return orderedLayouts.map((layout, index) => {
|
||||||
@@ -49,12 +49,7 @@ 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));
|
||||||
let role: LayoutRole = layout.role ?? "custom";
|
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,19 +63,13 @@ 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 },
|
||||||
mediaServer: { width: 3, height: 2 },
|
mediaMissing: { width: 4, height: 3 },
|
||||||
"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,7 +49,8 @@ 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);
|
||||||
router.push(`/boards/${name}`);
|
const layoutId = encodeURIComponent(result.layoutId);
|
||||||
|
router.push(`/boards/${name}?layout=${layoutId}&edit=true&add=true`);
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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;
|
||||||
@@ -19,38 +18,24 @@ const ActionButtons = ({
|
|||||||
submitLabel,
|
submitLabel,
|
||||||
cancelLabel,
|
cancelLabel,
|
||||||
loading,
|
loading,
|
||||||
form,
|
}: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading">) => {
|
||||||
}: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading" | "form">) => {
|
const t = useI18n();
|
||||||
const t = useI18n("common.action");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={onCancel} variant="subtle" color="gray">
|
<Button onClick={onCancel} variant="subtle" color="gray">
|
||||||
{cancelLabel ?? t("cancel")}
|
{cancelLabel ?? t("common.action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form={form} loading={loading}>
|
<Button type="submit" loading={loading}>
|
||||||
{submitLabel ?? t("saveChanges")}
|
{submitLabel ?? t("common.action.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModalFormFooter = ({
|
export const ModalFormFooter = ({ onCancel, submitLabel, cancelLabel, loading, leftSection }: ModalFormFooterProps) => {
|
||||||
onCancel,
|
|
||||||
form,
|
|
||||||
submitLabel,
|
|
||||||
cancelLabel,
|
|
||||||
loading,
|
|
||||||
leftSection,
|
|
||||||
}: ModalFormFooterProps) => {
|
|
||||||
const actionButtons = (
|
const actionButtons = (
|
||||||
<ActionButtons
|
<ActionButtons onCancel={onCancel} submitLabel={submitLabel} cancelLabel={cancelLabel} loading={loading} />
|
||||||
onCancel={onCancel}
|
|
||||||
form={form}
|
|
||||||
submitLabel={submitLabel}
|
|
||||||
cancelLabel={cancelLabel}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (leftSection) {
|
if (leftSection) {
|
||||||
|
|||||||
@@ -2430,16 +2430,6 @@
|
|||||||
"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."
|
||||||
@@ -4227,11 +4217,7 @@
|
|||||||
},
|
},
|
||||||
"mediaRequests-requestList": {
|
"mediaRequests-requestList": {
|
||||||
"name": "Media Requests List",
|
"name": "Media Requests List",
|
||||||
"description": "See a list of all media requests from your Seerr, Overseerr, or Jellyseerr instance",
|
"description": "See a list of all media requests from your 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"
|
||||||
@@ -4271,10 +4257,6 @@
|
|||||||
"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",
|
||||||
@@ -4680,6 +4662,12 @@
|
|||||||
"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": {
|
||||||
@@ -4853,8 +4841,7 @@
|
|||||||
"memory": "MEM",
|
"memory": "MEM",
|
||||||
"network": "NET",
|
"network": "NET",
|
||||||
"up": "UP",
|
"up": "UP",
|
||||||
"down": "DOWN",
|
"down": "DOWN"
|
||||||
"memoryTooltip": "{used} / {available} ({percent}%)"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systemDisks": {
|
"systemDisks": {
|
||||||
@@ -5426,7 +5413,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 at any additional breakpoint.",
|
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.",
|
||||||
"action": {
|
"action": {
|
||||||
"add": "Add layout"
|
"add": "Add layout"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ 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 = ["user"] as const;
|
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "user"] as const;
|
||||||
|
|
||||||
export const headerPreferenceItemIds = headerBuiltinItemIds;
|
export const headerPreferenceItemIds = headerBuiltinItemIds;
|
||||||
export const headerItemIds = headerBuiltinItemIds;
|
export const headerItemIds = headerBuiltinItemIds;
|
||||||
|
|||||||
@@ -23,16 +23,11 @@ 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,
|
||||||
@@ -261,10 +256,6 @@ 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,20 +62,9 @@ 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({
|
||||||
@@ -84,7 +73,6 @@ export function BeszelStatsView({
|
|||||||
timePeriod,
|
timePeriod,
|
||||||
visibility,
|
visibility,
|
||||||
columns,
|
columns,
|
||||||
availableHeight,
|
|
||||||
onSwitchToHistorical,
|
onSwitchToHistorical,
|
||||||
}: BeszelStatsViewProps) {
|
}: BeszelStatsViewProps) {
|
||||||
const t = useI18n("widget.beszelSystemStats");
|
const t = useI18n("widget.beszelSystemStats");
|
||||||
@@ -260,29 +248,14 @@ 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={effectiveColumns} spacing="md">
|
<SimpleGrid cols={columns} 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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: cpuData,
|
data: cpuData,
|
||||||
series: series.cpu,
|
series: series.cpu,
|
||||||
yAxisFormatter: chartAxisFormatters.percent,
|
yAxisFormatter: chartAxisFormatters.percent,
|
||||||
@@ -296,7 +269,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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: memoryData,
|
data: memoryData,
|
||||||
type: "stacked",
|
type: "stacked",
|
||||||
series: series.memory,
|
series: series.memory,
|
||||||
@@ -310,7 +283,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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: diskData,
|
data: diskData,
|
||||||
type: "stacked",
|
type: "stacked",
|
||||||
series: diskSeries,
|
series: diskSeries,
|
||||||
@@ -324,7 +297,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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: diskIOData,
|
data: diskIOData,
|
||||||
series: series.diskIO,
|
series: series.diskIO,
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
@@ -337,7 +310,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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: networkData,
|
data: networkData,
|
||||||
series: series.network,
|
series: series.network,
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
@@ -352,7 +325,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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: dockerCpuData,
|
data: dockerCpuData,
|
||||||
type: "stacked",
|
type: "stacked",
|
||||||
series: containerSeries,
|
series: containerSeries,
|
||||||
@@ -366,7 +339,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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: dockerMemoryData,
|
data: dockerMemoryData,
|
||||||
type: "stacked",
|
type: "stacked",
|
||||||
series: containerSeries,
|
series: containerSeries,
|
||||||
@@ -380,7 +353,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: chartHeight,
|
h: CHART_HEIGHT,
|
||||||
data: dockerNetworkData,
|
data: dockerNetworkData,
|
||||||
series: containerSeries,
|
series: containerSeries,
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -14,25 +13,15 @@ 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 { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]);
|
const indicatorSize = shouldScaleDown ? 3 : 4;
|
||||||
|
|
||||||
const cell = (
|
const cell = (
|
||||||
<Container
|
<Container
|
||||||
@@ -50,15 +39,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text ta={"center"} size={textSize} lh={1}>
|
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</Text>
|
</Text>
|
||||||
<NotificationIndicator
|
<NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} />
|
||||||
events={events}
|
|
||||||
size={indicatorSize}
|
|
||||||
marginTop={indicatorMarginTop}
|
|
||||||
visible={shouldShowIndicators}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,20 +70,19 @@ 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, marginTop, visible }: NotificationIndicatorProps) => {
|
const NotificationIndicator = ({ events, size, 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 || notificationEvents.length === 0) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
mt={marginTop}
|
mt={3}
|
||||||
w="fit-content"
|
w="fit-content"
|
||||||
maw="75%"
|
maw="75%"
|
||||||
h={size}
|
h={size}
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ const CalendarBase = ({
|
|||||||
monthCell: {
|
monthCell: {
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
padding: 3,
|
|
||||||
},
|
},
|
||||||
day: {
|
day: {
|
||||||
borderRadius: actualItemRadius,
|
borderRadius: actualItemRadius,
|
||||||
|
|||||||
@@ -132,9 +132,6 @@ 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>
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ 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>
|
||||||
|
|||||||
@@ -12,23 +12,6 @@ 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,
|
||||||
@@ -118,18 +101,6 @@ 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,
|
||||||
@@ -160,15 +131,6 @@ 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,9 +14,6 @@ interface ClockWeatherSummaryProps {
|
|||||||
locationName: string;
|
locationName: string;
|
||||||
isFahrenheit: boolean;
|
isFahrenheit: boolean;
|
||||||
animateIcon: boolean;
|
animateIcon: boolean;
|
||||||
colorByDayNight: boolean;
|
|
||||||
dayColor: string;
|
|
||||||
nightColor: string;
|
|
||||||
detailed: boolean;
|
detailed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,9 +23,6 @@ export const ClockWeatherSummary = ({
|
|||||||
locationName,
|
locationName,
|
||||||
isFahrenheit,
|
isFahrenheit,
|
||||||
animateIcon,
|
animateIcon,
|
||||||
colorByDayNight,
|
|
||||||
dayColor,
|
|
||||||
nightColor,
|
|
||||||
detailed,
|
detailed,
|
||||||
}: ClockWeatherSummaryProps) => {
|
}: ClockWeatherSummaryProps) => {
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
@@ -52,10 +46,6 @@ 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 ? (
|
||||||
@@ -64,9 +54,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), ...(weatherColor ? { color: weatherColor } : undefined) }}
|
style={zoomCompensatedSize(28)}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" fw={600} c={weatherColor}>
|
<Text size="sm" fw={600}>
|
||||||
{Math.round(temperature)}
|
{Math.round(temperature)}
|
||||||
{unit}
|
{unit}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -78,9 +68,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), ...(weatherColor ? { color: weatherColor } : undefined) }}
|
style={zoomCompensatedSize(26)}
|
||||||
/>
|
/>
|
||||||
<Text size="xs" c={weatherColor ?? "dimmed"}>
|
<Text size="xs" c="dimmed">
|
||||||
{Math.round(temperature)}
|
{Math.round(temperature)}
|
||||||
{unit}
|
{unit}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ 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,5 +1,3 @@
|
|||||||
import { isTrpcForbiddenError } from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
interface WidgetQueryState {
|
interface WidgetQueryState {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
@@ -11,7 +9,6 @@ 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 } from "./component";
|
import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component";
|
||||||
|
|
||||||
const permissions = {
|
const permissions = {
|
||||||
allowFullScreen: true,
|
allowFullScreen: true,
|
||||||
@@ -18,6 +18,12 @@ 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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconBrowserOff } from "@tabler/icons-react";
|
import { IconBrowserOff, IconProtocol } 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}>
|
||||||
@@ -24,8 +29,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={embedUrl}
|
src={safeEmbedUrl}
|
||||||
title={getFrameTitle(embedUrl)}
|
title={getFrameTitle(safeEmbedUrl)}
|
||||||
allow={allowedPermissions}
|
allow={allowedPermissions}
|
||||||
scrolling={allowScrolling ? "yes" : "no"}
|
scrolling={allowScrolling ? "yes" : "no"}
|
||||||
sandbox={sandboxFlags.join(" ")}
|
sandbox={sandboxFlags.join(" ")}
|
||||||
@@ -37,6 +42,12 @@ 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");
|
||||||
|
|
||||||
@@ -48,6 +59,21 @@ 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">,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 = () =>
|
||||||
@@ -22,17 +23,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe",
|
|||||||
supportsAdvancedFocus: false,
|
supportsAdvancedFocus: false,
|
||||||
icon: IconBrowser,
|
icon: IconBrowser,
|
||||||
contextActions: ({ options }) => {
|
contextActions: ({ options }) => {
|
||||||
const embedUrl = options.embedUrl;
|
const embedUrl = getSafeApplicationUrl(options.embedUrl);
|
||||||
if (typeof embedUrl !== "string" || embedUrl.trim() === "") return [];
|
return embedUrl
|
||||||
|
? [
|
||||||
return [
|
{
|
||||||
{
|
key: "open-iframe",
|
||||||
key: "open-iframe",
|
label: "widget.common.openInNewTab.label",
|
||||||
label: "widget.common.openInNewTab.label",
|
icon: IconExternalLink,
|
||||||
icon: IconExternalLink,
|
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
|
||||||
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
|
},
|
||||||
},
|
]
|
||||||
];
|
: [];
|
||||||
},
|
},
|
||||||
createOptions,
|
createOptions,
|
||||||
}).withDynamicImport(() => import("./component"));
|
}).withDynamicImport(() => import("./component"));
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"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 { MediaRequestsEmptyState } from "../empty-state";
|
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||||
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,7 +30,6 @@ 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))
|
||||||
@@ -49,16 +48,7 @@ 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) {
|
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||||
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 { MediaRequestsEmptyState } from "../empty-state";
|
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||||
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,16 +54,8 @@ 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
|
||||||
) {
|
)
|
||||||
return (
|
throw new NoIntegrationDataError();
|
||||||
<MediaRequestsEmptyState
|
|
||||||
title={t("empty.title")}
|
|
||||||
description={t("empty.description")}
|
|
||||||
integrationIds={integrationIds}
|
|
||||||
isEditMode={isEditMode}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -143,7 +143,6 @@
|
|||||||
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, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
import { createModal, 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,14 +68,10 @@ 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) => WidgetPreviewDimensions;
|
getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmbeddedIntegrationEditFormHandle {
|
export interface EmbeddedIntegrationEditFormHandle {
|
||||||
@@ -103,7 +99,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?: WidgetPreviewDimensions;
|
previewDimensions?: { width: number; height: number; scale?: number };
|
||||||
previewResize?: WidgetEditPreviewResizeOptions;
|
previewResize?: WidgetEditPreviewResizeOptions;
|
||||||
previewWrapper?: ComponentType<PropsWithChildren>;
|
previewWrapper?: ComponentType<PropsWithChildren>;
|
||||||
}
|
}
|
||||||
@@ -115,7 +111,7 @@ interface WidgetEditPreviewProps {
|
|||||||
state: WidgetEditModalState;
|
state: WidgetEditModalState;
|
||||||
itemId?: string;
|
itemId?: string;
|
||||||
boardId?: string;
|
boardId?: string;
|
||||||
dimensions?: WidgetPreviewDimensions;
|
dimensions?: { width: number; height: number; scale?: number };
|
||||||
integrationData: IntegrationSelectOption[];
|
integrationData: IntegrationSelectOption[];
|
||||||
onChangeOptions: (newOptions: Record<string, unknown>) => void;
|
onChangeOptions: (newOptions: Record<string, unknown>) => void;
|
||||||
resize?: {
|
resize?: {
|
||||||
@@ -183,9 +179,7 @@ const WidgetEditPreview = ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
let componentItemId = itemId;
|
let componentItemId = itemId;
|
||||||
if (kind === "assistant" || kind === "timer") {
|
if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`;
|
||||||
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) => {
|
||||||
@@ -674,7 +668,16 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
|||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<Box className={classes.footer}>
|
<Box className={classes.footer}>
|
||||||
<ModalFormFooter onCancel={actions.closeModal} form={widgetFormId} loading={isSubmitting} />
|
<Group justify="end" wrap="nowrap">
|
||||||
|
<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 } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, 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,10 +21,14 @@ 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(
|
||||||
@@ -32,24 +36,98 @@ export default function SpeedtestTrackerWidget({
|
|||||||
[successfulDashboards],
|
[successfulDashboards],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
const rangeHours = isAdvanced ? Number(advancedRange) : 12;
|
||||||
|
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 = options.showLatestResult;
|
const showLatestResult = isAdvanced || options.showLatestResult;
|
||||||
const showStats = options.showStats;
|
const showStats = isAdvanced || options.showStats;
|
||||||
const showRecentResults = options.showRecentResults;
|
const showRecentResults = isAdvanced || options.showRecentResults;
|
||||||
const showPingGraph = options.showPingGraph;
|
const showPingGraph = isAdvanced || 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 = !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
const noSectionsEnabled =
|
||||||
|
!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 result={combined.latestResult} width={width} compactSurface />
|
<LatestResultSection
|
||||||
|
result={combined.latestResult}
|
||||||
|
width={isAdvanced ? advancedSectionWidth : width}
|
||||||
|
compactSurface={!isAdvanced}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
const averages = showStats && combined.stats && (
|
const averages = showStats && combined.stats && (
|
||||||
<AveragesSection stats={combined.stats} width={width} compactSurface />
|
<AveragesSection
|
||||||
|
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,6 +5,7 @@ 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, Stack, Text } from "@mantine/core";
|
import { Box, Group, Paper, 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,43 +21,9 @@ export const CombinedNetworkTrafficChart = ({
|
|||||||
labelDisplayMode: LabelDisplayModeOption;
|
labelDisplayMode: LabelDisplayModeOption;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const chartData = usageOverTime.map((usage, index) => ({
|
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
|
||||||
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}
|
||||||
@@ -72,7 +38,28 @@ export const CombinedNetworkTrafficChart = ({
|
|||||||
chartType={hasShadow ? "area" : "line"}
|
chartType={hasShadow ? "area" : "line"}
|
||||||
labelDisplayMode={labelDisplayMode}
|
labelDisplayMode={labelDisplayMode}
|
||||||
advanced={advanced}
|
advanced={advanced}
|
||||||
tooltipLabel={tooltipLabel}
|
tooltipProps={{
|
||||||
|
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,24 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import type {
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
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 {
|
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||||
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 { YAxisProps } from "recharts";
|
import type { TooltipProps, 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";
|
||||||
@@ -33,7 +19,7 @@ export const CommonChart = ({
|
|||||||
title,
|
title,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
labelDisplayMode,
|
labelDisplayMode,
|
||||||
tooltipLabel,
|
tooltipProps,
|
||||||
yAxisProps,
|
yAxisProps,
|
||||||
lastValue,
|
lastValue,
|
||||||
chartType = "line",
|
chartType = "line",
|
||||||
@@ -45,10 +31,7 @@ export const CommonChart = ({
|
|||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
labelDisplayMode: LabelDisplayModeOption;
|
labelDisplayMode: LabelDisplayModeOption;
|
||||||
// What to show on hover. Defaults to lastValue when omitted. Pass a function to reflect
|
tooltipProps?: TooltipProps<number, any>;
|
||||||
// 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";
|
||||||
@@ -62,80 +45,63 @@ 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 =
|
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
|
||||||
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 = {
|
const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] };
|
||||||
padding: 5,
|
|
||||||
borderRadius: theme.radius[board.itemRadius],
|
|
||||||
};
|
|
||||||
if (advanced) {
|
if (advanced) {
|
||||||
backgroundColor =
|
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||||
"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 =
|
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
||||||
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
const showText = labelDisplayMode === "text" || 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 (
|
||||||
<Tooltip.Floating
|
<Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
|
||||||
label={resolvedTooltipLabel}
|
{data.length > 1 && height > 40 && !hovered && (
|
||||||
disabled={data.length <= 1 || !resolvedTooltipLabel}
|
<Group
|
||||||
>
|
pos={"absolute"}
|
||||||
<Card
|
top={0}
|
||||||
ref={ref}
|
left={0}
|
||||||
h={"100%"}
|
p={8}
|
||||||
pos={"relative"}
|
pt={6}
|
||||||
style={cardStyle}
|
gap={5}
|
||||||
p={0}
|
wrap={"nowrap"}
|
||||||
bg={backgroundColor}
|
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||||
radius={board.itemRadius}
|
align="center"
|
||||||
onPointerMove={handlePointerMove}
|
>
|
||||||
>
|
{showIcon && (
|
||||||
{data.length > 1 && height > 40 && !hovered && (
|
<Icon
|
||||||
<Group
|
color="var(--mantine-color-dimmed)"
|
||||||
pos={"absolute"}
|
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
||||||
top={0}
|
stroke={1.5}
|
||||||
left={0}
|
/>
|
||||||
p={8}
|
)}
|
||||||
pt={6}
|
{showText && (
|
||||||
gap={5}
|
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||||
wrap={"nowrap"}
|
{title}
|
||||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
</Text>
|
||||||
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)"
|
||||||
@@ -144,85 +110,43 @@ export const CommonChart = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showText && (
|
{showText && (
|
||||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{lastValue && (
|
{advanced && lastValue && (
|
||||||
<Text
|
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
|
||||||
c={"dimmed"}
|
|
||||||
size={height > 100 ? "md" : "xs"}
|
|
||||||
lineClamp={1}
|
|
||||||
>
|
|
||||||
{lastValue}
|
{lastValue}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Stack>
|
||||||
)}
|
</Center>
|
||||||
{advanced &&
|
) : (
|
||||||
data.length > 1 &&
|
<ChartComponent
|
||||||
height > 0 &&
|
data={data}
|
||||||
height <= 40 &&
|
dataKey={dataKey}
|
||||||
lastValue &&
|
h={"100%"}
|
||||||
!hovered && (
|
series={series}
|
||||||
<Center
|
curveType="monotone"
|
||||||
pos="absolute"
|
tickLine="none"
|
||||||
w="100%"
|
gridAxis="none"
|
||||||
h="100%"
|
withXAxis={false}
|
||||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
withYAxis={false}
|
||||||
>
|
withDots={false}
|
||||||
<Text size="xs" fw={600}>
|
bg={backgroundColor}
|
||||||
{lastValue}
|
styles={{ root: chartRootStyle }}
|
||||||
</Text>
|
tooltipAnimationDuration={200}
|
||||||
</Center>
|
tooltipProps={{
|
||||||
)}
|
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden
|
||||||
{data.length <= 1 ? (
|
// (needed to stop the chart line itself bleeding past its rounded corners).
|
||||||
<Center pos="absolute" w="100%" h="100%">
|
portal: typeof document !== "undefined" ? document.body : undefined,
|
||||||
<Stack px={"xs"} align={"center"} gap={4}>
|
...tooltipProps,
|
||||||
{showIcon && (
|
}}
|
||||||
<Icon
|
withTooltip={height >= 64}
|
||||||
color="var(--mantine-color-dimmed)"
|
yAxisProps={yAxisProps}
|
||||||
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
fillOpacity={chartType === "area" ? 0.3 : undefined}
|
||||||
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,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -26,16 +27,25 @@ export const SystemResourceCPUChart = ({
|
|||||||
title={invariantTechnicalLabels.cpu}
|
title={invariantTechnicalLabels.cpu}
|
||||||
icon={IconCpu}
|
icon={IconCpu}
|
||||||
lastValue={
|
lastValue={
|
||||||
cpuUsageOverTime.length > 0
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined
|
||||||
`${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}
|
||||||
tooltipLabel={(index) => `${Math.round(cpuUsageOverTime[index] ?? 0)}%`}
|
tooltipProps={{
|
||||||
|
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,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -26,16 +27,25 @@ export const SystemResourceGPUChart = ({
|
|||||||
title={invariantTechnicalLabels.gpu}
|
title={invariantTechnicalLabels.gpu}
|
||||||
icon={IconDeviceDesktop}
|
icon={IconDeviceDesktop}
|
||||||
lastValue={
|
lastValue={
|
||||||
gpuUsageOverTime.length > 0
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
gpuUsageOverTime.length > 0 ? `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` : undefined
|
||||||
`${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}
|
||||||
tooltipLabel={(index) => `${Math.round(gpuUsageOverTime[index] ?? 0)}%`}
|
tooltipProps={{
|
||||||
|
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,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -19,33 +20,15 @@ export const SystemResourceMemoryChart = ({
|
|||||||
labelDisplayMode: LabelDisplayModeOption;
|
labelDisplayMode: LabelDisplayModeOption;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const chartData = memoryUsageOverTime.map((usage, index) => ({
|
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||||
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]! /
|
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
|
||||||
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}
|
||||||
@@ -56,13 +39,22 @@ export const SystemResourceMemoryChart = ({
|
|||||||
labelDisplayMode={labelDisplayMode}
|
labelDisplayMode={labelDisplayMode}
|
||||||
advanced={advanced}
|
advanced={advanced}
|
||||||
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
|
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
|
||||||
lastValue={
|
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
|
||||||
percentageUsed !== undefined
|
|
||||||
? `${Math.round(percentageUsed * 100)}%`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
chartType={hasShadow ? "area" : "line"}
|
chartType={hasShadow ? "area" : "line"}
|
||||||
tooltipLabel={tooltipLabel}
|
tooltipProps={{
|
||||||
|
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,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -38,9 +39,18 @@ export const NetworkTrafficChart = ({
|
|||||||
chartType={hasShadow ? "area" : "line"}
|
chartType={hasShadow ? "area" : "line"}
|
||||||
labelDisplayMode={labelDisplayMode}
|
labelDisplayMode={labelDisplayMode}
|
||||||
advanced={advanced}
|
advanced={advanced}
|
||||||
tooltipLabel={(index) =>
|
tooltipProps={{
|
||||||
formatByteRate(Math.round(usageOverTime[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">
|
||||||
|
{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}`} data-is-day={isDay === false ? "false" : "true"}>
|
<span className={`weather-anim-wrapper ${animationClass}`}>
|
||||||
<WeatherIcon code={code} isDay={isDay} size={size} style={style} />
|
<WeatherIcon code={code} isDay={isDay} size={size} style={style} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
-14
@@ -189,9 +189,6 @@ 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
|
||||||
@@ -1136,9 +1133,6 @@ 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)
|
||||||
@@ -8852,9 +8846,6 @@ 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'}
|
||||||
@@ -23957,11 +23948,6 @@ 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)
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ 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
|
||||||
|
|||||||
@@ -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", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
|
p.Env = append(p.Env, "DEMO_MODE=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
|
||||||
p.Label += " (demo)"
|
p.Label += " (demo)"
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
|
|||||||
@@ -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.volume,
|
volume: row.container.Name + "_data",
|
||||||
running: row.container.Running(),
|
running: row.container.Running(),
|
||||||
port: row.container.HostPort(),
|
port: row.container.HostPort(),
|
||||||
}
|
}
|
||||||
@@ -315,10 +315,6 @@ 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)
|
||||||
|
|||||||
@@ -65,10 +65,6 @@ 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):
|
||||||
|
|||||||
Reference in new issue
Block a user