diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89aa72bba..39d2c4904 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,8 +320,12 @@ jobs: publish-preview: name: Publish multi-platform preview image - needs: [preview-amd64, preview-arm64] + needs: [container, preview-amd64, preview-arm64] 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 == 'push' && github.ref == 'refs/heads/release/v2') || @@ -375,9 +379,6 @@ jobs: ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} run: | 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[@]}" \ "${PREVIEW_IMAGE}@${AMD64_DIGEST}" \ "${PREVIEW_IMAGE}@${ARM64_DIGEST}" @@ -386,9 +387,6 @@ jobs: if: "!env.ACT" run: | tags=("${PREVIEW_TAG}") - if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then - tags+=("pr-6545") - fi for tag in "${tags[@]}"; do image="${PREVIEW_IMAGE}:${tag}" manifest="$(docker buildx imagetools inspect "$image" --raw)" @@ -435,6 +433,14 @@ jobs: core.info(`No package version found with tag "${tag}"`); 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({ package_type: "container", package_name: packageName, diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index fab706520..67fa8119f 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -115,6 +115,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio 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. ## Docker action payload migration diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index 215ea1bd5..f45fabdc5 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards. You can set the default color scheme for users without their own color scheme. +## 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 ### Default language diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 5f410a7f3..040e08c31 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai - **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page - **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 diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 16392ff8d..f5c4794b7 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -72,6 +72,7 @@ "@rexa-developer/tiks": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", + "@tanstack/query-sync-storage-persister": "catalog:", "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 5c18c31f6..d7324e679 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -1,20 +1,17 @@ import { dehydrate, QueryClient } from "@tanstack/react-query"; 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 { - createSessionQueryPersister, + createSessionQueryPersistence, getQueryPersistenceStorageKey, queryPersistenceBuster, - queryPersistenceMaxDataBytes, shouldPersistDashboardQuery, } from "./query-persistence"; -const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => { +const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => { const queryClient = new QueryClient(); - queryClient.setQueryData(queryKey, data, { updatedAt }); + queryClient.setQueryData(queryKey, data); const query = queryClient.getQueryCache().find({ queryKey }); if (!query) throw new Error("Expected query to be present"); return { query, queryClient }; @@ -37,314 +34,98 @@ const createMemoryStorage = (): Storage => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - window.sessionStorage.clear(); }); -describe("widget query persistence", () => { - test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => { - const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); +describe("dashboard query persistence", () => { + test("keeps usable dashboard data and skips volatile or unrelated queries", () => { + const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 }); const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]); - const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" }); - const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], { - value: "x".repeat(queryPersistenceMaxDataBytes + 1), + 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 pendingClient = new QueryClient(); const pending = pendingClient.getQueryCache().build(pendingClient, { queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], 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(supporting.query)).toBe(true); - expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); expect(shouldPersistDashboardQuery(ping.query)).toBe(false); - expect(shouldPersistDashboardQuery(oversized.query)).toBe(false); + expect(shouldPersistDashboardQuery(beszel.query)).toBe(false); + expect(shouldPersistDashboardQuery(customApi.query)).toBe(false); + expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); expect(shouldPersistDashboardQuery(pending)).toBe(false); + expect(shouldPersistDashboardQuery(failed.query)).toBe(true); }); - test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => { - expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a")); - expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b")); - expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a")); - - const userAKey = getQueryPersistenceStorageKey("user-a"); - window.sessionStorage.setItem(userAKey, "a"); - createSessionQueryPersister("user-a").removeClient(); - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - }); - - test("evicts every other scope so signing out cannot leave data behind", () => { - const userAKey = getQueryPersistenceStorageKey("user-a"); - const anonymousKey = getQueryPersistenceStorageKey(null); - window.sessionStorage.setItem(userAKey, "a"); - window.sessionStorage.setItem(anonymousKey, "anonymous"); - window.sessionStorage.setItem("unrelated", "kept"); - - createSessionQueryPersister("user-b"); - - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - expect(window.sessionStorage.getItem(anonymousKey)).toBeNull(); - expect(window.sessionStorage.getItem("unrelated")).toBe("kept"); - }); - - test("never restores another session scope", async () => { - const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; - const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 }); - const userAPersister = createSessionQueryPersister("user-a"); - userAPersister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - userAPersister.flush(); - expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); - - expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined(); - }); - - test("stops persisting once the provider tears the cache down", async () => { - const storage = createMemoryStorage(); - const persister = createSessionQueryPersister("user-a", storage); - const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { - temp: 21, - }); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - persister.stop(); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1); - }); - - test("removes the cache instead of storing an empty one", () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - window.sessionStorage.setItem(key, "stale"); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect(window.sessionStorage.getItem(key)).toBeNull(); - }); - - test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => { + test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => { vi.useFakeTimers(); - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { + const storage = createMemoryStorage(); + const userA = createSessionQueryPersistence("user-a", storage); + const userB = createSessionQueryPersistence("user-b", storage); + const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { startsAt: new Date("2026-08-22T12:00:00.000Z"), }); - query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" }); - expect(shouldPersistDashboardQuery(query)).toBe(true); + const cachedQuery = queryClient.getQueryCache().find({ + queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], + }); + if (!cachedQuery) throw new Error("Expected cached query to be present"); + cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" }); + queryClient.getMutationCache().build( + queryClient, + { mutationKey: ["integration", "create"], mutationFn: async () => undefined }, + { + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isPaused: true, + status: "pending", + submittedAt: Date.now(), + variables: { apiKey: "secret" }, + }, + ); const persistedClient: PersistedClient = { timestamp: Date.now(), buster: queryPersistenceBuster, - clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), + clientState: dehydrate(queryClient, userA.dehydrateOptions), }; - persister.persistClient(persistedClient); - persister.flush(); - const restored = await persister.restoreClient(); - const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined; - expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z")); + expect(persistedClient.clientState.mutations).toHaveLength(0); + + await userA.persister.persistClient(persistedClient); + await vi.advanceTimersByTimeAsync(1_000); + + 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.error).toBeNull(); - expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true); - expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error"); + expect(await userB.persister.restoreClient()).toBeUndefined(); - window.sessionStorage.setItem(key, "not-valid-superjson"); - expect(persister.restoreClient()).toBeUndefined(); - expect(window.sessionStorage.getItem(key)).toBeNull(); + await userA.persister.removeClient(); + expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull(); }); - test("sanitizes structurally valid but unsafe stored state before hydration", async () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const { queryClient } = createSuccessfulQuery( - [["widget", "calendar", "findAllEvents"], { type: "query" }], - [{ title: "safe" }], - ); - const validQuery = dehydrate(queryClient).queries[0]; - if (!validQuery) throw new Error("Expected a dehydrated query"); - - window.sessionStorage.setItem( - key, - stringify({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [{ state: { status: "pending" } }], - queries: [ - { - ...validQuery, - state: { - ...validQuery.state, - error: new Error("private refresh error"), - fetchStatus: "fetching", - status: "error", - }, - }, - { - ...validQuery, - queryHash: "unrelated", - queryKey: [["board", "getBoardByName"], { type: "query" }], - }, - { queryHash: "malformed", queryKey: [["widget", "weather"]], state: null }, - { - ...validQuery, - queryHash: "missing-timestamp", - state: { ...validQuery.state, dataUpdatedAt: undefined }, - }, - ], - }, - }), - ); - - const restored = await createSessionQueryPersister(scope).restoreClient(); - expect(restored?.clientState.mutations).toEqual([]); - expect(restored?.clientState.queries).toHaveLength(1); - expect(restored?.clientState.queries[0]?.state.status).toBe("success"); - expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle"); - expect(restored?.clientState.queries[0]?.state.error).toBeNull(); - }); - - test.each([ - ["expired", 1, queryPersistenceBuster], - ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"], - ["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster], - ])("discards %s cache entries", async (_case, timestamp, storedBuster) => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z")); - const scope = "user-a"; - const persister = createSessionQueryPersister(scope); - const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { - temp: 21, - }); - persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); - persister.flush(); - - const restoredClient = new QueryClient(); - await persistQueryClientRestore({ - queryClient: restoredClient, - persister, - maxAge: 5 * 60 * 1_000, - buster: queryPersistenceBuster, - }); - - expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined(); - expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull(); - }); - - test("quota retry writes the cache after evicting the oldest query", async () => { - const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1); - const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2); - const storage = createMemoryStorage(); - const setItem = storage.setItem.bind(storage); - const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => { - const client = parse(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(() => { + test("treats unavailable session storage as an empty cache", async () => { + const blockedStorage = createMemoryStorage(); + blockedStorage.getItem = () => { throw new DOMException("blocked", "SecurityError"); - }); + }; + blockedStorage.removeItem = () => { + throw new DOMException("blocked", "SecurityError"); + }; - expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow(); - expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined(); + const persistence = createSessionQueryPersistence("blocked", blockedStorage); + expect(await persistence.persister.restoreClient()).toBeUndefined(); + expect(() => persistence.persister.removeClient()).not.toThrow(); }); }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index e5145ff5c..dbfd90c2f 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -1,13 +1,12 @@ import type { QueryKey } from "@tanstack/react-query"; -import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { removeOldestQuery } from "@tanstack/react-query-persist-client"; +import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client"; import { parse, stringify } from "superjson"; -import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; +import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; -export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; -export const queryPersistenceMaxDataBytes = 256 * 1024; -export const queryPersistenceBuster = "v2-session-superjson"; +export const queryPersistenceBuster = "v6-dashboard-data"; const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; @@ -16,95 +15,33 @@ export const getQueryPersistenceStorageKey = (scope: string | null) => interface PersistableQuery { queryKey: QueryKey; - state: { - data: unknown; - dataUpdatedAt: number; - }; + state: { data: unknown; status: string }; } -export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void }; - -const textEncoder = new TextEncoder(); -const budgetVerdictCache = new WeakMap(); - -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) => - holdsDashboardData(query) && fitsStorageBudget(query.state.data); + query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey); -type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; - -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 => - 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, +const serializePersistedClient = (client: PersistedClient) => + stringify({ + ...client, clientState: { - mutations: [], - queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery), + ...client.clientState, + queries: client.clientState.queries.map((query) => ({ + ...query, + state: { + ...query.state, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + status: "success", + }, + })), }, - }; -}; + }); -const getSessionStorage = (): Storage | undefined => { +const getSessionStorage = () => { if (typeof window === "undefined") return undefined; try { @@ -114,113 +51,47 @@ const getSessionStorage = (): Storage | undefined => { } }; -const removeStorageItem = (storage: Storage | undefined, key: string) => { - 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 | 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); - }; +const protectStorage = (storage: Storage | undefined) => { + if (!storage) return undefined; return { - persistClient(client) { - if (!storage || stopped) return; - pendingClient = client; - if (persistTimer !== undefined) return; - persistTimer = setTimeout(flush, 0); - }, - restoreClient() { + getItem(key: string) { try { - const storedValue = storage?.getItem(key); - if (!storedValue) return undefined; - - const client = sanitizeRestoredClient(parse(storedValue)); - if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) { - removeStorageItem(storage, key); - return undefined; - } - - return client; + return storage.getItem(key); + } catch { + return null; + } + }, + setItem(key: string, value: string) { + storage.setItem(key, value); + }, + removeItem(key: string) { + try { + storage.removeItem(key); } catch { - removeStorageItem(storage, key); 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, + }, +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index dc5242de4..5b6621679 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { @@ -25,6 +25,8 @@ import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { dashboardSupportingQueryPolicies, + isTrpcForbiddenError, + isWidgetDataQueryKey, queryCacheDefaultGcTimeMs, queryCacheDefaultRefetchIntervalMs, queryCacheDefaultStaleTimeMs, @@ -36,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; -import { - createSessionQueryPersister, - queryPersistenceBuster, - queryPersistenceMaxAgeMs, - shouldPersistDashboardQuery, -} from "./query-persistence"; -import type { SessionQueryPersister } from "./query-persistence"; +import { createSessionQueryPersistence } from "./query-persistence"; +import type { SessionQueryPersistence } from "./query-persistence"; import { createQueryRetry } from "./query-retry"; const DevelopmentTools = @@ -75,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { const { data: session } = useSession(); const sessionQueryScope = getSessionQueryScope(session); const [initialSessionQueryScope] = useState(() => sessionQueryScope); - const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope)); + const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope)); const handleScopeChange = useCallback(() => { - void queryPersister.removeClient(); + void queryPersistence.persister.removeClient(); reloadPage(); - }, [queryPersister]); + }, [queryPersistence]); return ( - {children} + {children} ); } @@ -96,8 +93,8 @@ const reloadPage = () => window.location.reload(); const ScopedTRPCReactProvider = ({ children, - queryPersister, -}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => { + queryPersistence, +}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => { const wsClient = useMemo( () => createWSClient({ @@ -114,6 +111,13 @@ const ScopedTRPCReactProvider = ({ ); const [queryClient] = useState(() => { 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: { queries: { staleTime: queryCacheDefaultStaleTimeMs, @@ -152,15 +156,6 @@ const ScopedTRPCReactProvider = ({ return client; }); - useEffect(() => { - const flush = () => queryPersister.flush(); - window.addEventListener("pagehide", flush); - return () => { - window.removeEventListener("pagehide", flush); - flush(); - queryPersister.stop(); - }; - }, [queryPersister]); useEffect(() => () => queryClient.clear(), [queryClient]); const trpcClient = useMemo(() => { @@ -211,15 +206,7 @@ const ScopedTRPCReactProvider = ({ return ( - + {children} {process.env.NODE_ENV === "development" && } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx index 3db1ab746..985b75d1a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; -interface CopyAiPromptButtonBaseProps { +interface CopyAiPromptButtonProps { rawResponse?: string | null; request?: 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 = ({ rawResponse, - draft, getDraft, - diagnostics, getDiagnostics, request, documentationUrl, @@ -35,23 +28,14 @@ export const CopyAiPromptButton = ({ const t = useI18n("customWidget"); const assistant = useOptionalHomarrAssistant(); - const readDraft = () => { - if (getDraft) return getDraft(); - return draft; - }; - const readDiagnostics = () => { - if (getDiagnostics) return getDiagnostics(); - return diagnostics; - }; - const handleAssistant = () => { const prompt = buildCustomWidgetAssistantPrompt( undefined, rawResponse, - readDraft(), + getDraft(), request, documentationUrl, - readDiagnostics(), + getDiagnostics(), ); if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return; showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); @@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({ const value = buildCustomWidgetAiPrompt( undefined, rawResponse, - readDraft(), + getDraft(), request, documentationUrl, - readDiagnostics(), + getDiagnostics(), ); await navigator.clipboard.writeText(value); showSuccessNotification({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx index 813b191cc..adefc229a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface ConfigMapsTableComponentProps { @@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery( { contextId }, - { - initialData: initialConfigMaps, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialConfigMaps), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx index dcc2a2e1d..372dd5da9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx @@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import KubernetesErrorPage from "../cluster-dashboard/error"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; dayjs.extend(relativeTime); @@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery( { contextId }, - { - initialData: initialIngresses, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialIngresses), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts new file mode 100644 index 000000000..217a66a70 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts @@ -0,0 +1,18 @@ +interface QueryWithStatus { + state: { + status: string; + }; +} + +const getErrorRefetchInterval = (query: QueryWithStatus) => { + if (query.state.status === "error") return 30_000; + return false; +}; + +export const createKubernetesResourceQueryOptions = (initialData: TData) => ({ + initialData, + refetchOnMount: "always" as const, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: getErrorRefetchInterval, +}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx index d70da8bc4..0c1a01c9d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx @@ -15,6 +15,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface NamespacesTableComponentProps { @@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery( { contextId }, - { - initialData: initialNamespaces, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialNamespaces), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx index 66e64059c..112ee25f3 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx @@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import KubernetesErrorPage from "../cluster-dashboard/error"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; dayjs.extend(relativeTime); @@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery( { contextId }, - { - initialData: initialNodes, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialNodes), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx index 855090602..83395f2b7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface PodsTableComponentProps { @@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) { const { data } = clientApi.kubernetes.pods.getPods.useQuery( { contextId }, - { - initialData: initialPods, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialPods), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx index 2c31987a7..4e3cab8c7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface SecretsTableComponentProps { @@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery( { contextId }, - { - initialData: initialSecrets, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialSecrets), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx index aa2c87c77..3a206cda9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx @@ -14,6 +14,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface ServicesTableComponentProps { @@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo const { data } = clientApi.kubernetes.services.getServices.useQuery( { contextId }, - { - initialData: initialServices, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialServices), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx index 4b54dbe34..e9da29962 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface VolumesTableComponentProps { @@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery( { contextId }, - { - initialData: initialVolumes, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialVolumes), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx index 2d959d24d..55462c3e4 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -11,16 +11,17 @@ import classes from "./board-layout-thumbnail.module.css"; interface BoardLayoutThumbnailProps { preview: BoardPreviewData | null; label: string; - fitFullLayout?: boolean; + previewRowLimit?: number; className?: string; } -const maxThumbnailRows = 12; +const compactBoardLayoutThumbnailRows = 12; +export const maxBoardLayoutThumbnailRows = 48; export const BoardLayoutThumbnail = ({ preview, label, - fitFullLayout = false, + previewRowLimit = compactBoardLayoutThumbnailRows, className, }: BoardLayoutThumbnailProps) => { const canvasClassName = [classes.canvas, className].filter(Boolean).join(" "); @@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({ } const elements = projectBoardLayout(preview, layout, layout); - const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows; + const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows)); const roots = preview.sections .filter((section) => section.kind === "empty") .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id)); @@ -94,9 +95,21 @@ export const BoardLayoutThumbnail = ({ }} > {laneElements.map((element) => { + const renderedWidth = Math.min(element.width, columnCount - element.xOffset); + const renderedHeight = Math.min(element.height, rowCount - element.yOffset); const nestedItems = element.type === "section" - ? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id) + ? elements.filter( + (candidate) => + candidate.type === "item" && + candidate.sectionId === element.id && + candidate.xOffset >= 0 && + candidate.xOffset < renderedWidth && + candidate.yOffset >= 0 && + candidate.yOffset < renderedHeight && + candidate.width > 0 && + candidate.height > 0, + ) : []; return ( {element.type === "section" ? ( {nestedItems.map((nestedItem) => ( @@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({ style={{ gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min( nestedItem.width, - element.width - nestedItem.xOffset, + renderedWidth - nestedItem.xOffset, )}`, gridRow: `${nestedItem.yOffset + 1} / span ${Math.min( nestedItem.height, - element.height - nestedItem.yOffset, + renderedHeight - nestedItem.yOffset, )}`, }} > diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 786ad582a..14dfaa42c 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; -import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; +import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail"; import classes from "./board-switcher.module.css"; export const boardSwitcherHotkey = "shift+c"; +const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const; export interface BoardSwitcherControls { open: () => void; @@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { data: boards = [], isPending, isError, - } = clientApi.board.getManageOverview.useQuery( - { fullPreview: true }, - { - enabled: isOpen, - }, - ); + } = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, { + enabled: isOpen, + }); 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], ); const filteredBoards = useMemo(() => { @@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { setSearch(""); setActiveIndex(0); }, []); - const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); + const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview); const spotlightAction = useMemo( () => ({ @@ -341,7 +336,7 @@ const getBoardSwitcherResults = ({ diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 8db0532f8..e1ac6524b 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -10,6 +10,7 @@ import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { InlineConfirmMenuItem } from "@homarr/ui"; import type { WidgetDefinition } from "@homarr/widgets/definition"; +import type { WidgetPreviewDimensions } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; @@ -21,7 +22,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit- interface BoardItemMenuProps { item: SectionItem; definition: WidgetDefinition; - previewDimensions: { width: number; height: number; scale?: number }; + previewDimensions: WidgetPreviewDimensions; resetErrorBoundary?: () => void; } diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index c8821aac4..5057907d2 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -25,6 +25,7 @@ import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definit import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation"; +import type { WidgetPreviewDimensions } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useAdvancedFocus } from "../advanced-focus/context"; @@ -39,7 +40,7 @@ type ToggleOption = [key: string, option: { type: string; skipContextMenu?: bool interface WidgetContextMenuProps { item: SectionItem; definition: WidgetDefinition; - previewDimensions: { width: number; height: number; scale?: number }; + previewDimensions: WidgetPreviewDimensions; widgetStateRef: MutableRefObject | null>; widgetRuntimeRef: WidgetRuntimeRef; sourceRef: RefObject; diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx index 427988c74..f36f3959c 100644 --- a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -54,11 +54,13 @@ export const LazySpotlight = () => { }; }, []); - return isMounted || isPreloaded ? ( + if (!isMounted && !isPreloaded) return null; + + return ( <> {isPreloaded &&