feat(ui): add global query cache leveraging Redis (#5811)

This commit is contained in:
Thomas authored and GitHub committed 2026-06-10 18:52:06 +02:00
1 parent 29bd7101b1
commit 74443a49b2
68 files changed
+843 -191

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
[tools]
node = "24.16.0"
pnpm = "10.33.4"
+1
View File
@@ -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:",
@@ -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<string>;
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
});
@@ -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) {
@@ -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 = <TParams extends Record<string, unknown>>(
),
]);
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
@@ -108,3 +117,37 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
};
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<PersistedEntry>(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 <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null;
}
}
+1
View File
@@ -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",
+34
View File
@@ -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("."));
};
+1
View File
@@ -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)),
+54
View File
@@ -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<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[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();
}),
});
+3 -1
View File
@@ -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",
+224
View File
@@ -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<string, { value: string; expiresAt: number }>();
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();
});
});
+14 -1
View File
@@ -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 (
<BoardContext.Provider
value={{
@@ -73,7 +86,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => 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
+2
View File
@@ -13,6 +13,8 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
createQueryCacheChannel,
getAllQueryCacheAsync,
} from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
+43
View File
@@ -125,6 +125,49 @@ export const createGetSetChannel = <TData>(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<Record<string, string>> => {
const hkey = queryCacheHashKey(userId, boardId);
return await getSetClient.hgetall(hkey);
};
/**
* Creates a new cache channel.
* @param name name of the channel
+21 -16
View File
@@ -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<string | null>(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<ReactQuillOnChange>(
(_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 <WidgetEmptyState />;
return (
<Stack h="100%" gap="xs" p="sm">
+8 -7
View File
@@ -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 <WidgetEmptyState />;
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
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}…`} />}>
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
@@ -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<RouterOutputs["widget"]["app"]["ping"]>(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 <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
const isError = "error" in pingResult || pingResult.statusCode >= 500;
return (
@@ -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 <WidgetEmptyState />;
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
}
@@ -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(
@@ -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);
@@ -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
@@ -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);
+1 -1
View File
@@ -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));
},
+4 -7
View File
@@ -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 <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
};
@@ -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 (
<CalendarDay
// new Date() does not work here, because for timezones like UTC-7 it will
// show one day earlier (probably due to the time being set to 00:00)
// see https://github.com/homarr-labs/homarr/pull/3120
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
@@ -0,0 +1,15 @@
import { Center, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
export const WidgetEmptyState = () => {
const t = useI18n();
return (
<Center h="100%" w="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{t("widget.common.error.noData")}
</Text>
</Center>
);
};
+4 -1
View File
@@ -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(
@@ -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,
@@ -20,12 +20,12 @@ import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
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,
+5 -1
View File
@@ -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, {
+2 -3
View File
@@ -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");
+1 -1
View File
@@ -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<typeof columnsList, (typeof sortingExclusion)[number]>;
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
@@ -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;
+8 -8
View File
@@ -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,
@@ -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 <WidgetEmptyState />;
const activeNodes = healthData.nodes.reduce(running, 0);
const activeVMs = healthData.vms.reduce(running, 0);
const activeLXCs = healthData.lxcs.reduce(running, 0);
@@ -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;
@@ -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 <WidgetEmptyState />;
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{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));
};
@@ -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 <NoAlbumSelected />;
}
const [album] = clientApi.widget.immich.getAlbum.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
albumId: options.albumId,
});
if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) {
return <NoPhotosInAlbum />;
@@ -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 <WidgetEmptyState />;
return (
<Stack gap="md" h="100%" p="md">
@@ -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,
@@ -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 (
<Stack p="xs" gap="sm">
@@ -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 <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return (
@@ -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 <WidgetEmptyState />;
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
const data = [
@@ -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,
},
@@ -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<View>(options.defaultView);
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const t = useI18n("widget.mediaTranscoding");
if (!transcodingData) return <WidgetEmptyState />;
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const isTiny = width < 256;
return (
@@ -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 <WidgetEmptyState />;
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
return (
@@ -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,
@@ -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,
@@ -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,
@@ -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 <WidgetEmptyState />;
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
const showHero = options.showInboxRatio && canShowInboxHero;
+2
View File
@@ -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 <TKind extends WidgetKind>(
+7 -2
View File
@@ -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<typeof d> => 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;
+3 -3
View File
@@ -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;
@@ -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();
@@ -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")}
</Text>
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
);
}
+7 -1
View File
@@ -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 <WidgetEmptyState />;
const { data } = result;
const stockValuesChange = round(calculateChange(data.priceHistory.at(-1) ?? 0, data.previousClose));
const stockValuesChangePercentage = round(
@@ -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 <WidgetEmptyState />;
const { fileSystem, smart } = lastItem.healthInfo;
if (fileSystem.length === 0) {
throw new NoIntegrationDataError();
}
@@ -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 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"}>
<Loader type="bars" size={height > 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} />
<Stack px={"xs"} align={"center"} gap={4}>
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} size={height > 100 ? 20 : 14} stroke={1.5} />}
{showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title}
</Text>
)}
</Stack>
</Center>
) : (
@@ -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);
+11 -6
View File
@@ -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 (
<Stack w="100%" gap="xs" p="sm">
<Text fw="bold">{t("title", { station: station.label })}</Text>
{timetable.entries.map((entry) => (
{(timetable?.entries ?? []).map((entry) => (
<Group key={`${entry.timestamp.toISOString()}-${entry.location}`} justify="space-between" w="100%">
<Group gap="sm">
{entry.line && (
+4 -1
View File
@@ -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(
+14 -9
View File
@@ -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
@@ -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(
+18 -12
View File
@@ -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 <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
}
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 (
<UmamiTopList
items={data}
+4 -1
View File
@@ -8,6 +8,7 @@ import type { UpsStatus, UpsSummary } from "@homarr/integrations/types";
import type { ScopedTranslationFunction } from "@homarr/translation";
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 { NoIntegrationSelectedError } from "../errors/no-integration-selected";
@@ -39,7 +40,7 @@ interface UpsContentProps {
function UpsContent({ integrationIds, options, isEditMode, width }: UpsContentProps) {
const t = useScopedI18n("widget.ups");
const [data] = clientApi.widget.ups.getSummaries.useSuspenseQuery({ integrationIds });
const { data } = clientApi.widget.ups.getSummaries.useQuery({ integrationIds }, { staleTime: 30 * 1000 });
const utils = clientApi.useUtils();
clientApi.widget.ups.subscribeSummaries.useSubscription(
@@ -59,6 +60,8 @@ function UpsContent({ integrationIds, options, isEditMode, width }: UpsContentPr
},
);
if (!data) return <WidgetEmptyState />;
const devices = data.flatMap((instance) =>
instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })),
);
@@ -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 <WidgetEmptyState />;
const combined = dashboardData.reduce<UptimeKumaDashboardData>(
(acc, item) => ({
totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors,
+5 -2
View File
@@ -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 <WidgetEmptyState />;
return (
<Stack
align="center"
+14 -11
View File
@@ -132,6 +132,9 @@ catalogs:
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0
'@tanstack/query-persist-client-core':
specifier: ^5.101.0
version: 5.101.0
'@tanstack/react-query':
specifier: ^5.101.0
version: 5.101.0
@@ -790,6 +793,9 @@ importers:
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
'@tanstack/query-persist-client-core':
specifier: 'catalog:'
version: 5.101.0
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.101.0(react@19.2.7)
@@ -5208,6 +5214,9 @@ packages:
'@tanstack/query-devtools@5.101.0':
resolution: {integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==}
'@tanstack/query-persist-client-core@5.101.0':
resolution: {integrity: sha512-LH99WepGVLwlLfuOcQcPK7f3Xg/Gf+xlMMIj9xWu/8oQ3egnDzjr+a4HvEmi6PGob5SmGXvmDKZaH5+In9dzjw==}
'@tanstack/react-query-devtools@5.101.0':
resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==}
peerDependencies:
@@ -9008,10 +9017,6 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=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:
+1
View File
@@ -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