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:",
|
"@modelcontextprotocol/sdk": "catalog:",
|
||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
|
"@tanstack/query-persist-client-core": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "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 type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { showWarningNotification } from "@homarr/notifications";
|
import { showWarningNotification } from "@homarr/notifications";
|
||||||
|
|
||||||
|
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -52,12 +55,14 @@ const wsClient = createWSClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
|
const [queryPersister] = useState(() => createWidgetQueryPersister());
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 5 * 1000,
|
persister: queryPersister.persisterFn,
|
||||||
|
staleTime: queryCacheDefaultStaleTimeMs,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
onError(error) {
|
onError(error) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
|
import type { DehydratedState } from "@tanstack/react-query";
|
||||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||||
|
import { parse } from "superjson";
|
||||||
|
|
||||||
import { getQueryClient } from "@homarr/api/server";
|
import { getQueryClient } from "@homarr/api/server";
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
@@ -14,6 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common";
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
import { getAllQueryCacheAsync } from "@homarr/redis";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
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 (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<IntegrationProvider integrations={integrations}>
|
<IntegrationProvider integrations={integrations}>
|
||||||
|
<Suspense>
|
||||||
|
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||||
|
</Suspense>
|
||||||
<DynamicClientBoard />
|
<DynamicClientBoard />
|
||||||
</IntegrationProvider>
|
</IntegrationProvider>
|
||||||
</HydrationBoundary>
|
</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": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
|
"./query-cache": "./src/query-cache.ts",
|
||||||
"./server": "./src/server.ts",
|
"./server": "./src/server.ts",
|
||||||
"./websocket": "./src/websocket.ts",
|
"./websocket": "./src/websocket.ts",
|
||||||
"./shared": "./src/shared.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)),
|
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
||||||
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
||||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
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)),
|
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
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 { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { queryCacheDefaultStaleTimeMs } from "./query-cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a headers callback for a given source
|
* Creates a headers callback for a given source
|
||||||
* It will set the x-trpc-source header and cookies if needed
|
* It will set the x-trpc-source header and cookies if needed
|
||||||
@@ -58,7 +60,7 @@ export const makeQueryClient = () => {
|
|||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 5 * 1000,
|
staleTime: queryCacheDefaultStaleTimeMs,
|
||||||
},
|
},
|
||||||
dehydrate: {
|
dehydrate: {
|
||||||
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
|
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 type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
import { updateBoardName } from "./updater";
|
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
|
// Update the board name so it can be used within updateBoard method
|
||||||
updateBoardName(initialBoard.name);
|
updateBoardName(initialBoard.name);
|
||||||
|
|
||||||
@@ -42,6 +45,16 @@ export const BoardProvider = ({
|
|||||||
};
|
};
|
||||||
}, [pathname, utils, initialBoard.name]);
|
}, [pathname, utils, initialBoard.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveQueryCacheBoardId(data.id);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (getActiveQueryCacheBoardId() === data.id) {
|
||||||
|
setActiveQueryCacheBoardId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [data.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardContext.Provider
|
<BoardContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -73,7 +86,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
if (typeof window === "undefined") return board.layouts.at(0)!.id;
|
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
|
// Fallback to smallest if none exists with breakpoint smaller than window width
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export {
|
|||||||
handshakeAsync,
|
handshakeAsync,
|
||||||
createSubPubChannel,
|
createSubPubChannel,
|
||||||
createGetSetChannel,
|
createGetSetChannel,
|
||||||
|
createQueryCacheChannel,
|
||||||
|
getAllQueryCacheAsync,
|
||||||
} from "./lib/channel";
|
} from "./lib/channel";
|
||||||
|
|
||||||
export { createIntegrationHistoryChannel } from "./lib/channels/history-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.
|
* Creates a new cache channel.
|
||||||
* @param name name of the channel
|
* @param name name of the channel
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useTimeAgo } from "@homarr/common";
|
|||||||
import type { AnchorNotePermission } from "@homarr/integrations";
|
import type { AnchorNotePermission } from "@homarr/integrations";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
@@ -144,13 +145,13 @@ interface AnchorNoteWidgetContentProps {
|
|||||||
|
|
||||||
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
|
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
|
||||||
const t = useScopedI18n("widget.anchorNote");
|
const t = useScopedI18n("widget.anchorNote");
|
||||||
const [note, { refetch }] = clientApi.widget.anchorNotes.getNote.useSuspenseQuery(
|
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery(
|
||||||
{
|
{
|
||||||
integrationId,
|
integrationId,
|
||||||
noteId,
|
noteId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 15 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -159,31 +160,32 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
|||||||
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
|
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [draftTitle, setDraftTitle] = useState(note.title);
|
const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
|
||||||
const [draftContent, setDraftContent] = useState(note.content ?? "");
|
const [draftContent, setDraftContent] = useState(note?.content ?? "");
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing) return;
|
if (isEditing || !note) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setDraftTitle(note.title);
|
setDraftTitle(note.title);
|
||||||
setDraftContent(note.content ?? "");
|
setDraftContent(note.content ?? "");
|
||||||
});
|
});
|
||||||
}, [isEditing, note.content, note.title]);
|
}, [isEditing, note]);
|
||||||
|
|
||||||
const canEdit = canEditPermission(note.permission);
|
const canEdit = canEditPermission(note?.permission ?? "viewer");
|
||||||
const isViewer = note.permission === "viewer";
|
const isViewer = !note || note.permission === "viewer";
|
||||||
const updatedAt = useMemo(() => new Date(note.updatedAt), [note.updatedAt]);
|
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
|
||||||
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
|
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!note) return false;
|
||||||
const normalizedTitle = draftTitle.trim() || note.title;
|
const normalizedTitle = draftTitle.trim() || note.title;
|
||||||
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
|
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
|
||||||
}, [draftContent, draftTitle, note.content, note.title]);
|
}, [draftContent, draftTitle, note]);
|
||||||
|
|
||||||
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
|
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>(
|
const handleEditorChange = useCallback<ReactQuillOnChange>(
|
||||||
(_html, _delta, source, editor) => {
|
(_html, _delta, source, editor) => {
|
||||||
if (!isEditing || source !== "user") return;
|
if (!isEditing || source !== "user") return;
|
||||||
@@ -194,26 +196,27 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
const handleEdit = useCallback(() => {
|
||||||
if (!canEdit) return;
|
if (!canEdit || !note) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setDraftTitle(note.title);
|
setDraftTitle(note.title);
|
||||||
setDraftContent(note.content ?? "");
|
setDraftContent(note.content ?? "");
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
});
|
});
|
||||||
}, [canEdit, note.content, note.title]);
|
}, [canEdit, note]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
if (!note) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setDraftTitle(note.title);
|
setDraftTitle(note.title);
|
||||||
setDraftContent(note.content ?? "");
|
setDraftContent(note.content ?? "");
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
});
|
});
|
||||||
}, [note.content, note.title]);
|
}, [note]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!canEdit) return;
|
if (!canEdit || !note) return;
|
||||||
|
|
||||||
const normalizedTitle = draftTitle.trim() || note.title || t("untitled");
|
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 (
|
return (
|
||||||
<Stack h="100%" gap="xs" p="sm">
|
<Stack h="100%" gap="xs" p="sm">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Fragment, Suspense } from "react";
|
import { Fragment, Suspense } from "react";
|
||||||
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
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 combineClasses from "clsx";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -13,6 +13,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./app.module.css";
|
import classes from "./app.module.css";
|
||||||
import { PingDot } from "./ping/ping-dot";
|
import { PingDot } from "./ping/ping-dot";
|
||||||
@@ -22,20 +23,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [app] = clientApi.app.byId.useSuspenseQuery(
|
const { data: app } = clientApi.app.byId.useQuery(
|
||||||
{
|
{
|
||||||
id: options.appId,
|
id: options.appId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
useRegisterSpotlightContextResults(
|
useRegisterSpotlightContextResults(
|
||||||
`app-${app.id}`,
|
`app-${app?.id ?? options.appId}`,
|
||||||
app.href
|
app?.href
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: app.id,
|
id: app.id,
|
||||||
@@ -44,7 +44,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
interaction() {
|
interaction() {
|
||||||
return {
|
return {
|
||||||
type: "link",
|
type: "link",
|
||||||
// We checked above that app.href is defined
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
href: app.href!,
|
href: app.href!,
|
||||||
newTab: options.openInNewTab,
|
newTab: options.openInNewTab,
|
||||||
@@ -56,6 +55,8 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
[app, options.openInNewTab],
|
[app, options.openInNewTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!app) return <WidgetEmptyState />;
|
||||||
|
|
||||||
const isTiny = height < 100 || width < 100;
|
const isTiny = height < 100 || width < 100;
|
||||||
const isColumnLayout = options.layout.startsWith("column");
|
const isColumnLayout = options.layout.startsWith("column");
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip.Floating>
|
</Tooltip.Floating>
|
||||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
{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} />
|
<PingIndicator appId={app.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { IconCheck, IconMinus, IconX } from "@tabler/icons-react";
|
||||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { PingDot } from "./ping-dot";
|
import { PingDot } from "./ping-dot";
|
||||||
|
|
||||||
@@ -11,27 +10,24 @@ interface PingIndicatorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
|
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
|
||||||
const [ping] = clientApi.widget.app.ping.useSuspenseQuery(
|
const t = useI18n();
|
||||||
{
|
const { data: pingResult } = clientApi.widget.app.ping.useQuery(
|
||||||
id: appId,
|
{ id: appId },
|
||||||
},
|
{ refetchOnMount: false, refetchOnWindowFocus: false },
|
||||||
{
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping);
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
clientApi.widget.app.updatedPing.useSubscription(
|
clientApi.widget.app.updatedPing.useSubscription(
|
||||||
{ id: appId },
|
{ id: appId },
|
||||||
{
|
{
|
||||||
onData(data) {
|
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;
|
const isError = "error" in pingResult || pingResult.statusCode >= 500;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { AudioStatsContent } from "./audio-stats-content";
|
import { AudioStatsContent } from "./audio-stats-content";
|
||||||
|
|
||||||
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
|
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
|
||||||
const [response] = clientApi.widget.audioStats.getStats.useSuspenseQuery({
|
const { data: response } = clientApi.widget.audioStats.getStats.useQuery(
|
||||||
integrationId: integrationIds[0] ?? "",
|
{ integrationId: integrationIds[0] ?? "" },
|
||||||
});
|
{ staleTime: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) return <WidgetEmptyState />;
|
||||||
|
|
||||||
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
|
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
|
||||||
}
|
}
|
||||||
@@ -36,11 +36,14 @@ export default function BeszelAlertsWidget({
|
|||||||
isEditMode,
|
isEditMode,
|
||||||
}: WidgetComponentProps<"beszelAlerts">) {
|
}: WidgetComponentProps<"beszelAlerts">) {
|
||||||
const t = useScopedI18n("widget.beszelAlerts");
|
const t = useScopedI18n("widget.beszelAlerts");
|
||||||
const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({
|
const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery(
|
||||||
integrationIds,
|
{
|
||||||
includeHistory: options.showHistory,
|
integrationIds,
|
||||||
maxHistoryItems: options.maxHistoryItems,
|
includeHistory: options.showHistory,
|
||||||
});
|
maxHistoryItems: options.maxHistoryItems,
|
||||||
|
},
|
||||||
|
{ staleTime: 30 * 1000 },
|
||||||
|
);
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
clientApi.widget.beszel.subscribeAlerts.useSubscription(
|
clientApi.widget.beszel.subscribeAlerts.useSubscription(
|
||||||
|
|||||||
@@ -344,7 +344,10 @@ export default function BeszelSystemGridWidget({
|
|||||||
height,
|
height,
|
||||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||||
const t = useScopedI18n("widget.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);
|
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
||||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ export default function BeszelSystemStatsWidget({
|
|||||||
width,
|
width,
|
||||||
}: WidgetComponentProps<"beszelSystemStats">) {
|
}: WidgetComponentProps<"beszelSystemStats">) {
|
||||||
const t = useScopedI18n("widget.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(
|
const systems = useMemo(
|
||||||
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
|
() => 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 showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
|
||||||
|
|
||||||
const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({
|
const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||||
integrationIds,
|
{
|
||||||
systemId: selectedSystem ?? systems[0]?.value ?? "",
|
integrationIds,
|
||||||
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
|
systemId: selectedSystem ?? systems[0]?.value ?? "",
|
||||||
includeDocker: showDocker,
|
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
|
||||||
});
|
includeDocker: showDocker,
|
||||||
|
},
|
||||||
|
{ staleTime: 30 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
// BeszelSystemStats fields are documented in beszel-types.ts.
|
// 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
|
// 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,
|
width,
|
||||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||||
const t = useScopedI18n("widget.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);
|
const size = getSizeConfig(width);
|
||||||
|
|
||||||
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import classes from "./bookmark.module.css";
|
|||||||
|
|
||||||
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
|
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
|
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
|
||||||
select(data) {
|
select(data) {
|
||||||
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
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,
|
releaseType: options.releaseType,
|
||||||
showUnmonitored: options.showUnmonitored,
|
showUnmonitored: options.showUnmonitored,
|
||||||
};
|
};
|
||||||
const [data] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(input, {
|
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, {
|
||||||
refetchOnMount: false,
|
staleTime: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: 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} />;
|
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(
|
.filter(
|
||||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
(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 (
|
return (
|
||||||
<CalendarDay
|
<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()}
|
date={dayjs(tileDate).toDate()}
|
||||||
events={eventsForDate}
|
events={eventsForDate}
|
||||||
disabled={isEditMode || eventsForDate.length === 0}
|
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) {
|
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();
|
const utils = clientApi.useUtils();
|
||||||
clientApi.widget.coolify.subscribeInstancesInfo.useSubscription(
|
clientApi.widget.coolify.subscribeInstancesInfo.useSubscription(
|
||||||
|
|||||||
@@ -51,12 +51,12 @@ export default function DnsHoleControlsWidget({
|
|||||||
.map(({ id }) => id)
|
.map(({ id }) => id)
|
||||||
.filter((id) => integrationIds.includes(id));
|
.filter((id) => integrationIds.includes(id));
|
||||||
|
|
||||||
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ import type { widgetKind } from ".";
|
|||||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||||
|
|
||||||
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
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,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -185,7 +185,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
|||||||
const isTiny = width <= 256;
|
const isTiny = width <= 256;
|
||||||
|
|
||||||
const utils = clientApi.useUtils();
|
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);
|
const relativeTime = useTimeAgo(timestamp);
|
||||||
|
|
||||||
clientApi.docker.subscribeContainers.useSubscription(undefined, {
|
clientApi.docker.subscribeContainers.useSubscription(undefined, {
|
||||||
|
|||||||
@@ -87,13 +87,13 @@ export default function DownloadClientsWidget({
|
|||||||
integrationIds.includes(id) ? [id] : [],
|
integrationIds.includes(id) ? [id] : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
|
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
limitPerIntegration: options.limitPerIntegration,
|
limitPerIntegration: options.limitPerIntegration,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -101,7 +101,6 @@ export default function DownloadClientsWidget({
|
|||||||
);
|
);
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
//Translations
|
|
||||||
const t = useScopedI18n("widget.downloads");
|
const t = useScopedI18n("widget.downloads");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const columnsList = [
|
|||||||
] as const satisfies (keyof ExtendedDownloadClientItem)[];
|
] as const satisfies (keyof ExtendedDownloadClientItem)[];
|
||||||
const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][];
|
const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][];
|
||||||
const columnsSort = columnsList.filter((column) =>
|
const columnsSort = columnsList.filter((column) =>
|
||||||
sortingExclusion.some((exclusion) => exclusion !== column),
|
sortingExclusion.every((exclusion) => exclusion !== column),
|
||||||
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
|
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
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[]) => {
|
export const useUpdatingCpuStatus = (integrationIds: string[]) => {
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const [firewallsCpuData] = clientApi.widget.firewall.getFirewallCpuStatus.useSuspenseQuery(
|
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -225,12 +225,12 @@ export const useUpdatingCpuStatus = (integrationIds: string[]) => {
|
|||||||
|
|
||||||
export const useUpdatingMemoryStatus = (integrationIds: string[]) => {
|
export const useUpdatingMemoryStatus = (integrationIds: string[]) => {
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const [firewallsMemoryData] = clientApi.widget.firewall.getFirewallMemoryStatus.useSuspenseQuery(
|
const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 15 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -266,12 +266,12 @@ export const useUpdatingMemoryStatus = (integrationIds: string[]) => {
|
|||||||
|
|
||||||
export const useUpdatingVersionStatus = (integrationIds: string[]) => {
|
export const useUpdatingVersionStatus = (integrationIds: string[]) => {
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const [firewallsVersionData] = clientApi.widget.firewall.getFirewallVersionStatus.useSuspenseQuery(
|
const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 60 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -306,12 +306,12 @@ export const useUpdatingVersionStatus = (integrationIds: string[]) => {
|
|||||||
|
|
||||||
export const useUpdatingInterfacesStatus = (integrationIds: string[]) => {
|
export const useUpdatingInterfacesStatus = (integrationIds: string[]) => {
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const [firewallsInterfacesData] = clientApi.widget.firewall.getFirewallInterfacesStatus.useSuspenseQuery(
|
const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 30 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import type { Resource } from "@homarr/integrations/types";
|
import type { Resource } from "@homarr/integrations/types";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { formatUptime } from "../system-health";
|
import { formatUptime } from "../system-health";
|
||||||
import { ResourceAccordionItem } from "./resource-accordion-item";
|
import { ResourceAccordionItem } from "./resource-accordion-item";
|
||||||
@@ -34,12 +35,12 @@ export const ClusterHealthMonitoring = ({
|
|||||||
width,
|
width,
|
||||||
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
|
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [healthData] = clientApi.widget.healthMonitoring.getClusterHealthStatus.useSuspenseQuery(
|
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery(
|
||||||
{
|
{
|
||||||
integrationId,
|
integrationId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -56,6 +57,8 @@ export const ClusterHealthMonitoring = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!healthData) return <WidgetEmptyState />;
|
||||||
|
|
||||||
const activeNodes = healthData.nodes.reduce(running, 0);
|
const activeNodes = healthData.nodes.reduce(running, 0);
|
||||||
const activeVMs = healthData.vms.reduce(running, 0);
|
const activeVMs = healthData.vms.reduce(running, 0);
|
||||||
const activeLXCs = healthData.lxcs.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";
|
integration.kind === "proxmox" || integration.kind === "mock";
|
||||||
|
|
||||||
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
|
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 t = useI18n();
|
||||||
|
|
||||||
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
|
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type { TranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { CpuRing } from "./rings/cpu-ring";
|
import { CpuRing } from "./rings/cpu-ring";
|
||||||
import { CpuTempRing } from "./rings/cpu-temp-ring";
|
import { CpuTempRing } from "./rings/cpu-temp-ring";
|
||||||
import { GpuRing } from "./rings/gpu-ring";
|
import { GpuRing } from "./rings/gpu-ring";
|
||||||
@@ -51,12 +52,12 @@ export const SystemHealthMonitoring = ({
|
|||||||
width,
|
width,
|
||||||
}: WidgetComponentProps<"healthMonitoring">) => {
|
}: WidgetComponentProps<"healthMonitoring">) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(
|
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -86,6 +87,8 @@ export const SystemHealthMonitoring = ({
|
|||||||
|
|
||||||
const isTiny = width < 256;
|
const isTiny = width < 256;
|
||||||
|
|
||||||
|
if (healthData.length === 0) return <WidgetEmptyState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="sm" className="health-monitoring">
|
<Stack h="100%" gap="sm" className="health-monitoring">
|
||||||
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
||||||
@@ -332,5 +335,5 @@ export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: Sm
|
|||||||
overallStatus: smartDisk?.overallStatus ?? "",
|
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 { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
@@ -17,14 +18,19 @@ export default function ImmichAlbumCarouselWidget({
|
|||||||
}: WidgetComponentProps<"immich-albumCarousel">) {
|
}: WidgetComponentProps<"immich-albumCarousel">) {
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
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) {
|
if (!options.albumId) {
|
||||||
return <NoAlbumSelected />;
|
return <NoAlbumSelected />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [album] = clientApi.widget.immich.getAlbum.useSuspenseQuery({
|
if (!album) return <WidgetEmptyState />;
|
||||||
integrationId: integrationIds[0] ?? "",
|
|
||||||
albumId: options.albumId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (album.assets.length === 0) {
|
if (album.assets.length === 0) {
|
||||||
return <NoPhotosInAlbum />;
|
return <NoPhotosInAlbum />;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
@@ -16,9 +17,14 @@ export default function ImmichServerStatsWidget({
|
|||||||
options,
|
options,
|
||||||
}: WidgetComponentProps<"immich-serverStats">) {
|
}: WidgetComponentProps<"immich-serverStats">) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [stats] = clientApi.widget.immich.getServerStats.useSuspenseQuery({
|
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(
|
||||||
integrationId: integrationIds[0] ?? "",
|
{
|
||||||
});
|
integrationId: integrationIds[0] ?? "",
|
||||||
|
},
|
||||||
|
{ staleTime: 15 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stats) return <WidgetEmptyState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" h="100%" p="md">
|
<Stack gap="md" h="100%" p="md">
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export default function IndexerManagerWidget({
|
|||||||
height,
|
height,
|
||||||
}: WidgetComponentProps<"indexerManager">) {
|
}: WidgetComponentProps<"indexerManager">) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery(
|
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ import { OverflowBadge } from "@homarr/ui";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
|
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
|
||||||
const [releases] = clientApi.widget.mediaRelease.getMediaReleases.useSuspenseQuery({
|
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery(
|
||||||
integrationIds,
|
{
|
||||||
});
|
integrationIds,
|
||||||
|
},
|
||||||
|
{ staleTime: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="xs" gap="sm">
|
<Stack p="xs" gap="sm">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from
|
|||||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||||
import classes from "../search-button.module.css";
|
import classes from "../search-button.module.css";
|
||||||
@@ -34,12 +35,12 @@ export default function MediaServerWidget({
|
|||||||
options,
|
options,
|
||||||
width,
|
width,
|
||||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||||
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
|
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
},
|
},
|
||||||
@@ -73,6 +74,7 @@ export default function MediaServerWidget({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mediaRequests) return <WidgetEmptyState />;
|
||||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { RequestStats } from "@homarr/integrations/types";
|
|||||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
@@ -35,12 +36,12 @@ export default function MediaServerWidget({
|
|||||||
width,
|
width,
|
||||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||||
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
||||||
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
|
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
},
|
},
|
||||||
@@ -48,6 +49,7 @@ export default function MediaServerWidget({
|
|||||||
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
|
if (!requestStats) return <WidgetEmptyState />;
|
||||||
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
|
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ export default function MediaServerWidget({
|
|||||||
integrationIds,
|
integrationIds,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
}: WidgetComponentProps<"mediaServer">) {
|
}: WidgetComponentProps<"mediaServer">) {
|
||||||
const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery(
|
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
showOnlyPlaying: options.showOnlyPlaying,
|
showOnlyPlaying: options.showOnlyPlaying,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { views } from ".";
|
import { views } from ".";
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { HealthCheckStatus } from "./health-check-status";
|
import { HealthCheckStatus } from "./health-check-status";
|
||||||
import { QueuePanel } from "./panels/queue.panel";
|
import { QueuePanel } from "./panels/queue.panel";
|
||||||
@@ -37,8 +38,8 @@ export default function MediaTranscodingWidget({
|
|||||||
pageSize: queuePageSize,
|
pageSize: queuePageSize,
|
||||||
page: queuePage,
|
page: queuePage,
|
||||||
};
|
};
|
||||||
const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery(input, {
|
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input, {
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
@@ -51,9 +52,11 @@ export default function MediaTranscodingWidget({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [view, setView] = useState<View>(options.defaultView);
|
const [view, setView] = useState<View>(options.defaultView);
|
||||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
|
||||||
|
|
||||||
const t = useI18n("widget.mediaTranscoding");
|
const t = useI18n("widget.mediaTranscoding");
|
||||||
|
|
||||||
|
if (!transcodingData) return <WidgetEmptyState />;
|
||||||
|
|
||||||
|
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||||
const isTiny = width < 256;
|
const isTiny = width < 256;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { formatNumber } from "@homarr/common";
|
import { formatNumber } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
|
|
||||||
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
|
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();
|
const utils = clientApi.useUtils();
|
||||||
clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, {
|
clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, {
|
||||||
onData(data) {
|
onData(data) {
|
||||||
@@ -22,6 +25,9 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
|
|||||||
});
|
});
|
||||||
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
|
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
|
||||||
|
|
||||||
|
if (!result) return <WidgetEmptyState />;
|
||||||
|
const { data } = result;
|
||||||
|
|
||||||
const title = options.title.trim().length > 0 ? options.title : options.domain;
|
const title = options.title.trim().length > 0 ? options.title : options.domain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export default function NetworkControllerNetworkStatusWidget({
|
|||||||
options,
|
options,
|
||||||
integrationIds,
|
integrationIds,
|
||||||
}: WidgetComponentProps<"networkControllerStatus">) {
|
}: WidgetComponentProps<"networkControllerStatus">) {
|
||||||
const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery(
|
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ dayjs.extend(duration);
|
|||||||
export default function NetworkControllerSummaryWidget({
|
export default function NetworkControllerSummaryWidget({
|
||||||
integrationIds,
|
integrationIds,
|
||||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||||
const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery(
|
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import { useScopedI18n } from "@homarr/translation/client";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
|
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
|
||||||
const [notificationIntegrations] = clientApi.widget.notifications.getNotifications.useSuspenseQuery(
|
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery(
|
||||||
{
|
{
|
||||||
...options,
|
...options,
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
@@ -71,9 +72,12 @@ const heroPartVisibility = {
|
|||||||
|
|
||||||
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
|
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
|
||||||
const t = useScopedI18n("widget.paperlessNgx");
|
const t = useScopedI18n("widget.paperlessNgx");
|
||||||
const [stats] = clientApi.widget.paperlessNgx.getStats.useSuspenseQuery({
|
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery(
|
||||||
integrationId: integrationIds[0] ?? "",
|
{ integrationId: integrationIds[0] ?? "" },
|
||||||
});
|
{ staleTime: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stats) return <WidgetEmptyState />;
|
||||||
|
|
||||||
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
|
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
|
||||||
const showHero = options.showInboxRatio && canShowInboxHero;
|
const showHero = options.showInboxRatio && canShowInboxHero;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createSettings } from "@homarr/settings/creator";
|
|||||||
import { reduceWidgetOptionsWithDefaultValues } from ".";
|
import { reduceWidgetOptionsWithDefaultValues } from ".";
|
||||||
import prefetchForApps from "./app/prefetch";
|
import prefetchForApps from "./app/prefetch";
|
||||||
import prefetchForBookmarks from "./bookmarks/prefetch";
|
import prefetchForBookmarks from "./bookmarks/prefetch";
|
||||||
|
import prefetchForDownloads from "./downloads/prefetch";
|
||||||
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
|
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
|
||||||
import type { inferOptionsFromCreator } from "./options";
|
import type { inferOptionsFromCreator } from "./options";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const prefetchCallbacks: Partial<{
|
|||||||
}> = {
|
}> = {
|
||||||
bookmarks: prefetchForBookmarks,
|
bookmarks: prefetchForBookmarks,
|
||||||
app: prefetchForApps,
|
app: prefetchForApps,
|
||||||
|
downloads: prefetchForDownloads,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
|||||||
);
|
);
|
||||||
}, [groupedRepositories]);
|
}, [groupedRepositories]);
|
||||||
|
|
||||||
const [results] = clientApi.useSuspenseQueries((t) =>
|
const queryResults = clientApi.useQueries((t) =>
|
||||||
batchedRepositories.flatMap(({ integrationId, repositories }) =>
|
batchedRepositories.flatMap(({ integrationId, repositories }) =>
|
||||||
t.widget.releases.getLatest({
|
t.widget.releases.getLatest({
|
||||||
integrationId,
|
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 repositories = useMemo(() => {
|
||||||
const formattedResults = options.repositories
|
const formattedResults = options.repositories
|
||||||
.map((repository) => {
|
.map((repository) => {
|
||||||
@@ -121,7 +126,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
|||||||
(repository) =>
|
(repository) =>
|
||||||
"error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease,
|
"error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease,
|
||||||
)
|
)
|
||||||
.sort((repoA, repoB) => {
|
.toSorted((repoA, repoB) => {
|
||||||
if ("error" in repoA) return -1;
|
if ("error" in repoA) return -1;
|
||||||
if ("error" in repoB) return 1;
|
if ("error" in repoB) return 1;
|
||||||
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
|
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import type { WidgetComponentProps } from "../definition";
|
|||||||
import classes from "./component.module.scss";
|
import classes from "./component.module.scss";
|
||||||
|
|
||||||
const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
||||||
const [feedEntries] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(input, {
|
const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input, {
|
||||||
refetchOnMount: false,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -26,7 +26,7 @@ const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]
|
|||||||
return oldData
|
return oldData
|
||||||
?.filter((entry) => entry.feedUrl !== updatedData.url)
|
?.filter((entry) => entry.feedUrl !== updatedData.url)
|
||||||
.concat(updatedData.entries)
|
.concat(updatedData.entries)
|
||||||
.sort((entryA, entryB) => {
|
.toSorted((entryA, entryB) => {
|
||||||
return entryA.published && entryB.published
|
return entryA.published && entryB.published
|
||||||
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
||||||
: 0;
|
: 0;
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export default function SmartHomeEntityStateWidget({
|
|||||||
entityId: options.entityId,
|
entityId: options.entityId,
|
||||||
integrationId,
|
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();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { AveragesSection } from "./averages";
|
import { AveragesSection } from "./averages";
|
||||||
import { mergeStats } from "./helpers";
|
import { mergeStats } from "./helpers";
|
||||||
import { LatestResultSection } from "./latest-result";
|
import { LatestResultSection } from "./latest-result";
|
||||||
@@ -19,7 +20,10 @@ export default function SpeedtestTrackerWidget({
|
|||||||
isEditMode,
|
isEditMode,
|
||||||
}: WidgetComponentProps<"speedtestTracker">) {
|
}: WidgetComponentProps<"speedtestTracker">) {
|
||||||
const t = useScopedI18n("widget.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();
|
const utils = clientApi.useUtils();
|
||||||
clientApi.widget.speedtestTracker.subscribeToDashboard.useSubscription(
|
clientApi.widget.speedtestTracker.subscribeToDashboard.useSubscription(
|
||||||
@@ -86,6 +90,7 @@ export default function SpeedtestTrackerWidget({
|
|||||||
{t("noSectionsEnabled")}
|
{t("noSectionsEnabled")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
function round(value: number) {
|
function round(value: number) {
|
||||||
@@ -24,7 +25,12 @@ function calculateChangePercentage(currentPrice: number, previousClose: number)
|
|||||||
export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
|
export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
|
||||||
const t = useScopedI18n("widget.stockPrice");
|
const t = useScopedI18n("widget.stockPrice");
|
||||||
const theme = useMantineTheme();
|
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 stockValuesChange = round(calculateChange(data.priceHistory.at(-1) ?? 0, data.previousClose));
|
||||||
const stockValuesChangePercentage = round(
|
const stockValuesChangePercentage = round(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
|||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
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">) {
|
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
|
||||||
const queryInput = { integrationIds };
|
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 utils = clientApi.useUtils();
|
||||||
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const scheme = useMantineColorScheme();
|
const scheme = useMantineColorScheme();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const lastItem = data.at(-1);
|
|
||||||
|
|
||||||
if (!lastItem) return null;
|
|
||||||
const { fileSystem, smart } = lastItem.healthInfo;
|
|
||||||
|
|
||||||
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, {
|
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, {
|
||||||
onData(data) {
|
onData(data) {
|
||||||
utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) =>
|
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) {
|
if (fileSystem.length === 0) {
|
||||||
throw new NoIntegrationDataError();
|
throw new NoIntegrationDataError();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { AreaChartSeries } from "@mantine/charts";
|
import type { AreaChartSeries } from "@mantine/charts";
|
||||||
import { AreaChart, LineChart } 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 { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||||
import type { TooltipProps, YAxisProps } from "recharts";
|
import type { TooltipProps, YAxisProps } from "recharts";
|
||||||
|
|
||||||
@@ -85,8 +85,13 @@ export const CommonChart = ({
|
|||||||
)}
|
)}
|
||||||
{data.length <= 1 ? (
|
{data.length <= 1 ? (
|
||||||
<Center pos="absolute" w="100%" h="100%">
|
<Center pos="absolute" w="100%" h="100%">
|
||||||
<Stack px={"xs"} align={"center"}>
|
<Stack px={"xs"} align={"center"} gap={4}>
|
||||||
<Loader type="bars" size={height > 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} />
|
{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>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ const MAX_QUEUE_SIZE = 15;
|
|||||||
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) {
|
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) {
|
||||||
const { ref, width } = useElementSize();
|
const { ref, width } = useElementSize();
|
||||||
|
|
||||||
const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery({
|
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(
|
||||||
integrationIds,
|
{
|
||||||
});
|
integrationIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 5 * 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
const memoryCapacityInBytes =
|
const memoryCapacityInBytes =
|
||||||
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
|
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
|
||||||
|
|
||||||
|
|||||||
@@ -26,17 +26,22 @@ interface TimetableWidgetInnerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TimetableWidgetInner = ({ station, integrationId }: TimetableWidgetInnerProps) => {
|
const TimetableWidgetInner = ({ station, integrationId }: TimetableWidgetInnerProps) => {
|
||||||
const [timetable] = clientApi.widget.timetable.getTimetable.useSuspenseQuery({
|
const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery(
|
||||||
integrationId,
|
{
|
||||||
stationId: station.value,
|
integrationId,
|
||||||
limit: 10,
|
stationId: station.value,
|
||||||
});
|
limit: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
const t = useScopedI18n("widget.timetable");
|
const t = useScopedI18n("widget.timetable");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack w="100%" gap="xs" p="sm">
|
<Stack w="100%" gap="xs" p="sm">
|
||||||
<Text fw="bold">{t("title", { station: station.label })}</Text>
|
<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 key={`${entry.timestamp.toISOString()}-${entry.location}`} justify="space-between" w="100%">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{entry.line && (
|
{entry.line && (
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ interface TracearrContentProps {
|
|||||||
|
|
||||||
function TracearrContent({ integrationIds, options, width, isEditMode }: TracearrContentProps) {
|
function TracearrContent({ integrationIds, options, width, isEditMode }: TracearrContentProps) {
|
||||||
const t = useScopedI18n("widget.tracearr");
|
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();
|
const utils = clientApi.useUtils();
|
||||||
clientApi.widget.tracearr.subscribeToDashboard.useSubscription(
|
clientApi.widget.tracearr.subscribeToDashboard.useSubscription(
|
||||||
|
|||||||
@@ -40,15 +40,20 @@ export function UmamiContent({
|
|||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
||||||
|
|
||||||
const [results] = clientApi.widget.umami.getVisitorStats.useSuspenseQuery({
|
const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery(
|
||||||
integrationIds,
|
{
|
||||||
websiteId,
|
integrationIds,
|
||||||
timeFrame,
|
websiteId,
|
||||||
eventName,
|
timeFrame,
|
||||||
});
|
eventName,
|
||||||
|
},
|
||||||
|
{ staleTime: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
|
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();
|
const utils = clientApi.useUtils();
|
||||||
clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, {
|
clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, {
|
||||||
onData(count) {
|
onData(count) {
|
||||||
@@ -57,8 +62,8 @@ export function UmamiContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].sort() },
|
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
|
||||||
{ enabled: viewMode === "events" && eventNames.length > 0 },
|
{ enabled: viewMode === "events" && eventNames.length > 0, staleTime: 5 * 60 * 1000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const multiEventTotal = multiEventSeries
|
const multiEventTotal = multiEventSeries
|
||||||
|
|||||||
@@ -28,12 +28,15 @@ export function UmamiEventsContent({
|
|||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
||||||
|
|
||||||
const [series] = clientApi.widget.umami.getMultiEventTimeSeries.useSuspenseQuery({
|
const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||||
integrationId: integrationIds[0] ?? "",
|
{
|
||||||
websiteId,
|
integrationId: integrationIds[0] ?? "",
|
||||||
timeFrame,
|
websiteId,
|
||||||
eventNames: [...eventNames].sort(),
|
timeFrame,
|
||||||
});
|
eventNames: [...eventNames].toSorted(),
|
||||||
|
},
|
||||||
|
{ staleTime: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
if (eventNames.length === 0) {
|
if (eventNames.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +51,7 @@ export function UmamiEventsContent({
|
|||||||
// Collect all unique timestamps across all series
|
// Collect all unique timestamps across all series
|
||||||
const allTimestamps = Array.from(
|
const allTimestamps = Array.from(
|
||||||
new Set(series.flatMap(({ dataPoints }) => dataPoints.map(({ x: xPoint }) => xPoint))),
|
new Set(series.flatMap(({ dataPoints }) => dataPoints.map(({ x: xPoint }) => xPoint))),
|
||||||
).sort();
|
).toSorted();
|
||||||
|
|
||||||
// Build per-event lookup by timestamp string
|
// Build per-event lookup by timestamp string
|
||||||
const byEvent = new Map(
|
const byEvent = new Map(
|
||||||
|
|||||||
@@ -15,23 +15,29 @@ interface UmamiTopListProps {
|
|||||||
|
|
||||||
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
||||||
const t = useScopedI18n("widget.umami");
|
const t = useScopedI18n("widget.umami");
|
||||||
const [data] = clientApi.widget.umami.getTopPages.useSuspenseQuery({
|
const { data = [] } = clientApi.widget.umami.getTopPages.useQuery(
|
||||||
integrationId: integrationIds[0] ?? "",
|
{
|
||||||
websiteId,
|
integrationId: integrationIds[0] ?? "",
|
||||||
timeFrame,
|
websiteId,
|
||||||
limit,
|
timeFrame,
|
||||||
});
|
limit,
|
||||||
|
},
|
||||||
|
{ staleTime: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
|
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
||||||
const t = useScopedI18n("widget.umami");
|
const t = useScopedI18n("widget.umami");
|
||||||
const [data] = clientApi.widget.umami.getTopReferrers.useSuspenseQuery({
|
const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery(
|
||||||
integrationId: integrationIds[0] ?? "",
|
{
|
||||||
websiteId,
|
integrationId: integrationIds[0] ?? "",
|
||||||
timeFrame,
|
websiteId,
|
||||||
limit,
|
timeFrame,
|
||||||
});
|
limit,
|
||||||
|
},
|
||||||
|
{ staleTime: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<UmamiTopList
|
<UmamiTopList
|
||||||
items={data}
|
items={data}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { UpsStatus, UpsSummary } from "@homarr/integrations/types";
|
|||||||
import type { ScopedTranslationFunction } from "@homarr/translation";
|
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
||||||
@@ -39,7 +40,7 @@ interface UpsContentProps {
|
|||||||
|
|
||||||
function UpsContent({ integrationIds, options, isEditMode, width }: UpsContentProps) {
|
function UpsContent({ integrationIds, options, isEditMode, width }: UpsContentProps) {
|
||||||
const t = useScopedI18n("widget.ups");
|
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();
|
const utils = clientApi.useUtils();
|
||||||
clientApi.widget.ups.subscribeSummaries.useSubscription(
|
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) =>
|
const devices = data.flatMap((instance) =>
|
||||||
instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })),
|
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 type { UptimeKumaDashboardData } from "@homarr/integrations/types";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
@@ -106,7 +107,10 @@ interface UptimeKumaContentProps {
|
|||||||
|
|
||||||
function UptimeKumaContent({ integrationIds, isEditMode, options, width }: UptimeKumaContentProps) {
|
function UptimeKumaContent({ integrationIds, isEditMode, options, width }: UptimeKumaContentProps) {
|
||||||
const t = useScopedI18n("widget.uptimeKuma");
|
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();
|
const utils = clientApi.useUtils();
|
||||||
clientApi.widget.uptimeKuma.subscribeToDashboard.useSubscription(
|
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>(
|
const combined = dashboardData.reduce<UptimeKumaDashboardData>(
|
||||||
(acc, item) => ({
|
(acc, item) => ({
|
||||||
totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors,
|
totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { metricToImperial } from "@homarr/common";
|
import { metricToImperial } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { WeatherDescription, WeatherIcon } from "./icon";
|
import { WeatherDescription, WeatherIcon } from "./icon";
|
||||||
|
|
||||||
@@ -18,8 +19,8 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
|
|||||||
latitude: options.location.latitude,
|
latitude: options.location.latitude,
|
||||||
longitude: options.location.longitude,
|
longitude: options.location.longitude,
|
||||||
};
|
};
|
||||||
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(input, {
|
const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input, {
|
||||||
refetchOnMount: false,
|
staleTime: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
@@ -29,6 +30,8 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
|
|||||||
onData: (data) => utils.widget.weather.atLocation.setData(input, data),
|
onData: (data) => utils.widget.weather.atLocation.setData(input, data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!weather) return <WidgetEmptyState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
Generated
+14
-11
@@ -132,6 +132,9 @@ catalogs:
|
|||||||
'@tabler/icons-react':
|
'@tabler/icons-react':
|
||||||
specifier: ^3.44.0
|
specifier: ^3.44.0
|
||||||
version: 3.44.0
|
version: 3.44.0
|
||||||
|
'@tanstack/query-persist-client-core':
|
||||||
|
specifier: ^5.101.0
|
||||||
|
version: 5.101.0
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.101.0
|
specifier: ^5.101.0
|
||||||
version: 5.101.0
|
version: 5.101.0
|
||||||
@@ -790,6 +793,9 @@ importers:
|
|||||||
'@tabler/icons-react':
|
'@tabler/icons-react':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.44.0(react@19.2.7)
|
version: 3.44.0(react@19.2.7)
|
||||||
|
'@tanstack/query-persist-client-core':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 5.101.0
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.101.0(react@19.2.7)
|
version: 5.101.0(react@19.2.7)
|
||||||
@@ -5208,6 +5214,9 @@ packages:
|
|||||||
'@tanstack/query-devtools@5.101.0':
|
'@tanstack/query-devtools@5.101.0':
|
||||||
resolution: {integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==}
|
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':
|
'@tanstack/react-query-devtools@5.101.0':
|
||||||
resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==}
|
resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9008,10 +9017,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
postgres-array@2.0.0:
|
||||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -13188,6 +13193,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/query-devtools@5.101.0': {}
|
'@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)':
|
'@tanstack/react-query-devtools@5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-devtools': 5.101.0
|
'@tanstack/query-devtools': 5.101.0
|
||||||
@@ -17218,12 +17227,6 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.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-array@2.0.0: {}
|
||||||
|
|
||||||
postgres-bytea@1.0.0: {}
|
postgres-bytea@1.0.0: {}
|
||||||
@@ -18772,7 +18775,7 @@ snapshots:
|
|||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
postcss: 8.5.8
|
postcss: 8.5.15
|
||||||
rollup: 4.60.0
|
rollup: 4.60.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ catalog:
|
|||||||
"@semantic-release/release-notes-generator": ^14.1.1
|
"@semantic-release/release-notes-generator": ^14.1.1
|
||||||
"@t3-oss/env-nextjs": ^0.13.11
|
"@t3-oss/env-nextjs": ^0.13.11
|
||||||
"@tabler/icons-react": ^3.44.0
|
"@tabler/icons-react": ^3.44.0
|
||||||
|
"@tanstack/query-persist-client-core": ^5.101.0
|
||||||
"@tanstack/react-query": ^5.101.0
|
"@tanstack/react-query": ^5.101.0
|
||||||
"@tanstack/react-query-devtools": ^5.101.0
|
"@tanstack/react-query-devtools": ^5.101.0
|
||||||
"@tanstack/react-query-next-experimental": ^5.101.0
|
"@tanstack/react-query-next-experimental": ^5.101.0
|
||||||
|
|||||||
Reference in new issue
Block a user