diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89aa72bba..99d8c3050 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,13 @@ jobs: core.info(`No package version found with tag "${tag}"`); return; } + const targetTags = target.metadata?.container?.tags ?? []; + if (targetTags.includes("v2")) { + core.setFailed( + `Package version ${target.id} also carries protected tag "v2"; refusing cleanup.`, + ); + return; + } await github.rest.packages.deletePackageVersionForOrg({ package_type: "container", package_name: packageName, diff --git a/apps/docs/docs/advanced/command-line/index.mdx b/apps/docs/docs/advanced/command-line/index.mdx index 0e77162a2..9acbdc333 100644 --- a/apps/docs/docs/advanced/command-line/index.mdx +++ b/apps/docs/docs/advanced/command-line/index.mdx @@ -36,7 +36,7 @@ pnpm cli harness setup feature-board The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later: -The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets. +The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use bundled same-origin demo assets. ```sh pnpm cli harness port feature-board 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/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 5c18c31f6..801928aed 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 @@ -9,6 +9,7 @@ import { getQueryPersistenceStorageKey, queryPersistenceBuster, queryPersistenceMaxDataBytes, + queryPersistenceThrottleMs, shouldPersistDashboardQuery, } from "./query-persistence"; @@ -54,6 +55,15 @@ describe("widget query persistence", () => { queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], queryFn: () => Promise.resolve([]), }); + const forbidden = createSuccessfulQuery( + [["widget", "calendar", "findAllEvents"], { type: "query" }], + [{ title: "revoked" }], + ); + forbidden.query.setState({ + ...forbidden.query.state, + error: Object.assign(new Error("access denied"), { data: { code: "FORBIDDEN" } }), + status: "error", + }); expect(shouldPersistDashboardQuery(widget.query)).toBe(true); expect(shouldPersistDashboardQuery(supporting.query)).toBe(true); @@ -61,6 +71,7 @@ describe("widget query persistence", () => { expect(shouldPersistDashboardQuery(ping.query)).toBe(false); expect(shouldPersistDashboardQuery(oversized.query)).toBe(false); expect(shouldPersistDashboardQuery(pending)).toBe(false); + expect(shouldPersistDashboardQuery(forbidden.query)).toBe(false); }); test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => { @@ -170,6 +181,19 @@ describe("widget query persistence", () => { expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true); expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error"); + query.setState({ + ...query.state, + error: Object.assign(new Error("access denied"), { data: { code: "FORBIDDEN" } }), + status: "error", + }); + persister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: dehydrate(queryClient), + }); + persister.flush(); + expect(window.sessionStorage.getItem(key)).toBeNull(); + window.sessionStorage.setItem(key, "not-valid-superjson"); expect(persister.restoreClient()).toBeUndefined(); expect(window.sessionStorage.getItem(key)).toBeNull(); @@ -280,6 +304,10 @@ describe("widget query persistence", () => { const restored = await persister.restoreClient(); expect(restored?.clientState.queries).toHaveLength(1); expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]); + + persister.persistClient(persistedClient); + persister.flush(); + expect(setItemSpy).toHaveBeenCalledTimes(3); }); test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => { @@ -302,10 +330,12 @@ describe("widget query persistence", () => { clientState: dehydrate(latest.queryClient), }); + expect(setItem).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(queryPersistenceThrottleMs - 1); expect(setItem).not.toHaveBeenCalled(); persister.flush(); expect(setItem).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(queryPersistenceThrottleMs); expect(setItem).toHaveBeenCalledOnce(); const restored = await persister.restoreClient(); 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..9dbcfe169 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -1,12 +1,14 @@ import type { QueryKey } from "@tanstack/react-query"; import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; -import { removeOldestQuery } from "@tanstack/react-query-persist-client"; import { parse, stringify } from "superjson"; import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; export const queryPersistenceMaxDataBytes = 256 * 1024; +export const queryPersistenceMaxCacheBytes = 2 * 1024 * 1024; +export const queryPersistenceThrottleMs = 1_000; +export const queryPersistenceIdleTimeoutMs = 1_000; export const queryPersistenceBuster = "v2-session-superjson"; const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; @@ -19,6 +21,7 @@ interface PersistableQuery { state: { data: unknown; dataUpdatedAt: number; + error?: unknown; }; } @@ -27,6 +30,28 @@ export type SessionQueryPersister = Persister & { flush: () => void; stop: () => const textEncoder = new TextEncoder(); const budgetVerdictCache = new WeakMap(); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +interface ForbiddenTrpcError { + data: { code: "FORBIDDEN" }; +} + +const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => { + if (!isRecord(error) || !isRecord(error.data)) return false; + return error.data.code === "FORBIDDEN"; +}; + +const isQuotaExceededError = (error: unknown): boolean => { + if (!isRecord(error)) return false; + return ( + error.name === "QuotaExceededError" || + error.name === "NS_ERROR_DOM_QUOTA_REACHED" || + error.code === 22 || + error.code === 1014 + ); +}; + const measureFitsBudget = (data: unknown) => { try { return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes; @@ -35,6 +60,8 @@ const measureFitsBudget = (data: unknown) => { } }; +const measureSerializedBytes = (data: unknown) => textEncoder.encode(stringify(data)).byteLength; + const fitsStorageBudget = (data: unknown) => { if (typeof data !== "object" || data === null) return measureFitsBudget(data); @@ -53,10 +80,25 @@ const holdsDashboardData = (query: PersistableQuery) => isWidgetDataQueryKey(query.queryKey); export const shouldPersistDashboardQuery = (query: PersistableQuery) => - holdsDashboardData(query) && fitsStorageBudget(query.state.data); + holdsDashboardData(query) && !isTrpcForbiddenError(query.state.error) && fitsStorageBudget(query.state.data); type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; +const keepNewestQueries = (client: PersistedClient, maximumQueries: number): PersistedClient | undefined => { + if (maximumQueries <= 0) return undefined; + if (client.clientState.queries.length <= maximumQueries) return client; + + return { + ...client, + clientState: { + ...client.clientState, + queries: client.clientState.queries + .toSorted((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt) + .slice(0, maximumQueries), + }, + }; +}; + const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ ...query, state: { @@ -76,13 +118,12 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ ...client, clientState: { mutations: [], - queries: client.clientState.queries.map(sanitizePersistedQuery), + queries: client.clientState.queries + .filter((query) => !isTrpcForbiddenError(query.state.error)) + .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; @@ -146,15 +187,53 @@ export const createSessionQueryPersister = ( const key = getQueryPersistenceStorageKey(scope); removeForeignScopes(storage, key); let pendingClient: PersistedClient | undefined; - let persistTimer: ReturnType | undefined; + let persistDelayTimer: ReturnType | undefined; + let persistIdleCallback: number | undefined; + let persistedQueryLimit = Number.POSITIVE_INFINITY; + let persistenceDisabled = false; let stopped = false; - const persistNow = (client: PersistedClient) => { - if (!storage) return; - let candidate: PersistedClient | undefined = sanitizePersistedClient(client); - let errorCount = 0; + const trimToQueryLimit = (client: PersistedClient): PersistedClient | undefined => { + const limitedClient = keepNewestQueries(client, persistedQueryLimit); + if (!limitedClient || measureSerializedBytes(limitedClient) <= queryPersistenceMaxCacheBytes) return limitedClient; - if (candidate.clientState.queries.length === 0) { + const emptyClient: PersistedClient = { + ...limitedClient, + clientState: { ...limitedClient.clientState, queries: [] }, + }; + let remainingBytes = queryPersistenceMaxCacheBytes - measureSerializedBytes(emptyClient); + const selectedQueries: DehydratedQuery[] = []; + const newestQueries = limitedClient.clientState.queries.toSorted( + (left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt, + ); + + for (const query of newestQueries) { + const queryBytes = measureSerializedBytes(query) + 1; + if (queryBytes > remainingBytes) continue; + selectedQueries.push(query); + remainingBytes -= queryBytes; + } + + return { + ...limitedClient, + clientState: { ...limitedClient.clientState, queries: selectedQueries }, + }; + }; + + const persistNow = (client: PersistedClient) => { + if (!storage || persistenceDisabled) return; + let candidate: PersistedClient | undefined; + let sanitizedQueryCount = 0; + try { + const sanitizedClient = sanitizePersistedClient(client); + sanitizedQueryCount = sanitizedClient.clientState.queries.length; + candidate = trimToQueryLimit(sanitizedClient); + } catch { + persistenceDisabled = true; + removeStorageItem(storage, key); + return; + } + if (!candidate || candidate.clientState.queries.length === 0) { removeStorageItem(storage, key); return; } @@ -162,37 +241,56 @@ export const createSessionQueryPersister = ( while (candidate) { try { storage.setItem(key, stringify(candidate)); + if (candidate.clientState.queries.length < sanitizedQueryCount) { + persistedQueryLimit = Math.min(persistedQueryLimit, candidate.clientState.queries.length); + } return; } catch (error) { - errorCount += 1; - candidate = removeOldestQuery({ - persistedClient: candidate, - error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"), - errorCount, - }); + removeStorageItem(storage, key); + if (!isQuotaExceededError(error)) { + persistenceDisabled = true; + return; + } + candidate = keepNewestQueries(candidate, Math.floor(candidate.clientState.queries.length / 2)); } } + + persistenceDisabled = true; + }; + + const clearScheduledPersist = () => { + if (persistDelayTimer !== undefined) { + clearTimeout(persistDelayTimer); + persistDelayTimer = undefined; + } + if (persistIdleCallback !== undefined && typeof window !== "undefined") { + window.cancelIdleCallback?.(persistIdleCallback); + persistIdleCallback = undefined; + } }; - const clearPersistTimer = () => { - if (persistTimer === undefined) return; - clearTimeout(persistTimer); - persistTimer = undefined; - }; - const flush = () => { - clearPersistTimer(); + clearScheduledPersist(); const client = pendingClient; pendingClient = undefined; if (client) persistNow(client); }; + const scheduleIdlePersist = () => { + persistDelayTimer = undefined; + if (typeof window !== "undefined" && window.requestIdleCallback) { + persistIdleCallback = window.requestIdleCallback(flush, { timeout: queryPersistenceIdleTimeoutMs }); + return; + } + flush(); + }; + return { persistClient(client) { - if (!storage || stopped) return; + if (!storage || stopped || persistenceDisabled) return; pendingClient = client; - if (persistTimer !== undefined) return; - persistTimer = setTimeout(flush, 0); + if (persistDelayTimer !== undefined || persistIdleCallback !== undefined) return; + persistDelayTimer = setTimeout(scheduleIdlePersist, queryPersistenceThrottleMs); }, restoreClient() { try { @@ -212,14 +310,14 @@ export const createSessionQueryPersister = ( } }, removeClient() { - clearPersistTimer(); + clearScheduledPersist(); pendingClient = undefined; removeStorageItem(storage, key); }, flush, stop() { stopped = true; - clearPersistTimer(); + clearScheduledPersist(); pendingClient = undefined; }, }; 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/app/api/demo-assets/overview/route.ts b/apps/nextjs/src/app/api/demo-assets/overview/route.ts new file mode 100644 index 000000000..401e97564 --- /dev/null +++ b/apps/nextjs/src/app/api/demo-assets/overview/route.ts @@ -0,0 +1,56 @@ +const overview = ` + + + + + + + +
+
H
+

Your home, at a glance.

+

Bring services, media, infrastructure, notes and everyday tools together on one practical dashboard.

+
59 widgetsMock data includedBuilt for self-hosters
+
+ +`; + +export const GET = () => + new Response(overview, { + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": "text/html; charset=utf-8", + }, + }); diff --git a/apps/nextjs/src/app/api/demo-assets/video/file.mp4/route.ts b/apps/nextjs/src/app/api/demo-assets/video/file.mp4/route.ts new file mode 100644 index 000000000..0ced33071 --- /dev/null +++ b/apps/nextjs/src/app/api/demo-assets/video/file.mp4/route.ts @@ -0,0 +1 @@ +export { GET } from "../route"; diff --git a/apps/nextjs/src/app/api/demo-assets/video/route.ts b/apps/nextjs/src/app/api/demo-assets/video/route.ts new file mode 100644 index 000000000..c4ef08adb --- /dev/null +++ b/apps/nextjs/src/app/api/demo-assets/video/route.ts @@ -0,0 +1,10 @@ +const videoBase64 = + "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"; + +export const GET = () => + new Response(Buffer.from(videoBase64, "base64"), { + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": "video/mp4", + }, + }); 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 8b9505aae..7a6430753 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( () => ({ @@ -335,7 +330,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 1b18dd5a9..826027919 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -9,6 +9,7 @@ import { useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import type { WidgetDefinition } from "@homarr/widgets/definition"; +import type { WidgetPreviewDimensions } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item"; @@ -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 a4fc9b52a..546d48426 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -24,6 +24,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"; @@ -35,7 +36,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope"; 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 &&