Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
008b506dfa | ||
|
|
3b0f9bec1e | ||
|
|
7c6706b82b | ||
|
|
6cd947fc67 | ||
|
|
c8a2be553a | ||
|
|
80d61f5428 | ||
|
|
a3a26c0c4f | ||
|
|
69e87fd3fe | ||
|
|
93b2ed88ed | ||
|
|
ef27d739bb | ||
|
|
9fc0e5f94c | ||
|
|
60ae487842 | ||
|
|
4bfde12b65 | ||
|
|
5899e1b44b | ||
|
|
201a461729 | ||
|
|
87798a9789 | ||
|
|
682f6bd3ea | ||
|
|
f49ae79c99 | ||
|
|
f7f6d1e382 | ||
|
|
6c623a3a2d | ||
|
|
b65eef08ea | ||
|
|
598307c7bf | ||
|
|
94cbbde576 | ||
|
|
1b003bfc50 | ||
|
|
394debbea1 | ||
|
|
cb5ae65f8a | ||
|
|
fc80a61aef | ||
|
|
a2668740c0 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
[tools]
|
||||
node = "24.16.0"
|
||||
pnpm = "10.33.4"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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("."));
|
||||
};
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,8 @@ export {
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
createGetSetChannel,
|
||||
createQueryCacheChannel,
|
||||
getAllQueryCacheAsync,
|
||||
} from "./lib/channel";
|
||||
|
||||
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+14
-11
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user