diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 000000000..103c85307 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "24.16.0" +pnpm = "10.33.4" diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 4a9ab466d..146578bf6 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -59,6 +59,7 @@ "@modelcontextprotocol/sdk": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", + "@tanstack/query-persist-client-core": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-next-experimental": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts new file mode 100644 index 000000000..70d8581c7 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts @@ -0,0 +1,49 @@ +import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core"; +import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core"; +import { parse, stringify } from "superjson"; + +import { fetchApi } from "@homarr/api/client"; +import { + getActiveQueryCacheBoardId, + isPersistableWidgetQueryKey, + queryCacheStoragePrefix, +} from "@homarr/api/query-cache"; + +const queryCacheStorage = { + async getItem(_key) { + return null; + }, + async setItem(key, value) { + const boardId = getActiveQueryCacheBoardId(); + if (!boardId) return; + + try { + await fetchApi.queryCache.setItem.mutate({ boardId, key, value }); + } catch { + // Query persistence is best-effort; failed writes should not affect widget rendering. + } + }, + async removeItem(key) { + const boardId = getActiveQueryCacheBoardId(); + if (!boardId) return; + + try { + await fetchApi.queryCache.removeItem.mutate({ boardId, key }); + } catch { + // Query persistence is best-effort; failed removals should not affect widget rendering. + } + }, +} satisfies AsyncStorage; + +export const createWidgetQueryPersister = () => + experimental_createQueryPersister({ + storage: typeof window === "undefined" ? undefined : queryCacheStorage, + maxAge: Number.POSITIVE_INFINITY, + prefix: queryCacheStoragePrefix, + refetchOnRestore: true, + serialize: (query) => stringify(query), + deserialize: (value) => parse(value), + filters: { + predicate: (query) => isPersistableWidgetQueryKey(query.queryKey), + }, + }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 7c7ca0766..d09acd8c8 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -21,10 +21,13 @@ import { TRPCClientError } from "@trpc/client"; import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; +import { createWidgetQueryPersister } from "./query-cache-persister"; + const getWebSocketProtocol = () => { // window is not defined on server side if (typeof window === "undefined") { @@ -52,12 +55,14 @@ const wsClient = createWSClient({ }); export function TRPCReactProvider(props: PropsWithChildren) { + const [queryPersister] = useState(() => createWidgetQueryPersister()); const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 1000, + persister: queryPersister.persisterFn, + staleTime: queryCacheDefaultStaleTimeMs, }, mutations: { onError(error) { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 96481508c..2144304c5 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,10 +1,13 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; +import type { DehydratedState } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; +import { parse } from "superjson"; import { getQueryClient } from "@homarr/api/server"; import { IntegrationProvider } from "@homarr/auth/client"; @@ -14,6 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { WidgetKind } from "@homarr/definitions"; +import { getAllQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; @@ -72,9 +76,14 @@ export const createBoardContentPage = >( ), ]); + const userId = session?.user.id ?? "anonymous"; + return ( + + + @@ -108,3 +117,37 @@ export const createBoardContentPage = >( }, }; }; + +interface PersistedEntry { + state: { data: unknown; dataUpdatedAt: number; status: string }; + queryKey: unknown[]; + queryHash: string; +} + +async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) { + try { + const entries = await getAllQueryCacheAsync(userId, boardId); + const queries: DehydratedState["queries"] = []; + + for (const serialized of Object.values(entries)) { + try { + const persisted = parse(serialized); + if (persisted.state.status !== "success") continue; + + queries.push({ + state: persisted.state as DehydratedState["queries"][number]["state"], + queryKey: persisted.queryKey, + queryHash: persisted.queryHash, + }); + } catch { + // skip malformed entries + } + } + + if (queries.length === 0) return null; + + return ; + } catch { + return null; + } +} diff --git a/packages/api/package.json b/packages/api/package.json index 482913f96..c7e74a1ca 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,6 +9,7 @@ "exports": { ".": "./src/index.ts", "./client": "./src/client.ts", + "./query-cache": "./src/query-cache.ts", "./server": "./src/server.ts", "./websocket": "./src/websocket.ts", "./shared": "./src/shared.ts", diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts new file mode 100644 index 000000000..690059723 --- /dev/null +++ b/packages/api/src/query-cache.ts @@ -0,0 +1,34 @@ +import type { QueryKey } from "@tanstack/react-query"; + +export const queryCacheRetentionMs = 1000 * 60 * 60 * 24; +export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5; +export const queryCacheMaxValueBytes = 1024 * 1024; +export const queryCacheStoragePrefix = "homarr-widget-query"; + +let activeQueryCacheBoardId: string | null = null; + +export const setActiveQueryCacheBoardId = (boardId: string | null) => { + activeQueryCacheBoardId = boardId; +}; + +export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId; + +const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); +const excludedWidgetPaths = new Set(["widget.app.ping"]); + +const getTrpcPathFromQueryKey = (queryKey: QueryKey) => { + const first = queryKey[0]; + if (!Array.isArray(first)) return null; + + const parts = first.filter((part): part is string => typeof part === "string"); + return parts.length > 0 ? parts : null; +}; + +export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => { + const path = getTrpcPathFromQueryKey(queryKey); + if (!path) return false; + + if (path[0] === "widget") return !excludedWidgetPaths.has(path.join(".")); + + return persistableQueryPaths.has(path.join(".")); +}; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index a014b4b75..2c785e6ff 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -24,6 +24,7 @@ export const appRouter = createTRPCRouter({ cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)), apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)), media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)), + queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)), updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)), certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)), bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)), diff --git a/packages/api/src/router/query-cache.ts b/packages/api/src/router/query-cache.ts new file mode 100644 index 000000000..6143501fc --- /dev/null +++ b/packages/api/src/router/query-cache.ts @@ -0,0 +1,54 @@ +import { z } from "zod/v4"; + +import { eq } from "@homarr/db"; +import { boards } from "@homarr/db/schema"; +import { createQueryCacheChannel } from "@homarr/redis"; + +import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache"; +import { createTRPCRouter, publicProcedure } from "../trpc"; +import { throwIfActionForbiddenAsync } from "./board/board-access"; + +const queryCacheKeyInput = z.object({ + boardId: z.string().min(1), + key: z.string().min(1).max(4096), +}); + +const queryCacheSetInput = queryCacheKeyInput.extend({ + value: z.string().max(queryCacheMaxValueBytes * 2), +}); + +const createChannel = (input: z.infer, userId: string) => + createQueryCacheChannel({ + userId, + boardId: input.boardId, + key: input.key, + ttlMs: queryCacheRetentionMs, + maxValueBytes: queryCacheMaxValueBytes, + }); + +const validateBoardAccessAsync = async (ctx: Parameters[0], boardId: string) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view"); +}; + +export const queryCacheRouter = createTRPCRouter({ + getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => { + await validateBoardAccessAsync(ctx, input.boardId); + + const channel = createChannel(input, ctx.session?.user.id ?? "anonymous"); + return await channel.getAsync(); + }), + setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => { + await validateBoardAccessAsync(ctx, input.boardId); + + const channel = createChannel(input, ctx.session?.user.id ?? "anonymous"); + return { + stored: await channel.setAsync(input.value), + }; + }), + removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => { + await validateBoardAccessAsync(ctx, input.boardId); + + const channel = createChannel(input, ctx.session?.user.id ?? "anonymous"); + await channel.removeAsync(); + }), +}); diff --git a/packages/api/src/shared.ts b/packages/api/src/shared.ts index 4ef9b7a4f..e05700f22 100644 --- a/packages/api/src/shared.ts +++ b/packages/api/src/shared.ts @@ -1,5 +1,7 @@ import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query"; +import { queryCacheDefaultStaleTimeMs } from "./query-cache"; + /** * Creates a headers callback for a given source * It will set the x-trpc-source header and cookies if needed @@ -58,7 +60,7 @@ export const makeQueryClient = () => { return new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 1000, + staleTime: queryCacheDefaultStaleTimeMs, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts new file mode 100644 index 000000000..c765af031 --- /dev/null +++ b/packages/api/src/test/query-cache.spec.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { boards, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import { + isPersistableWidgetQueryKey, + queryCacheDefaultStaleTimeMs, + queryCacheMaxValueBytes, + queryCacheRetentionMs, +} from "@homarr/api/query-cache"; + +import { queryCacheRouter } from "../router/query-cache"; + +const redisMock = vi.hoisted(() => { + const values = new Map(); + const calls: { + userId: string; + boardId: string; + key: string; + ttlMs: number; + maxValueBytes: number; + }[] = []; + + return { + values, + calls, + createQueryCacheChannel: vi.fn( + (options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => { + calls.push(options); + const storageKey = `${options.userId}:${options.boardId}:${options.key}`; + + return { + getAsync: async () => { + const item = values.get(storageKey); + if (!item) return null; + if (Date.now() > item.expiresAt) { + values.delete(storageKey); + return null; + } + + return item.value; + }, + setAsync: async (value: string) => { + if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) { + return false; + } + + values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs }); + return true; + }, + removeAsync: async () => { + values.delete(storageKey); + }, + }; + }, + ), + }; +}); + +vi.mock("@homarr/redis", () => ({ + createQueryCacheChannel: redisMock.createQueryCacheChannel, +})); + +vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); + +afterEach(() => { + vi.useRealTimers(); + redisMock.values.clear(); + redisMock.calls.length = 0; + redisMock.createQueryCacheChannel.mockClear(); +}); + +const createSession = (userId = createId()): Session => ({ + user: { + id: userId, + permissions: [], + colorScheme: "light", + }, + expires: new Date().toISOString(), +}); + +const insertUserAsync = async (db: Database, id: string) => { + await db.insert(users).values({ id }); +}; + +describe("isPersistableWidgetQueryKey", () => { + test.each([ + [[["widget", "calendar", "findAllEvents"], { type: "query" }], true], + [[["app", "byId"], { type: "query" }], true], + [[["app", "byIds"], { type: "query" }], true], + [[["docker", "getContainers"], { type: "query" }], true], + [[["integration", "byIds"], { type: "query" }], true], + [[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false], + [[["app", "selectable"], { type: "query" }], false], + [[["board", "getBoardByName"], { type: "query" }], false], + [["widget"], false], + ])("matches persist filter for %j", (queryKey, expected) => { + expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected); + }); +}); + +describe("queryCacheRouter", () => { + test("stores, reads, and removes data for a board viewer", async () => { + const db = createDb(); + const session = createSession(); + await insertUserAsync(db, session.user.id); + await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false }); + + const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); + + await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({ + stored: true, + }); + await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value"); + + await caller.removeItem({ boardId: "board-1", key: "query-key" }); + + await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull(); + }); + + test("does not touch Redis when board access is denied", async () => { + const db = createDb(); + await insertUserAsync(db, "owner"); + await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false }); + + redisMock.createQueryCacheChannel.mockClear(); + const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") }); + + await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found"); + expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled(); + }); + + test("scopes anonymous public-board cache entries by anonymous user and board", async () => { + const db = createDb(); + await insertUserAsync(db, "owner"); + await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true }); + + const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null }); + + await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" }); + + expect(redisMock.calls.at(-1)).toMatchObject({ + userId: "anonymous", + boardId: "board-1", + key: "query-key", + ttlMs: queryCacheRetentionMs, + maxValueBytes: queryCacheMaxValueBytes, + }); + }); + + test("scopes signed-in cache entries by user and rejects oversized values", async () => { + const db = createDb(); + const session = createSession("user-1"); + await insertUserAsync(db, session.user.id); + await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false }); + + const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); + const value = "x".repeat(queryCacheMaxValueBytes + 1); + + await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({ + stored: false, + }); + expect(redisMock.calls.at(-1)).toMatchObject({ + userId: "user-1", + boardId: "board-1", + ttlMs: queryCacheRetentionMs, + maxValueBytes: queryCacheMaxValueBytes, + }); + }); + + test("expires values through the Redis TTL path", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + const db = createDb(); + const session = createSession("user-1"); + await insertUserAsync(db, session.user.id); + await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false }); + + const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); + + await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" }); + await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value"); + + await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1); + + await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull(); + }); +}); + +describe("query cache stale handling", () => { + test("uses TanStack Query dataUpdatedAt and staleTime to decide when cached data refetches", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: queryCacheDefaultStaleTimeMs, + }, + }, + }); + const queryKey = [["widget", "example"], { input: { integrationIds: ["integration-1"] }, type: "query" }]; + const queryFn = vi.fn().mockResolvedValue("fresh"); + + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - queryCacheDefaultStaleTimeMs - 1, + }); + + await expect( + queryClient.fetchQuery({ + queryKey, + queryFn, + }), + ).resolves.toBe("fresh"); + + expect(queryFn).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx index a099f64c7..c2ccc690a 100644 --- a/packages/boards/src/context.tsx +++ b/packages/boards/src/context.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache"; import { updateBoardName } from "./updater"; @@ -28,6 +29,8 @@ export const BoardProvider = ({ }, ); + setActiveQueryCacheBoardId(data.id); + // Update the board name so it can be used within updateBoard method updateBoardName(initialBoard.name); @@ -42,6 +45,16 @@ export const BoardProvider = ({ }; }, [pathname, utils, initialBoard.name]); + useEffect(() => { + setActiveQueryCacheBoardId(data.id); + + return () => { + if (getActiveQueryCacheBoardId() === data.id) { + setActiveQueryCacheBoardId(null); + } + }; + }, [data.id]); + return ( layoutB.breakpoint - layoutA.breakpoint); + const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint); // Fallback to smallest if none exists with breakpoint smaller than window width // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index f8d8fd329..8e6e3db86 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -13,6 +13,8 @@ export { handshakeAsync, createSubPubChannel, createGetSetChannel, + createQueryCacheChannel, + getAllQueryCacheAsync, } from "./lib/channel"; export { createIntegrationHistoryChannel } from "./lib/channels/history-channel"; diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 193f551e3..03025565e 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -125,6 +125,49 @@ export const createGetSetChannel = (name: string) => { }; }; +interface QueryCacheChannelOptions { + userId: string; + boardId: string; + key: string; + ttlMs: number; + maxValueBytes: number; +} + +const queryCacheHashKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`; + +export const createQueryCacheChannel = ({ userId, boardId, key, ttlMs, maxValueBytes }: QueryCacheChannelOptions) => { + const hkey = queryCacheHashKey(userId, boardId); + + return { + name: `${hkey}:${key}`, + getAsync: async () => { + return await getSetClient.hget(hkey, key); + }, + setAsync: async (value: string) => { + if (Buffer.byteLength(value, "utf8") > maxValueBytes) { + logger.warn("Query cache value exceeded maximum size", { + channel: hkey, + valueBytes: Buffer.byteLength(value, "utf8"), + maxValueBytes, + }); + return false; + } + + await getSetClient.hset(hkey, key, value); + await getSetClient.pexpire(hkey, ttlMs); + return true; + }, + removeAsync: async () => { + await getSetClient.hdel(hkey, key); + }, + }; +}; + +export const getAllQueryCacheAsync = async (userId: string, boardId: string): Promise> => { + const hkey = queryCacheHashKey(userId, boardId); + return await getSetClient.hgetall(hkey); +}; + /** * Creates a new cache channel. * @param name name of the channel diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index f3fde1f97..99585b838 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -14,6 +14,7 @@ import { useTimeAgo } from "@homarr/common"; import type { AnchorNotePermission } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import "react-quill-new/dist/quill.snow.css"; @@ -144,13 +145,13 @@ interface AnchorNoteWidgetContentProps { const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); - const [note, { refetch }] = clientApi.widget.anchorNotes.getNote.useSuspenseQuery( + const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery( { integrationId, noteId, }, { - refetchOnMount: false, + staleTime: 15 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -159,31 +160,32 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation(); const [isEditing, setIsEditing] = useState(false); - const [draftTitle, setDraftTitle] = useState(note.title); - const [draftContent, setDraftContent] = useState(note.content ?? ""); + const [draftTitle, setDraftTitle] = useState(note?.title ?? ""); + const [draftContent, setDraftContent] = useState(note?.content ?? ""); const [saveError, setSaveError] = useState(null); useEffect(() => { - if (isEditing) return; + if (isEditing || !note) return; startTransition(() => { setDraftTitle(note.title); setDraftContent(note.content ?? ""); }); - }, [isEditing, note.content, note.title]); + }, [isEditing, note]); - const canEdit = canEditPermission(note.permission); - const isViewer = note.permission === "viewer"; - const updatedAt = useMemo(() => new Date(note.updatedAt), [note.updatedAt]); + const canEdit = canEditPermission(note?.permission ?? "viewer"); + const isViewer = !note || note.permission === "viewer"; + const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]); const updatedAtRelative = useTimeAgo(updatedAt, 30000); const hasChanges = useMemo(() => { + if (!note) return false; const normalizedTitle = draftTitle.trim() || note.title; return normalizedTitle !== note.title || draftContent !== (note.content ?? ""); - }, [draftContent, draftTitle, note.content, note.title]); + }, [draftContent, draftTitle, note]); const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]); - const readOnlyValue = useMemo(() => parseStoredContent(note.content), [note.content]); + const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]); const handleEditorChange = useCallback( (_html, _delta, source, editor) => { if (!isEditing || source !== "user") return; @@ -194,26 +196,27 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW ); const handleEdit = useCallback(() => { - if (!canEdit) return; + if (!canEdit || !note) return; startTransition(() => { setDraftTitle(note.title); setDraftContent(note.content ?? ""); setSaveError(null); setIsEditing(true); }); - }, [canEdit, note.content, note.title]); + }, [canEdit, note]); const handleCancel = useCallback(() => { + if (!note) return; startTransition(() => { setDraftTitle(note.title); setDraftContent(note.content ?? ""); setSaveError(null); setIsEditing(false); }); - }, [note.content, note.title]); + }, [note]); const handleSave = useCallback(async () => { - if (!canEdit) return; + if (!canEdit || !note) return; const normalizedTitle = draftTitle.trim() || note.title || t("untitled"); @@ -260,7 +263,9 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW }, }, ); - }, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note.title, noteId, refetch, t, updateNoteAsync]); + }, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note, noteId, refetch, t, updateNoteAsync]); + + if (!note) return ; return ( diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 1caa1ce56..054a2da6b 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import { Fragment, Suspense } from "react"; import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; -import { IconLoader } from "@tabler/icons-react"; +import { IconMinus } from "@tabler/icons-react"; import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; @@ -13,6 +13,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; import { PingDot } from "./ping/ping-dot"; @@ -22,20 +23,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); - const [app] = clientApi.app.byId.useSuspenseQuery( + const { data: app } = clientApi.app.byId.useQuery( { id: options.appId, }, { - refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, ); useRegisterSpotlightContextResults( - `app-${app.id}`, - app.href + `app-${app?.id ?? options.appId}`, + app?.href ? [ { id: app.id, @@ -44,7 +44,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget interaction() { return { type: "link", - // We checked above that app.href is defined // eslint-disable-next-line @typescript-eslint/no-non-null-assertion href: app.href!, newTab: options.openInNewTab, @@ -56,6 +55,8 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget [app, options.openInNewTab], ); + if (!app) return ; + const isTiny = height < 100 || width < 100; const isColumnLayout = options.layout.startsWith("column"); @@ -130,7 +131,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( - }> + }> ) : null} diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx index a6299ed95..b8f88cdc8 100644 --- a/packages/widgets/src/app/ping/ping-indicator.tsx +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -1,8 +1,7 @@ -import { useState } from "react"; -import { IconCheck, IconX } from "@tabler/icons-react"; +import { IconCheck, IconMinus, IconX } from "@tabler/icons-react"; -import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; import { PingDot } from "./ping-dot"; @@ -11,27 +10,24 @@ interface PingIndicatorProps { } export const PingIndicator = ({ appId }: PingIndicatorProps) => { - const [ping] = clientApi.widget.app.ping.useSuspenseQuery( - { - id: appId, - }, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - }, + const t = useI18n(); + const { data: pingResult } = clientApi.widget.app.ping.useQuery( + { id: appId }, + { refetchOnMount: false, refetchOnWindowFocus: false }, ); - const [pingResult, setPingResult] = useState(ping); - + const utils = clientApi.useUtils(); clientApi.widget.app.updatedPing.useSubscription( { id: appId }, { onData(data) { - setPingResult(data); + utils.widget.app.ping.setData({ id: appId }, data); }, }, ); + if (!pingResult) return ; + const isError = "error" in pingResult || pingResult.statusCode >= 500; return ( diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index ff2b56863..d9d8d26d3 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -2,13 +2,17 @@ import { clientApi } from "@homarr/api/client"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { AudioStatsContent } from "./audio-stats-content"; export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) { - const [response] = clientApi.widget.audioStats.getStats.useSuspenseQuery({ - integrationId: integrationIds[0] ?? "", - }); + const { data: response } = clientApi.widget.audioStats.getStats.useQuery( + { integrationId: integrationIds[0] ?? "" }, + { staleTime: 5 * 60 * 1000 }, + ); + + if (!response) return ; return ; } diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 167b2ce1d..1048ebdae 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -36,11 +36,14 @@ export default function BeszelAlertsWidget({ isEditMode, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); - const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({ - integrationIds, - includeHistory: options.showHistory, - maxHistoryItems: options.maxHistoryItems, - }); + const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery( + { + integrationIds, + includeHistory: options.showHistory, + maxHistoryItems: options.maxHistoryItems, + }, + { staleTime: 30 * 1000 }, + ); const utils = clientApi.useUtils(); clientApi.widget.beszel.subscribeAlerts.useSubscription( diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index f0a7c1ee0..6bdb7a139 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -344,7 +344,10 @@ export default function BeszelSystemGridWidget({ height, }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); - const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); + const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery( + { integrationIds }, + { staleTime: 30 * 1000 }, + ); useBeszelSystemsSubscription(integrationIds, !isEditMode); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 99ee0f732..73715839e 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -39,7 +39,10 @@ export default function BeszelSystemStatsWidget({ width, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); - const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); + const { data: systemsResult = [] } = clientApi.widget.beszel.getSystems.useQuery( + { integrationIds }, + { staleTime: 30 * 1000 }, + ); const systems = useMemo( () => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), @@ -50,12 +53,15 @@ export default function BeszelSystemStatsWidget({ const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; - const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({ - integrationIds, - systemId: selectedSystem ?? systems[0]?.value ?? "", - timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", - includeDocker: showDocker, - }); + const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery( + { + integrationIds, + systemId: selectedSystem ?? systems[0]?.value ?? "", + timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", + includeDocker: showDocker, + }, + { staleTime: 30 * 1000 }, + ); // BeszelSystemStats fields are documented in beszel-types.ts. // mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 1422ccce9..bb306d8f9 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -54,7 +54,10 @@ export default function BeszelSystemTableWidget({ width, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); - const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); + const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery( + { integrationIds }, + { staleTime: 30 * 1000 }, + ); const size = getSizeConfig(width); useBeszelSystemsSubscription(integrationIds, !isEditMode); diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 51fb6f781..e6b7eb7c0 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -14,7 +14,7 @@ import classes from "./bookmark.module.css"; export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) { const board = useRequiredBoard(); - const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, { + const { data = [] } = clientApi.app.byIds.useQuery(options.items, { select(data) { return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); }, diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index b287d2324..ac3d8b37b 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -39,8 +39,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: releaseType: options.releaseType, showUnmonitored: options.showUnmonitored, }; - const [data] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(input, { - refetchOnMount: false, + const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, { + staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -61,7 +61,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: }, }); - const events = useMemo(() => data.flatMap((item) => item.events), [data]); + const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); return ; }; @@ -152,13 +152,10 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar .filter( (event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType), ) - .sort((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()); + .toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()); return ( { + const t = useI18n(); + + return ( +
+ + {t("widget.common.error.noData")} + +
+ ); +}; diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index a62b4f3cd..91b37ecd4 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -31,7 +31,10 @@ interface CoolifyContentProps { } function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) { - const [instancesData] = clientApi.widget.coolify.getInstancesInfo.useSuspenseQuery({ integrationIds }); + const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery( + { integrationIds }, + { staleTime: 30 * 1000 }, + ); const utils = clientApi.useUtils(); clientApi.widget.coolify.subscribeInstancesInfo.useSubscription( diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 8ce9251a4..afe397cfd 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -51,12 +51,12 @@ export default function DnsHoleControlsWidget({ .map(({ id }) => id) .filter((id) => integrationIds.includes(id)); - const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery( + const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 5 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx index b0636ff44..4a4c2879c 100644 --- a/packages/widgets/src/dns-hole/summary/component.tsx +++ b/packages/widgets/src/dns-hole/summary/component.tsx @@ -20,12 +20,12 @@ import type { widgetKind } from "."; import type { WidgetComponentProps, WidgetProps } from "../../definition"; export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps) { - const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery( + const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 5 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 5d869c52a..d8dd77bdf 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -185,7 +185,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo const isTiny = width <= 256; const utils = clientApi.useUtils(); - const [{ containers, timestamp }] = clientApi.docker.getContainers.useSuspenseQuery(); + const { data } = clientApi.docker.getContainers.useQuery(undefined, { + staleTime: 20 * 1000, + }); + const containers = data?.containers ?? []; + const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const relativeTime = useTimeAgo(timestamp); clientApi.docker.subscribeContainers.useSubscription(undefined, { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 96e63a106..bee31eb20 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -87,13 +87,13 @@ export default function DownloadClientsWidget({ integrationIds.includes(id) ? [id] : [], ); - const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery( + const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery( { integrationIds, limitPerIntegration: options.limitPerIntegration, }, { - refetchOnMount: false, + staleTime: 5 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -101,7 +101,6 @@ export default function DownloadClientsWidget({ ); const utils = clientApi.useUtils(); - //Translations const t = useScopedI18n("widget.downloads"); const tCommon = useScopedI18n("common"); diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index c00044aa9..36979083b 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -28,7 +28,7 @@ const columnsList = [ ] as const satisfies (keyof ExtendedDownloadClientItem)[]; const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][]; const columnsSort = columnsList.filter((column) => - sortingExclusion.some((exclusion) => exclusion !== column), + sortingExclusion.every((exclusion) => exclusion !== column), ) as Exclude; export const { definition, componentLoader } = createWidgetDefinition("downloads", { diff --git a/packages/widgets/src/downloads/prefetch.ts b/packages/widgets/src/downloads/prefetch.ts new file mode 100644 index 000000000..4eb073c43 --- /dev/null +++ b/packages/widgets/src/downloads/prefetch.ts @@ -0,0 +1,29 @@ +import { api, trpc } from "@homarr/api/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; + +import type { Prefetch } from "../definition"; + +const logger = createLogger({ module: "downloadsWidgetPrefetch" }); + +const prefetchAllAsync: Prefetch<"downloads"> = async (queryClient, items) => { + await Promise.all( + items.map(async (item) => { + if (item.integrationIds.length === 0) return; + + const input = { + integrationIds: item.integrationIds, + limitPerIntegration: Number(item.options.limitPerIntegration) || 50, + }; + + try { + const data = await api.widget.downloads.getJobsAndStatuses(input); + queryClient.setQueryData(trpc.widget.downloads.getJobsAndStatuses.queryKey(input), data); + logger.info("Prefetched downloads widget data", { integrations: item.integrationIds.length }); + } catch (error) { + logger.warn("Failed to prefetch downloads widget data", { error }); + } + }), + ); +}; + +export default prefetchAllAsync; diff --git a/packages/widgets/src/firewall/component.tsx b/packages/widgets/src/firewall/component.tsx index cf5ac2b0e..7318cd3d3 100644 --- a/packages/widgets/src/firewall/component.tsx +++ b/packages/widgets/src/firewall/component.tsx @@ -184,12 +184,12 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget export const useUpdatingCpuStatus = (integrationIds: string[]) => { const utils = clientApi.useUtils(); - const [firewallsCpuData] = clientApi.widget.firewall.getFirewallCpuStatus.useSuspenseQuery( + const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 5 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -225,12 +225,12 @@ export const useUpdatingCpuStatus = (integrationIds: string[]) => { export const useUpdatingMemoryStatus = (integrationIds: string[]) => { const utils = clientApi.useUtils(); - const [firewallsMemoryData] = clientApi.widget.firewall.getFirewallMemoryStatus.useSuspenseQuery( + const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 15 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -266,12 +266,12 @@ export const useUpdatingMemoryStatus = (integrationIds: string[]) => { export const useUpdatingVersionStatus = (integrationIds: string[]) => { const utils = clientApi.useUtils(); - const [firewallsVersionData] = clientApi.widget.firewall.getFirewallVersionStatus.useSuspenseQuery( + const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 60 * 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -306,12 +306,12 @@ export const useUpdatingVersionStatus = (integrationIds: string[]) => { export const useUpdatingInterfacesStatus = (integrationIds: string[]) => { const utils = clientApi.useUtils(); - const [firewallsInterfacesData] = clientApi.widget.firewall.getFirewallInterfacesStatus.useSuspenseQuery( + const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 30 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, diff --git a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx index 3ffa99bdc..daa0b40ec 100644 --- a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx +++ b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx @@ -5,6 +5,7 @@ import { clientApi } from "@homarr/api/client"; import type { Resource } from "@homarr/integrations/types"; import { useI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; import { formatUptime } from "../system-health"; import { ResourceAccordionItem } from "./resource-accordion-item"; @@ -34,12 +35,12 @@ export const ClusterHealthMonitoring = ({ width, }: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => { const t = useI18n(); - const [healthData] = clientApi.widget.healthMonitoring.getClusterHealthStatus.useSuspenseQuery( + const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery( { integrationId, }, { - refetchOnMount: false, + staleTime: 5 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -56,6 +57,8 @@ export const ClusterHealthMonitoring = ({ }, ); + if (!healthData) return ; + const activeNodes = healthData.nodes.reduce(running, 0); const activeVMs = healthData.vms.reduce(running, 0); const activeLXCs = healthData.lxcs.reduce(running, 0); diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index baa958fe9..ead4fef92 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -18,7 +18,7 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) => integration.kind === "proxmox" || integration.kind === "mock"; export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) { - const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds); + const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds); const t = useI18n(); const clusterIntegrationId = integrations.find(isClusterIntegration)?.id; diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 6e7d2965c..1f0dd02e5 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -37,6 +37,7 @@ import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { WidgetEmptyState } from "../common/empty-state"; import { CpuRing } from "./rings/cpu-ring"; import { CpuTempRing } from "./rings/cpu-temp-ring"; import { GpuRing } from "./rings/gpu-ring"; @@ -51,12 +52,12 @@ export const SystemHealthMonitoring = ({ width, }: WidgetComponentProps<"healthMonitoring">) => { const t = useI18n(); - const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery( + const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 5 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -86,6 +87,8 @@ export const SystemHealthMonitoring = ({ const isTiny = width < 256; + if (healthData.length === 0) return ; + return ( {healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => { @@ -332,5 +335,5 @@ export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: Sm overallStatus: smartDisk?.overallStatus ?? "", }; }) - .sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName)); + .toSorted((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName)); }; diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx index a8a920d5d..94795991c 100644 --- a/packages/widgets/src/immich/album-carousel/component.tsx +++ b/packages/widgets/src/immich/album-carousel/component.tsx @@ -8,6 +8,7 @@ import { IconAlertCircle, IconCalendar } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; import classes from "./component.module.css"; @@ -17,14 +18,19 @@ export default function ImmichAlbumCarouselWidget({ }: WidgetComponentProps<"immich-albumCarousel">) { const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); + const { data: album } = clientApi.widget.immich.getAlbum.useQuery( + { + integrationId: integrationIds[0] ?? "", + albumId: options.albumId ?? "", + }, + { enabled: Boolean(options.albumId), staleTime: 15 * 60 * 1000 }, + ); + if (!options.albumId) { return ; } - const [album] = clientApi.widget.immich.getAlbum.useSuspenseQuery({ - integrationId: integrationIds[0] ?? "", - albumId: options.albumId, - }); + if (!album) return ; if (album.assets.length === 0) { return ; diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx index 0240a27c4..e3e1596ea 100644 --- a/packages/widgets/src/immich/server-stats/component.tsx +++ b/packages/widgets/src/immich/server-stats/component.tsx @@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client"; import { humanFileSize } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; import classes from "./component.module.css"; @@ -16,9 +17,14 @@ export default function ImmichServerStatsWidget({ options, }: WidgetComponentProps<"immich-serverStats">) { const t = useI18n(); - const [stats] = clientApi.widget.immich.getServerStats.useSuspenseQuery({ - integrationId: integrationIds[0] ?? "", - }); + const { data: stats } = clientApi.widget.immich.getServerStats.useQuery( + { + integrationId: integrationIds[0] ?? "", + }, + { staleTime: 15 * 60 * 1000 }, + ); + + if (!stats) return ; return ( diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index e406ffa8a..51ddb1805 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -18,10 +18,10 @@ export default function IndexerManagerWidget({ height, }: WidgetComponentProps<"indexerManager">) { const t = useI18n(); - const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery( + const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery( { integrationIds }, { - refetchOnMount: false, + staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index cd2432ddb..61fc9216d 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -18,9 +18,12 @@ import { OverflowBadge } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) { - const [releases] = clientApi.widget.mediaRelease.getMediaReleases.useSuspenseQuery({ - integrationIds, - }); + const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery( + { + integrationIds, + }, + { staleTime: 5 * 60 * 1000 }, + ); return ( diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 3a17c5e0f..3bc24cf70 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -24,6 +24,7 @@ import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "../search-button.module.css"; @@ -34,12 +35,12 @@ export default function MediaServerWidget({ options, width, }: WidgetComponentProps<"mediaRequests-requestList">) { - const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery( + const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, }, @@ -73,6 +74,7 @@ export default function MediaServerWidget({ }, ); + if (!mediaRequests) return ; if (mediaRequests.length === 0) throw new NoIntegrationDataError(); return ( diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 2960d7ae6..51a3e3d5e 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -21,6 +21,7 @@ import type { RequestStats } from "@homarr/integrations/types"; import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "./component.module.css"; @@ -35,12 +36,12 @@ export default function MediaServerWidget({ width, }: WidgetComponentProps<"mediaRequests-requestStats">) { const t = useScopedI18n("widget.mediaRequests-requestStats"); - const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery( + const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, }, @@ -48,6 +49,7 @@ export default function MediaServerWidget({ const board = useRequiredBoard(); + if (!requestStats) return ; if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError(); const data = [ diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 9b540339e..efa2c2415 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -23,13 +23,13 @@ export default function MediaServerWidget({ integrationIds, isEditMode, }: WidgetComponentProps<"mediaServer">) { - const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery( + const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery( { integrationIds, showOnlyPlaying: options.showOnlyPlaying, }, { - refetchOnMount: false, + staleTime: 5 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, }, diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index 8967210b3..8b0ba6591 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { views } from "."; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; @@ -37,8 +38,8 @@ export default function MediaTranscodingWidget({ pageSize: queuePageSize, page: queuePage, }; - const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery(input, { - refetchOnMount: false, + const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input, { + staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, }); @@ -51,9 +52,11 @@ export default function MediaTranscodingWidget({ }); const [view, setView] = useState(options.defaultView); - const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize); - const t = useI18n("widget.mediaTranscoding"); + + if (!transcodingData) return ; + + const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize); const isTiny = width < 256; return ( diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx index 00556be85..ddf4d6e4b 100644 --- a/packages/widgets/src/minecraft/server-status/component.tsx +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -7,10 +7,13 @@ import { clientApi } from "@homarr/api/client"; import { formatNumber } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) { - const [{ data }] = clientApi.widget.minecraft.getServerStatus.useSuspenseQuery(options); + const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options, { + staleTime: 5 * 60 * 1000, + }); const utils = clientApi.useUtils(); clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, { onData(data) { @@ -22,6 +25,9 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent }); const tStatus = useScopedI18n("widget.minecraftServerStatus.status"); + if (!result) return ; + const { data } = result; + const title = options.title.trim().length > 0 ? options.title : options.domain; return ( diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index f87973252..846d1128f 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -21,12 +21,12 @@ export default function NetworkControllerNetworkStatusWidget({ options, integrationIds, }: WidgetComponentProps<"networkControllerStatus">) { - const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery( + const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx index bbd9775cd..21a4b6a93 100644 --- a/packages/widgets/src/network-controller/summary/component.tsx +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -20,12 +20,12 @@ dayjs.extend(duration); export default function NetworkControllerSummaryWidget({ integrationIds, }: WidgetComponentProps<"networkControllerSummary">) { - const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery( + const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery( { integrationIds, }, { - refetchOnMount: false, + staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index 72a662956..47aa829be 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -12,13 +12,13 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) { - const [notificationIntegrations] = clientApi.widget.notifications.getNotifications.useSuspenseQuery( + const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery( { ...options, integrationIds, }, { - refetchOnMount: false, + staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index 406c50605..3b476aa0d 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -8,6 +8,7 @@ import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; @@ -71,9 +72,12 @@ const heroPartVisibility = { export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) { const t = useScopedI18n("widget.paperlessNgx"); - const [stats] = clientApi.widget.paperlessNgx.getStats.useSuspenseQuery({ - integrationId: integrationIds[0] ?? "", - }); + const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery( + { integrationId: integrationIds[0] ?? "" }, + { staleTime: 5 * 60 * 1000 }, + ); + + if (!stats) return ; const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal; const showHero = options.showInboxRatio && canShowInboxHero; diff --git a/packages/widgets/src/prefetch.ts b/packages/widgets/src/prefetch.ts index 590237cc9..c0e3d03c4 100644 --- a/packages/widgets/src/prefetch.ts +++ b/packages/widgets/src/prefetch.ts @@ -9,6 +9,7 @@ import { createSettings } from "@homarr/settings/creator"; import { reduceWidgetOptionsWithDefaultValues } from "."; import prefetchForApps from "./app/prefetch"; import prefetchForBookmarks from "./bookmarks/prefetch"; +import prefetchForDownloads from "./downloads/prefetch"; import type { Prefetch, WidgetOptionsRecordOf } from "./definition"; import type { inferOptionsFromCreator } from "./options"; @@ -19,6 +20,7 @@ const prefetchCallbacks: Partial<{ }> = { bookmarks: prefetchForBookmarks, app: prefetchForApps, + downloads: prefetchForDownloads, }; export const prefetchForKindAsync = async ( diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index fdd9f1f0b..cc41e8b68 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -81,7 +81,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas ); }, [groupedRepositories]); - const [results] = clientApi.useSuspenseQueries((t) => + const queryResults = clientApi.useQueries((t) => batchedRepositories.flatMap(({ integrationId, repositories }) => t.widget.releases.getLatest({ integrationId, @@ -94,6 +94,11 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas ), ); + const results = useMemo( + () => queryResults.map((q) => q.data).filter((d): d is NonNullable => d != null), + [queryResults], + ); + const repositories = useMemo(() => { const formattedResults = options.repositories .map((repository) => { @@ -121,7 +126,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas (repository) => "error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease, ) - .sort((repoA, repoB) => { + .toSorted((repoA, repoB) => { if ("error" in repoA) return -1; if ("error" in repoB) return 1; return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1; diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 843168b88..a2cf07ebb 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -12,8 +12,8 @@ import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.scss"; const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { - const [feedEntries] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(input, { - refetchOnMount: false, + const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input, { + staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, @@ -26,7 +26,7 @@ const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"] return oldData ?.filter((entry) => entry.feedUrl !== updatedData.url) .concat(updatedData.entries) - .sort((entryA, entryB) => { + .toSorted((entryA, entryB) => { return entryA.published && entryB.published ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() : 0; diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index f11cfa844..2ad45b7a3 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -23,7 +23,9 @@ export default function SmartHomeEntityStateWidget({ entityId: options.entityId, integrationId, }; - const [entityState] = clientApi.widget.smartHome.entityState.useSuspenseQuery(input); + const { data: entityState } = clientApi.widget.smartHome.entityState.useQuery(input, { + staleTime: 60 * 1000, + }); const utils = clientApi.useUtils(); diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index 699b354a9..dcde145a4 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -8,6 +8,7 @@ import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { WidgetEmptyState } from "../common/empty-state"; import { AveragesSection } from "./averages"; import { mergeStats } from "./helpers"; import { LatestResultSection } from "./latest-result"; @@ -19,7 +20,10 @@ export default function SpeedtestTrackerWidget({ isEditMode, }: WidgetComponentProps<"speedtestTracker">) { const t = useScopedI18n("widget.speedtestTracker"); - const [dashboardData] = clientApi.widget.speedtestTracker.getDashboard.useSuspenseQuery({ integrationIds }); + const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery( + { integrationIds }, + { staleTime: 10 * 60 * 1000 }, + ); const utils = clientApi.useUtils(); clientApi.widget.speedtestTracker.subscribeToDashboard.useSubscription( @@ -86,6 +90,7 @@ export default function SpeedtestTrackerWidget({ {t("noSectionsEnabled")} )} + {!noSectionsEnabled && !hasStatSection && !hasChart && } ); } diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx index e9d7b98ad..99078b4d2 100644 --- a/packages/widgets/src/stocks/component.tsx +++ b/packages/widgets/src/stocks/component.tsx @@ -7,6 +7,7 @@ import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; function round(value: number) { @@ -24,7 +25,12 @@ function calculateChangePercentage(currentPrice: number, previousClose: number) export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) { const t = useScopedI18n("widget.stockPrice"); const theme = useMantineTheme(); - const [{ data }] = clientApi.widget.stockPrice.getPriceHistory.useSuspenseQuery(options); + const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options, { + staleTime: 5 * 60 * 1000, + }); + + if (!result) return ; + const { data } = result; const stockValuesChange = round(calculateChange(data.priceHistory.at(-1) ?? 0, data.previousClose)); const stockValuesChangePercentage = round( diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index ba2d49c25..5c6271c09 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -7,6 +7,7 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { humanFileSize } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; @@ -32,18 +33,15 @@ const getDisplayText = (item: { used: string; available: string; percentage: num export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) { const queryInput = { integrationIds }; - const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(queryInput); + const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput, { + staleTime: 5 * 1000, + }); const utils = clientApi.useUtils(); const board = useRequiredBoard(); const scheme = useMantineColorScheme(); const t = useI18n(); - const lastItem = data.at(-1); - - if (!lastItem) return null; - const { fileSystem, smart } = lastItem.healthInfo; - clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, { onData(data) { utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) => @@ -52,6 +50,11 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo }, }); + const lastItem = data.at(-1); + + if (!lastItem) return ; + const { fileSystem, smart } = lastItem.healthInfo; + if (fileSystem.length === 0) { throw new NoIntegrationDataError(); } diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx index 2c25c8218..f2f9f1d87 100644 --- a/packages/widgets/src/system-resources/chart/common-chart.tsx +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import type { AreaChartSeries } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts"; -import { Card, Center, Group, Loader, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core"; +import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import type { TooltipProps, YAxisProps } from "recharts"; @@ -85,8 +85,13 @@ export const CommonChart = ({ )} {data.length <= 1 ? (
- - 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} /> + + {showIcon && 100 ? 20 : 14} stroke={1.5} />} + {showText && ( + 100 ? "md" : "xs"} fw={"bold"} ta="center"> + {title} + + )}
) : ( diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index d1d54ff85..d9e5a8604 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -18,9 +18,14 @@ const MAX_QUEUE_SIZE = 15; export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) { const { ref, width } = useElementSize(); - const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery({ - integrationIds, - }); + const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery( + { + integrationIds, + }, + { + staleTime: 5 * 1000, + }, + ); const memoryCapacityInBytes = (data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0); diff --git a/packages/widgets/src/timetable/component.tsx b/packages/widgets/src/timetable/component.tsx index ceaf9e41d..539d7e6c7 100644 --- a/packages/widgets/src/timetable/component.tsx +++ b/packages/widgets/src/timetable/component.tsx @@ -26,17 +26,22 @@ interface TimetableWidgetInnerProps { } const TimetableWidgetInner = ({ station, integrationId }: TimetableWidgetInnerProps) => { - const [timetable] = clientApi.widget.timetable.getTimetable.useSuspenseQuery({ - integrationId, - stationId: station.value, - limit: 10, - }); + const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery( + { + integrationId, + stationId: station.value, + limit: 10, + }, + { + staleTime: 60 * 1000, + }, + ); const t = useScopedI18n("widget.timetable"); return ( {t("title", { station: station.label })} - {timetable.entries.map((entry) => ( + {(timetable?.entries ?? []).map((entry) => ( {entry.line && ( diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 1b26b6c67..0ff5e9132 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -35,7 +35,10 @@ interface TracearrContentProps { function TracearrContent({ integrationIds, options, width, isEditMode }: TracearrContentProps) { const t = useScopedI18n("widget.tracearr"); - const [dashboardData] = clientApi.widget.tracearr.getDashboard.useSuspenseQuery({ integrationIds }); + const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery( + { integrationIds }, + { staleTime: 5 * 1000 }, + ); const utils = clientApi.useUtils(); clientApi.widget.tracearr.subscribeToDashboard.useSubscription( diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index da9494519..2f8dea22c 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -40,15 +40,20 @@ export function UmamiContent({ const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; - const [results] = clientApi.widget.umami.getVisitorStats.useSuspenseQuery({ - integrationIds, - websiteId, - timeFrame, - eventName, - }); + const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery( + { + integrationIds, + websiteId, + timeFrame, + eventName, + }, + { staleTime: 5 * 60 * 1000 }, + ); const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId }; - const [activeVisitors] = clientApi.widget.umami.getActiveVisitors.useSuspenseQuery(activeVisitorsInput); + const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(activeVisitorsInput, { + staleTime: 30 * 1000, + }); const utils = clientApi.useUtils(); clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, { onData(count) { @@ -57,8 +62,8 @@ export function UmamiContent({ }); const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery( - { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].sort() }, - { enabled: viewMode === "events" && eventNames.length > 0 }, + { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() }, + { enabled: viewMode === "events" && eventNames.length > 0, staleTime: 5 * 60 * 1000 }, ); const multiEventTotal = multiEventSeries diff --git a/packages/widgets/src/umami/umami-events-content.tsx b/packages/widgets/src/umami/umami-events-content.tsx index 0962d3681..0351e906c 100644 --- a/packages/widgets/src/umami/umami-events-content.tsx +++ b/packages/widgets/src/umami/umami-events-content.tsx @@ -28,12 +28,15 @@ export function UmamiEventsContent({ const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; - const [series] = clientApi.widget.umami.getMultiEventTimeSeries.useSuspenseQuery({ - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - eventNames: [...eventNames].sort(), - }); + const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery( + { + integrationId: integrationIds[0] ?? "", + websiteId, + timeFrame, + eventNames: [...eventNames].toSorted(), + }, + { staleTime: 5 * 60 * 1000 }, + ); if (eventNames.length === 0) { return ( @@ -48,7 +51,7 @@ export function UmamiEventsContent({ // Collect all unique timestamps across all series const allTimestamps = Array.from( new Set(series.flatMap(({ dataPoints }) => dataPoints.map(({ x: xPoint }) => xPoint))), - ).sort(); + ).toSorted(); // Build per-event lookup by timestamp string const byEvent = new Map( diff --git a/packages/widgets/src/umami/umami-top-list.tsx b/packages/widgets/src/umami/umami-top-list.tsx index 1d9a87988..125115f0c 100644 --- a/packages/widgets/src/umami/umami-top-list.tsx +++ b/packages/widgets/src/umami/umami-top-list.tsx @@ -15,23 +15,29 @@ interface UmamiTopListProps { export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useScopedI18n("widget.umami"); - const [data] = clientApi.widget.umami.getTopPages.useSuspenseQuery({ - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - limit, - }); + const { data = [] } = clientApi.widget.umami.getTopPages.useQuery( + { + integrationId: integrationIds[0] ?? "", + websiteId, + timeFrame, + limit, + }, + { staleTime: 5 * 60 * 1000 }, + ); return ; } export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useScopedI18n("widget.umami"); - const [data] = clientApi.widget.umami.getTopReferrers.useSuspenseQuery({ - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - limit, - }); + const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery( + { + integrationId: integrationIds[0] ?? "", + websiteId, + timeFrame, + limit, + }, + { staleTime: 5 * 60 * 1000 }, + ); return ( ; + const devices = data.flatMap((instance) => instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })), ); diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 4d484138a..2a3b6136b 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -9,6 +9,7 @@ import { formatNumber } from "@homarr/common"; import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; @@ -106,7 +107,10 @@ interface UptimeKumaContentProps { function UptimeKumaContent({ integrationIds, isEditMode, options, width }: UptimeKumaContentProps) { const t = useScopedI18n("widget.uptimeKuma"); - const [dashboardData] = clientApi.widget.uptimeKuma.getDashboard.useSuspenseQuery({ integrationIds }); + const { data: dashboardData } = clientApi.widget.uptimeKuma.getDashboard.useQuery( + { integrationIds }, + { staleTime: 30 * 1000 }, + ); const utils = clientApi.useUtils(); clientApi.widget.uptimeKuma.subscribeToDashboard.useSubscription( @@ -127,6 +131,8 @@ function UptimeKumaContent({ integrationIds, isEditMode, options, width }: Uptim }, ); + if (!dashboardData) return ; + const combined = dashboardData.reduce( (acc, item) => ({ totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors, diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index f81736c27..a2e9778ca 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client"; import { metricToImperial } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { WeatherDescription, WeatherIcon } from "./icon"; @@ -18,8 +19,8 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr latitude: options.location.latitude, longitude: options.location.longitude, }; - const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(input, { - refetchOnMount: false, + const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input, { + staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, }); @@ -29,6 +30,8 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr onData: (data) => utils.widget.weather.atLocation.setData(input, data), }); + if (!weather) return ; + return ( =14} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} - engines: {node: ^10 || ^12 || >=14} - postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -13188,6 +13193,10 @@ snapshots: '@tanstack/query-devtools@5.101.0': {} + '@tanstack/query-persist-client-core@5.101.0': + dependencies: + '@tanstack/query-core': 5.101.0 + '@tanstack/react-query-devtools@5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/query-devtools': 5.101.0 @@ -17218,12 +17227,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postgres-array@2.0.0: {} postgres-bytea@1.0.0: {} @@ -18772,7 +18775,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.15 rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index da1c3f94e..61650269f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -45,6 +45,7 @@ catalog: "@semantic-release/release-notes-generator": ^14.1.1 "@t3-oss/env-nextjs": ^0.13.11 "@tabler/icons-react": ^3.44.0 + "@tanstack/query-persist-client-core": ^5.101.0 "@tanstack/react-query": ^5.101.0 "@tanstack/react-query-devtools": ^5.101.0 "@tanstack/react-query-next-experimental": ^5.101.0