perf: cut cold route compilation and unblock preview images (#6441)
This commit is contained in:
1 parent
622e1dcfa5
commit
b32ad82850
182 files changed
+11421
-2339
No files matched your search
@@ -9,7 +9,10 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./board-server": "./src/board-server.ts",
|
||||
"./query-cache": "./src/query-cache.ts",
|
||||
"./server-settings-server": "./src/server-settings-server.ts",
|
||||
"./user-server": "./src/user-server.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { createRscTrpcContext } from "./rsc-context";
|
||||
import { boardRouter } from "./router/board";
|
||||
|
||||
const createBoardCallerAsync = async () => boardRouter.createCaller(await createRscTrpcContext());
|
||||
|
||||
export const getBoardByNameAsync = cache(async (name: string) => {
|
||||
const caller = await createBoardCallerAsync();
|
||||
return await caller.getBoardByName({ name });
|
||||
});
|
||||
|
||||
export const getHomeBoardAsync = cache(async () => {
|
||||
const caller = await createBoardCallerAsync();
|
||||
return await caller.getHomeBoard();
|
||||
});
|
||||
@@ -1,22 +1,54 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const queryCacheDefaultStaleTimeMs = 0;
|
||||
// RSC-prefetched data must survive the hydration pass without an immediate
|
||||
// duplicate fetch. Widget-specific intervals still control live refreshes.
|
||||
export const queryCacheDefaultStaleTimeMs = 30_000;
|
||||
export const queryCacheDefaultRefetchIntervalMs = 30_000;
|
||||
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
|
||||
export const queryCacheMaxValueBytes = 1024 * 1024;
|
||||
export const queryCacheStoragePrefix = "homarr-widget-query";
|
||||
export const queryCacheBuster = "v2-superjson";
|
||||
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||
|
||||
let activeQueryCacheBoardId: string | null = null;
|
||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
|
||||
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
|
||||
activeQueryCacheBoardId = boardId;
|
||||
export const isWidgetDataTrpcPath = (path: string) => {
|
||||
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
|
||||
return widgetDataQueryPaths.has(path);
|
||||
};
|
||||
|
||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
||||
|
||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
// These supporting queries can be numerous on a dashboard. Give them a
|
||||
// predictable refresh policy instead of refetching all of them after every tab
|
||||
// focus/reconnect event. Metadata can refresh after a bounded stale window;
|
||||
// Docker state remains live on a single shared timer.
|
||||
export const dashboardSupportingQueryPolicies = [
|
||||
{
|
||||
queryKey: [["app", "byId"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["app", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["integration", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
const first = queryKey[0];
|
||||
@@ -26,11 +58,9 @@ const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
return parts.length > 0 ? parts : null;
|
||||
};
|
||||
|
||||
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
|
||||
export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
|
||||
const path = getTrpcPathFromQueryKey(queryKey);
|
||||
if (!path) return false;
|
||||
|
||||
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
|
||||
|
||||
return persistableQueryPaths.has(path.join("."));
|
||||
return isWidgetDataTrpcPath(path.join("."));
|
||||
};
|
||||
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
|
||||
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
||||
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
||||
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
|
||||
@@ -1757,9 +1757,12 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
|
||||
};
|
||||
|
||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
});
|
||||
const groupPermissionWhere = userId
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, userId)),
|
||||
)
|
||||
: eq(boardGroupPermissions.groupId, "");
|
||||
const board = await db.query.boards.findFirst({
|
||||
where,
|
||||
with: {
|
||||
@@ -1782,8 +1785,8 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
items: {
|
||||
with: {
|
||||
integrations: {
|
||||
with: {
|
||||
integration: true,
|
||||
columns: {
|
||||
integrationId: true,
|
||||
},
|
||||
},
|
||||
layouts: true,
|
||||
@@ -1797,7 +1800,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
},
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1845,7 +1848,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
layoutId: layout.layoutId,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||
integrationIds: itemIntegrations.map((item) => item.integrationId),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
}),
|
||||
|
||||
@@ -19,9 +19,12 @@ export const throwIfActionForbiddenAsync = async (
|
||||
permission: BoardPermission,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const groupPermissionWhere = session
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
|
||||
)
|
||||
: eq(boardGroupPermissions.groupId, "");
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: boardWhere,
|
||||
columns: {
|
||||
@@ -34,7 +37,7 @@ export const throwIfActionForbiddenAsync = async (
|
||||
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
|
||||
|
||||
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
|
||||
import { createTRPCRouter, internalProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
const queryCacheBoardInput = z.object({
|
||||
boardId: z.string().min(1),
|
||||
});
|
||||
|
||||
const queryCacheSetInput = queryCacheBoardInput.extend({
|
||||
value: z.string().max(queryCacheMaxValueBytes * 2),
|
||||
});
|
||||
|
||||
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
|
||||
};
|
||||
|
||||
export const queryCacheRouter = createTRPCRouter({
|
||||
setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
|
||||
await validateBoardAccessAsync(ctx, input.boardId);
|
||||
const userId = ctx.session?.user.id ?? "anonymous";
|
||||
return {
|
||||
stored: await setQueryCacheAsync(
|
||||
userId,
|
||||
input.boardId,
|
||||
input.value,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheMaxValueBytes,
|
||||
),
|
||||
};
|
||||
}),
|
||||
removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
|
||||
await validateBoardAccessAsync(ctx, input.boardId);
|
||||
const userId = ctx.session?.user.id ?? "anonymous";
|
||||
await removeQueryCacheAsync(userId, input.boardId);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import { updateCheckerRouter } from "../update-checker";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDataAsync: vi.fn(async () => ({
|
||||
data: { availableUpdates: [{ tagName: "v99.0.0" }] },
|
||||
timestamp: new Date(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/update-checker", () => ({
|
||||
updateCheckerRequestHandler: {
|
||||
handler: () => ({ getDataAsync: mocks.getDataAsync }),
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (permissions: GroupPermissionKey[]): Session => ({
|
||||
user: {
|
||||
id: "user",
|
||||
permissions,
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCaller = (session: Session | null) =>
|
||||
updateCheckerRouter.createCaller({
|
||||
db: null as unknown as Database,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
|
||||
describe("update checker authorization", () => {
|
||||
test("allows administrators to read the cached result", async () => {
|
||||
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([
|
||||
{ tagName: "v99.0.0" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects authenticated users without admin permission", async () => {
|
||||
await expect(createCaller(createSession([])).getAvailableUpdates()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("rejects anonymous users", async () => {
|
||||
await expect(createCaller(null).getAvailableUpdates()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("returns an empty result when the cache reader fails", async () => {
|
||||
mocks.getDataAsync.mockRejectedValueOnce(new Error("Redis unavailable"));
|
||||
|
||||
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ export const updateCheckerRouter = createTRPCRouter({
|
||||
const data = await handler.getDataAsync();
|
||||
return data.data.availableUpdates;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to get available updates", { cause: error }));
|
||||
return undefined; // We return undefined to not show the indicator in the UI
|
||||
logger.error(new Error("Failed to read the cached update check", { cause: error }));
|
||||
return []; // An empty list hides the indicator without violating TanStack Query's data contract.
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { env } from "@homarr/common/env";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { items, users } from "@homarr/db/schema";
|
||||
@@ -20,6 +21,8 @@ const feedsInput = z.object({
|
||||
|
||||
export const rssFeedRouter = createTRPCRouter({
|
||||
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return [];
|
||||
|
||||
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
|
||||
? input.urls
|
||||
: await restrictUrlsAsync(ctx.db, input.urls);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -11,6 +12,8 @@ const atLocationInput = z.object({
|
||||
|
||||
export const weatherRouter = createTRPCRouter({
|
||||
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
|
||||
const handler = weatherRequestHandler.handler(input);
|
||||
return await handler.getDataAsync().then((result) => result.data);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { createTRPCContext } from "./trpc";
|
||||
|
||||
export const createRscTrpcContext = cache(async () => {
|
||||
const requestHeaders = new Headers(await headers());
|
||||
requestHeaders.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
session: await auth(),
|
||||
headers: requestHeaders,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
|
||||
// One settings snapshot per RSC request, shared by the root layout, theme, and
|
||||
// widget prefetching. React invalidates this cache between requests.
|
||||
export const getRscServerSettingsAsync = cache(async () => await getServerSettingsAsync(db));
|
||||
@@ -1,25 +1,16 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
|
||||
import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { appRouter, createCaller } from "@homarr/api";
|
||||
|
||||
import { createRscTrpcContext } from "./rsc-context";
|
||||
import { makeQueryClient } from "./shared";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a tRPC call from a React Server Component.
|
||||
*/
|
||||
const createContext = cache(async () => {
|
||||
const heads = new Headers(await headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
session: await auth(),
|
||||
headers: heads,
|
||||
});
|
||||
});
|
||||
const createContext = () => createRscTrpcContext();
|
||||
|
||||
export const api = createCaller(createContext);
|
||||
|
||||
|
||||
@@ -1,65 +1,20 @@
|
||||
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,
|
||||
dashboardSupportingQueryPolicies,
|
||||
isWidgetDataQueryKey,
|
||||
isWidgetDataTrpcPath,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
queryCacheMaxValueBytes,
|
||||
queryCacheMetadataStaleTimeMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
|
||||
import { queryCacheRouter } from "../router/query-cache";
|
||||
|
||||
const redisMock = vi.hoisted(() => {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
values,
|
||||
setQueryCacheAsync: vi.fn(
|
||||
async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
|
||||
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
|
||||
values.set(`${_userId}:${_boardId}`, value);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
|
||||
values.delete(`${_userId}:${_boardId}`);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
setQueryCacheAsync: redisMock.setQueryCacheAsync,
|
||||
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
redisMock.values.clear();
|
||||
redisMock.setQueryCacheAsync.mockClear();
|
||||
redisMock.removeQueryCacheAsync.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", () => {
|
||||
describe("isWidgetDataQueryKey", () => {
|
||||
test.each([
|
||||
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
|
||||
[[["app", "byId"], { type: "query" }], true],
|
||||
@@ -72,77 +27,22 @@ describe("isPersistableWidgetQueryKey", () => {
|
||||
[[["app", "selectable"], { type: "query" }], false],
|
||||
[[["board", "getBoardByName"], { type: "query" }], false],
|
||||
[["widget"], false],
|
||||
])("matches persist filter for %j", (queryKey, expected) => {
|
||||
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
|
||||
])("matches widget data queries for %j", (queryKey, expected) => {
|
||||
expect(isWidgetDataQueryKey(queryKey)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryCacheRouter", () => {
|
||||
test("stores 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", value: "cached-value" })).resolves.toEqual({
|
||||
stored: true,
|
||||
});
|
||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
||||
session.user.id,
|
||||
"board-1",
|
||||
"cached-value",
|
||||
expect.any(Number),
|
||||
queryCacheMaxValueBytes,
|
||||
);
|
||||
|
||||
await caller.removeItem({ boardId: "board-1" });
|
||||
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
|
||||
});
|
||||
|
||||
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.setQueryCacheAsync.mockClear();
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
|
||||
|
||||
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
|
||||
expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("scopes anonymous public-board cache entries by anonymous user", 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", value: "cached-value" });
|
||||
|
||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
||||
"anonymous",
|
||||
"board-1",
|
||||
"cached-value",
|
||||
expect.any(Number),
|
||||
queryCacheMaxValueBytes,
|
||||
);
|
||||
});
|
||||
|
||||
test("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", value })).resolves.toEqual({
|
||||
stored: false,
|
||||
});
|
||||
describe("isWidgetDataTrpcPath", () => {
|
||||
test.each([
|
||||
["widget.calendar.findAllEvents", true],
|
||||
["app.byIds", true],
|
||||
["integration.byIds", true],
|
||||
["docker.getContainers", true],
|
||||
["widget.app.ping", false],
|
||||
["widget.beszel.getSystemStats", false],
|
||||
["board.getBoardByName", false],
|
||||
])("classifies dashboard data path %s", (path, expected) => {
|
||||
expect(isWidgetDataTrpcPath(path)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,3 +76,38 @@ describe("query cache stale handling", () => {
|
||||
expect(queryFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard supporting query policies", () => {
|
||||
test("bounds metadata staleness without adding per-widget timers", () => {
|
||||
expect(dashboardSupportingQueryPolicies).toEqual([
|
||||
{
|
||||
queryKey: [["app", "byId"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["app", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["integration", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema";
|
||||
|
||||
// Keep the authenticated user lookup shared across nested RSC layouts. React
|
||||
// clears this memo between requests, so authorization changes cannot leak.
|
||||
export const getRscUserSettingsAsync = cache(async (userId: string) =>
|
||||
db.query.users.findFirst({
|
||||
columns: {
|
||||
homeBoardId: true,
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
},
|
||||
where: eq(users.id, userId),
|
||||
}),
|
||||
);
|
||||
+13
-16
@@ -3,24 +3,18 @@ import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, users } from "@homarr/db/schema";
|
||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||
|
||||
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
|
||||
const dbGroupMembers = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
});
|
||||
const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId);
|
||||
|
||||
if (groupIds.length === 0) return [];
|
||||
|
||||
const dbGroupPermissions = await db
|
||||
.selectDistinct({
|
||||
permission: groupPermissions.permission,
|
||||
})
|
||||
.from(groupPermissions)
|
||||
.where(inArray(groupPermissions.groupId, groupIds));
|
||||
.innerJoin(groupMembers, eq(groupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, userId));
|
||||
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
|
||||
|
||||
return getPermissionsWithChildren(permissionKeys);
|
||||
@@ -43,12 +37,15 @@ export const createSessionAsync = async (
|
||||
|
||||
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
|
||||
return async ({ session, user }) => {
|
||||
const additionalProperties = await db.query.users.findFirst({
|
||||
where: eq(users.id, user.id),
|
||||
columns: {
|
||||
colorScheme: true,
|
||||
},
|
||||
});
|
||||
const [additionalProperties, permissions] = await Promise.all([
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, user.id),
|
||||
columns: {
|
||||
colorScheme: true,
|
||||
},
|
||||
}),
|
||||
getCurrentUserPermissionsAsync(db, user.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
...session,
|
||||
@@ -57,7 +54,7 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
|
||||
...additionalProperties,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
permissions: await getCurrentUserPermissionsAsync(db, user.id),
|
||||
permissions,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
interface IntegrationContextProps {
|
||||
integrations: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
permissions: {
|
||||
hasFullAccess: boolean;
|
||||
hasInteractAccess: boolean;
|
||||
|
||||
@@ -6,31 +6,28 @@ import { groupMembers, integrationGroupPermissions, integrationUserPermissions }
|
||||
import { constructIntegrationPermissions } from "./integration-permissions";
|
||||
|
||||
export const getIntegrationsWithPermissionsAsync = async (session: Session | null) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const integrations = await db.query.integrations.findMany({
|
||||
const groupPermissionWhere = session
|
||||
? inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
|
||||
)
|
||||
: eq(integrationGroupPermissions.groupId, "");
|
||||
const integrationRows = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
kind: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return integrations.map(({ userPermissions, groupPermissions, ...integration }) => ({
|
||||
...integration,
|
||||
return integrationRows.map(({ id, userPermissions, groupPermissions }) => ({
|
||||
id,
|
||||
permissions: constructIntegrationPermissions({ userPermissions, groupPermissions }, session),
|
||||
}));
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
|
||||
|
||||
import { updateBoardName } from "./updater";
|
||||
|
||||
@@ -36,16 +35,6 @@ export const BoardProvider = ({
|
||||
};
|
||||
}, [pathname, utils, initialBoard.name]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveQueryCacheBoardId(data.id);
|
||||
|
||||
return () => {
|
||||
if (getActiveQueryCacheBoardId() === data.id) {
|
||||
setActiveQueryCacheBoardId(null);
|
||||
}
|
||||
};
|
||||
}, [data.id]);
|
||||
|
||||
return (
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
|
||||
@@ -35,8 +35,10 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
|
||||
db.transaction((transaction) => {
|
||||
for (const [key, values] of objectEntries(context)) {
|
||||
if (values.length >= 1) {
|
||||
// oxlint-disable-next-line import/namespace -- TableKey limits key to exported schema tables.
|
||||
const table = schema[key];
|
||||
transaction
|
||||
.insert(schema[key])
|
||||
.insert(table)
|
||||
.values(values as never)
|
||||
.run();
|
||||
}
|
||||
@@ -50,7 +52,9 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
|
||||
for (const [key, values] of objectEntries(context)) {
|
||||
if (values.length >= 1) {
|
||||
// Below is actually the mysqlSchema when the driver is mysql
|
||||
await transaction.insert(schema[key] as never).values(values as never);
|
||||
// oxlint-disable-next-line import/namespace -- TableKey limits key to exported schema tables.
|
||||
const table = schema[key] as never;
|
||||
await transaction.insert(table).values(values as never);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./collection": "./collection.ts",
|
||||
"./proxy-reader": "./proxy-reader.ts",
|
||||
"./schema": "./schema/index.ts",
|
||||
"./test": "./test/index.ts",
|
||||
"./queries": "./queries/index.ts",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { MySqlContainer } from "@testcontainers/mysql";
|
||||
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
import mysql from "mysql2/promise";
|
||||
import { Client } from "pg";
|
||||
import { stringify } from "superjson";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dbEnv: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: mocks.dbEnv }));
|
||||
|
||||
const configureDriver = (values: Record<string, unknown>) => {
|
||||
vi.resetModules();
|
||||
for (const key of Object.keys(mocks.dbEnv)) delete mocks.dbEnv[key];
|
||||
Object.assign(mocks.dbEnv, values);
|
||||
};
|
||||
|
||||
const readSettingsAsync = async () => {
|
||||
const reader = await import("./proxy-reader");
|
||||
const result = {
|
||||
locale: await reader.getDefaultLocaleForProxyAsync(),
|
||||
onboardingStep: await reader.getOnboardingStepForProxyAsync(),
|
||||
};
|
||||
await reader.closeProxySettingsReaderAsync();
|
||||
return result;
|
||||
};
|
||||
|
||||
describe("proxy settings reader database compatibility", () => {
|
||||
test("reads SQLite settings and falls back for malformed culture data", async () => {
|
||||
const databasePath = path.join(os.tmpdir(), `homarr-proxy-reader-${process.pid}-${Date.now()}.sqlite`);
|
||||
const database = new Database(databasePath);
|
||||
database.exec("CREATE TABLE onboarding (step TEXT NOT NULL)");
|
||||
database.exec('CREATE TABLE "serverSetting" (setting_key TEXT NOT NULL, value TEXT NOT NULL)');
|
||||
database.prepare("INSERT INTO onboarding (step) VALUES (?)").run("finish");
|
||||
database
|
||||
.prepare('INSERT INTO "serverSetting" (setting_key, value) VALUES (?, ?)')
|
||||
.run("culture", stringify({ defaultLocale: "de" }));
|
||||
database.close();
|
||||
|
||||
try {
|
||||
configureDriver({ DRIVER: "better-sqlite3", URL: databasePath });
|
||||
await expect(readSettingsAsync()).resolves.toEqual({ locale: "de", onboardingStep: "finish" });
|
||||
|
||||
const malformedDatabase = new Database(databasePath);
|
||||
malformedDatabase.prepare('UPDATE "serverSetting" SET value = ? WHERE setting_key = ?').run("{", "culture");
|
||||
malformedDatabase.close();
|
||||
vi.resetModules();
|
||||
await expect(readSettingsAsync()).resolves.toEqual({ locale: "en", onboardingStep: "finish" });
|
||||
} finally {
|
||||
await fs.rm(databasePath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reads MySQL settings through the bounded host configuration", async () => {
|
||||
const container = await new MySqlContainer("mysql:latest").start();
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: container.getHost(),
|
||||
port: container.getPort(),
|
||||
database: container.getDatabase(),
|
||||
user: container.getUsername(),
|
||||
password: container.getUserPassword(),
|
||||
});
|
||||
await connection.execute("CREATE TABLE onboarding (step VARCHAR(32) NOT NULL)");
|
||||
await connection.execute("CREATE TABLE serverSetting (setting_key VARCHAR(64) NOT NULL, value TEXT NOT NULL)");
|
||||
await connection.execute("INSERT INTO onboarding (step) VALUES (?)", ["finish"]);
|
||||
await connection.execute("INSERT INTO serverSetting (setting_key, value) VALUES (?, ?)", [
|
||||
"culture",
|
||||
stringify({ defaultLocale: "fr" }),
|
||||
]);
|
||||
await connection.end();
|
||||
|
||||
configureDriver({
|
||||
DRIVER: "mysql2",
|
||||
HOST: container.getHost(),
|
||||
PORT: container.getPort(),
|
||||
NAME: container.getDatabase(),
|
||||
USER: container.getUsername(),
|
||||
PASSWORD: container.getUserPassword(),
|
||||
});
|
||||
await expect(readSettingsAsync()).resolves.toEqual({ locale: "fr", onboardingStep: "finish" });
|
||||
} finally {
|
||||
await container.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("reads PostgreSQL settings through the bounded host configuration", async () => {
|
||||
const container = await new PostgreSqlContainer("postgres:latest").start();
|
||||
try {
|
||||
const connection = new Client({
|
||||
host: container.getHost(),
|
||||
port: container.getPort(),
|
||||
database: container.getDatabase(),
|
||||
user: container.getUsername(),
|
||||
password: container.getPassword(),
|
||||
});
|
||||
await connection.connect();
|
||||
await connection.query("CREATE TABLE onboarding (step TEXT NOT NULL)");
|
||||
await connection.query('CREATE TABLE "serverSetting" (setting_key TEXT NOT NULL, value TEXT NOT NULL)');
|
||||
await connection.query("INSERT INTO onboarding (step) VALUES ($1)", ["finish"]);
|
||||
await connection.query('INSERT INTO "serverSetting" (setting_key, value) VALUES ($1, $2)', [
|
||||
"culture",
|
||||
stringify({ defaultLocale: "es" }),
|
||||
]);
|
||||
await connection.end();
|
||||
|
||||
configureDriver({
|
||||
DRIVER: "node-postgres",
|
||||
HOST: container.getHost(),
|
||||
PORT: container.getPort(),
|
||||
NAME: container.getDatabase(),
|
||||
USER: container.getUsername(),
|
||||
PASSWORD: container.getPassword(),
|
||||
});
|
||||
await expect(readSettingsAsync()).resolves.toEqual({ locale: "es", onboardingStep: "finish" });
|
||||
} finally {
|
||||
await container.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const query = {
|
||||
where: vi.fn(),
|
||||
limit: vi.fn().mockResolvedValue([{ step: "finish" }]),
|
||||
};
|
||||
query.where.mockReturnValue(query);
|
||||
|
||||
return {
|
||||
dbEnv: {
|
||||
DRIVER: "mysql2",
|
||||
URL: "mysql://homarr:test@localhost:3306/homarr",
|
||||
} as Record<string, unknown>,
|
||||
mysqlCreatePool: vi.fn(() => ({})),
|
||||
postgresPool: vi.fn(function PostgresPool() {
|
||||
return {};
|
||||
}),
|
||||
database: {
|
||||
select: vi.fn(() => ({ from: vi.fn(() => query) })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: mocks.dbEnv }));
|
||||
vi.mock("mysql2", () => ({ default: { createPool: mocks.mysqlCreatePool } }));
|
||||
vi.mock("pg", () => ({ Pool: mocks.postgresPool }));
|
||||
vi.mock("drizzle-orm/mysql2", () => ({ drizzle: vi.fn(() => mocks.database) }));
|
||||
vi.mock("drizzle-orm/node-postgres", () => ({ drizzle: vi.fn(() => mocks.database) }));
|
||||
vi.mock("./proxy/mysql", () => ({
|
||||
proxySchema: { onboarding: { step: {} }, serverSettings: { settingKey: {}, value: {} } },
|
||||
}));
|
||||
vi.mock("./proxy/postgresql", () => ({
|
||||
proxySchema: { onboarding: { step: {} }, serverSettings: { settingKey: {}, value: {} } },
|
||||
}));
|
||||
|
||||
const initializeProxyReader = async () => {
|
||||
const { getOnboardingStepForProxyAsync } = await import("./proxy-reader");
|
||||
await getOnboardingStepForProxyAsync();
|
||||
};
|
||||
|
||||
describe("proxy database pools", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(mocks.dbEnv)) delete mocks.dbEnv[key];
|
||||
mocks.mysqlCreatePool.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("bounds MySQL URL pools instead of falling back to the driver defaults", async () => {
|
||||
Object.assign(mocks.dbEnv, {
|
||||
DRIVER: "mysql2",
|
||||
URL: "mysql://homarr:test@localhost:3306/homarr",
|
||||
});
|
||||
|
||||
await initializeProxyReader();
|
||||
|
||||
expect(mocks.mysqlCreatePool).toHaveBeenCalledWith({
|
||||
uri: "mysql://homarr:test@localhost:3306/homarr",
|
||||
connectionLimit: 1,
|
||||
maxIdle: 1,
|
||||
idleTimeout: 60_000,
|
||||
enableKeepAlive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds MySQL host pools instead of falling back to the driver defaults", async () => {
|
||||
Object.assign(mocks.dbEnv, {
|
||||
DRIVER: "mysql2",
|
||||
HOST: "localhost",
|
||||
PORT: 3306,
|
||||
NAME: "homarr",
|
||||
USER: "homarr",
|
||||
PASSWORD: "test",
|
||||
});
|
||||
|
||||
await initializeProxyReader();
|
||||
|
||||
expect(mocks.mysqlCreatePool).toHaveBeenCalledWith(expect.objectContaining({ connectionLimit: 1, maxIdle: 1 }));
|
||||
});
|
||||
|
||||
it("bounds PostgreSQL URL pools instead of falling back to the driver defaults", async () => {
|
||||
Object.assign(mocks.dbEnv, {
|
||||
DRIVER: "node-postgres",
|
||||
URL: "postgresql://homarr:test@localhost:5432/homarr",
|
||||
});
|
||||
|
||||
await initializeProxyReader();
|
||||
|
||||
expect(mocks.postgresPool).toHaveBeenCalledWith({
|
||||
connectionString: "postgresql://homarr:test@localhost:5432/homarr",
|
||||
max: 1,
|
||||
idleTimeoutMillis: 60_000,
|
||||
allowExitOnIdle: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds PostgreSQL host pools instead of falling back to the driver defaults", async () => {
|
||||
Object.assign(mocks.dbEnv, {
|
||||
DRIVER: "node-postgres",
|
||||
HOST: "localhost",
|
||||
PORT: 5432,
|
||||
NAME: "homarr",
|
||||
USER: "homarr",
|
||||
PASSWORD: "test",
|
||||
});
|
||||
|
||||
await initializeProxyReader();
|
||||
|
||||
expect(mocks.postgresPool).toHaveBeenCalledWith(expect.objectContaining({ max: 1 }));
|
||||
});
|
||||
|
||||
it("retries initialization after a transient driver failure", async () => {
|
||||
Object.assign(mocks.dbEnv, {
|
||||
DRIVER: "mysql2",
|
||||
URL: "mysql://homarr:test@localhost:3306/homarr",
|
||||
});
|
||||
mocks.mysqlCreatePool.mockImplementationOnce(() => {
|
||||
throw new Error("database unavailable");
|
||||
});
|
||||
|
||||
const { getOnboardingStepForProxyAsync } = await import("./proxy-reader");
|
||||
|
||||
await expect(getOnboardingStepForProxyAsync()).rejects.toThrow("database unavailable");
|
||||
await expect(getOnboardingStepForProxyAsync()).resolves.toBe("finish");
|
||||
expect(mocks.mysqlCreatePool).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { SQLWrapper } from "drizzle-orm";
|
||||
import { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
|
||||
type ProxyRow = Record<string, unknown>;
|
||||
type ProxySchema = {
|
||||
onboarding: { step: SQLWrapper };
|
||||
serverSettings: { settingKey: SQLWrapper; value: SQLWrapper };
|
||||
};
|
||||
type ProxyQuery = {
|
||||
where(condition: unknown): ProxyQuery;
|
||||
limit(count: number): Promise<ProxyRow[]>;
|
||||
};
|
||||
type ProxyDatabase = {
|
||||
select(selection: Record<string, unknown>): { from(table: unknown): ProxyQuery };
|
||||
};
|
||||
|
||||
type ProxyReader = {
|
||||
closeAsync: () => Promise<void>;
|
||||
getOnboardingStepAsync: () => Promise<string>;
|
||||
getDefaultLocaleAsync: () => Promise<string>;
|
||||
};
|
||||
|
||||
// The proxy is long-lived and only performs two single-row reads. Keep its
|
||||
// independent pool deliberately smaller than the application pool.
|
||||
const proxyPoolConnectionLimit = 1;
|
||||
|
||||
const createProxyReader = (database: unknown, schema: ProxySchema, closeAsync: () => Promise<void>): ProxyReader => {
|
||||
const queryDatabase = database as ProxyDatabase;
|
||||
|
||||
return {
|
||||
closeAsync,
|
||||
async getOnboardingStepAsync() {
|
||||
const rows = await queryDatabase.select({ step: schema.onboarding.step }).from(schema.onboarding).limit(1);
|
||||
const step = rows[0]?.step;
|
||||
return typeof step === "string" ? step : "start";
|
||||
},
|
||||
async getDefaultLocaleAsync() {
|
||||
const rows = await queryDatabase
|
||||
.select({ value: schema.serverSettings.value })
|
||||
.from(schema.serverSettings)
|
||||
.where(eq(schema.serverSettings.settingKey, "culture"))
|
||||
.limit(1);
|
||||
const value = rows[0]?.value;
|
||||
if (typeof value !== "string") return defaultServerSettings.culture.defaultLocale;
|
||||
|
||||
try {
|
||||
const culture: unknown = parseSuperJson(value);
|
||||
if (
|
||||
typeof culture === "object" &&
|
||||
culture !== null &&
|
||||
"defaultLocale" in culture &&
|
||||
typeof culture.defaultLocale === "string"
|
||||
) {
|
||||
return culture.defaultLocale;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the default when a legacy or malformed value cannot be decoded.
|
||||
}
|
||||
|
||||
return defaultServerSettings.culture.defaultLocale;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createProxyReaderAsync = async (): Promise<ProxyReader> => {
|
||||
switch (dbEnv.DRIVER) {
|
||||
case "better-sqlite3": {
|
||||
const [{ default: Database }, { drizzle }, { proxySchema }] = await Promise.all([
|
||||
import("better-sqlite3"),
|
||||
import("drizzle-orm/better-sqlite3"),
|
||||
import("./proxy/sqlite"),
|
||||
]);
|
||||
const connection = new Database(dbEnv.URL);
|
||||
const database = drizzle(connection, { schema: proxySchema });
|
||||
return createProxyReader(database, proxySchema, async () => {
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
case "mysql2": {
|
||||
const [{ default: mysql }, { drizzle }, { proxySchema }] = await Promise.all([
|
||||
import("mysql2"),
|
||||
import("drizzle-orm/mysql2"),
|
||||
import("./proxy/mysql"),
|
||||
]);
|
||||
const connection = dbEnv.HOST
|
||||
? mysql.createPool({
|
||||
host: dbEnv.HOST,
|
||||
port: dbEnv.PORT,
|
||||
database: dbEnv.NAME,
|
||||
user: dbEnv.USER,
|
||||
password: dbEnv.PASSWORD,
|
||||
connectionLimit: proxyPoolConnectionLimit,
|
||||
maxIdle: 1,
|
||||
idleTimeout: 60_000,
|
||||
enableKeepAlive: true,
|
||||
})
|
||||
: mysql.createPool({
|
||||
uri: dbEnv.URL,
|
||||
connectionLimit: proxyPoolConnectionLimit,
|
||||
maxIdle: 1,
|
||||
idleTimeout: 60_000,
|
||||
enableKeepAlive: true,
|
||||
});
|
||||
const database = drizzle(connection, { schema: proxySchema, mode: "default" });
|
||||
return createProxyReader(
|
||||
database,
|
||||
proxySchema,
|
||||
async () =>
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
connection.end((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "node-postgres": {
|
||||
const [{ Pool }, { drizzle }, { proxySchema }] = await Promise.all([
|
||||
import("pg"),
|
||||
import("drizzle-orm/node-postgres"),
|
||||
import("./proxy/postgresql"),
|
||||
]);
|
||||
const connection = dbEnv.HOST
|
||||
? new Pool({
|
||||
host: dbEnv.HOST,
|
||||
port: dbEnv.PORT,
|
||||
database: dbEnv.NAME,
|
||||
user: dbEnv.USER,
|
||||
password: dbEnv.PASSWORD,
|
||||
max: proxyPoolConnectionLimit,
|
||||
idleTimeoutMillis: 60_000,
|
||||
allowExitOnIdle: false,
|
||||
})
|
||||
: new Pool({
|
||||
connectionString: dbEnv.URL,
|
||||
max: proxyPoolConnectionLimit,
|
||||
idleTimeoutMillis: 60_000,
|
||||
allowExitOnIdle: false,
|
||||
});
|
||||
const database = drizzle({ client: connection, schema: proxySchema });
|
||||
return createProxyReader(database, proxySchema, async () => {
|
||||
await connection.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let proxyReaderPromise: Promise<ProxyReader> | null = null;
|
||||
|
||||
const getProxyReaderAsync = () => {
|
||||
if (!proxyReaderPromise) {
|
||||
proxyReaderPromise = createProxyReaderAsync().catch((error: unknown) => {
|
||||
proxyReaderPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return proxyReaderPromise;
|
||||
};
|
||||
|
||||
export const getOnboardingStepForProxyAsync = async () => (await getProxyReaderAsync()).getOnboardingStepAsync();
|
||||
|
||||
export const getDefaultLocaleForProxyAsync = async () => (await getProxyReaderAsync()).getDefaultLocaleAsync();
|
||||
|
||||
export const closeProxySettingsReaderAsync = async () => {
|
||||
if (!proxyReaderPromise) return;
|
||||
const reader = await proxyReaderPromise;
|
||||
proxyReaderPromise = null;
|
||||
await reader.closeAsync();
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { mysqlTable, text, varchar } from "drizzle-orm/mysql-core";
|
||||
|
||||
export const proxySchema = {
|
||||
onboarding: mysqlTable("onboarding", {
|
||||
step: varchar("step", { length: 32 }).notNull(),
|
||||
}),
|
||||
serverSettings: mysqlTable("serverSetting", {
|
||||
settingKey: varchar("setting_key", { length: 64 }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||
|
||||
export const proxySchema = {
|
||||
onboarding: pgTable("onboarding", {
|
||||
step: text("step").notNull(),
|
||||
}),
|
||||
serverSettings: pgTable("serverSetting", {
|
||||
settingKey: text("setting_key").notNull(),
|
||||
value: text("value").notNull(),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const proxySchema = {
|
||||
onboarding: sqliteTable("onboarding", {
|
||||
step: text("step").notNull(),
|
||||
}),
|
||||
serverSettings: sqliteTable("serverSetting", {
|
||||
settingKey: text("setting_key").notNull(),
|
||||
value: text("value").notNull(),
|
||||
}),
|
||||
};
|
||||
@@ -63,6 +63,7 @@ test("schemas should match", () => {
|
||||
if (!("uniqueName" in sqliteColumn)) return;
|
||||
if (!("primary" in sqliteColumn)) return;
|
||||
|
||||
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
|
||||
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
|
||||
@@ -84,6 +85,7 @@ test("schemas should match", () => {
|
||||
).toEqual(mysqlColumn.primary);
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
|
||||
| SqliteForeignKey[]
|
||||
@@ -144,6 +146,7 @@ test("schemas should match for postgresql", () => {
|
||||
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
||||
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
|
||||
// skipcq: JS-E1007
|
||||
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
|
||||
const postgresqlTable = postgresqlSchema[tableName];
|
||||
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
||||
if (!("isUnique" in sqliteColumn)) return;
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "migrations/*/migrate.cjs"]
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./cookie": "./src/cookie.ts",
|
||||
"./docs/*": "./src/docs/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -27,7 +27,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["emby", "jellyfin", "plex"],
|
||||
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
|
||||
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
||||
coolify: ["coolify"],
|
||||
@@ -40,7 +40,13 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
speedtestTracker: ["speedtestTracker"],
|
||||
uptimeKuma: ["uptimeKuma"],
|
||||
audioStats: ["navidrome", "audiobookshelf"],
|
||||
umami: ["umami"],
|
||||
vpn: getIntegrationKindsByCategory("vpn"),
|
||||
ups: getIntegrationKindsByCategory("ups"),
|
||||
beszelSystemTable: ["beszel", "mock"],
|
||||
beszelSystemGrid: ["beszel", "mock"],
|
||||
beszelAlerts: ["beszel", "mock"],
|
||||
beszelSystemStats: ["beszel", "mock"],
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
|
||||
@@ -6,9 +6,7 @@ export {
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
createGetSetChannel,
|
||||
setQueryCacheAsync,
|
||||
getQueryCacheAsync,
|
||||
removeQueryCacheAsync,
|
||||
createLockChannel,
|
||||
invalidateIntegrationCacheAsync,
|
||||
} from "./lib/channel";
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("./connection", () => ({
|
||||
createRedisConnection: () => null,
|
||||
}));
|
||||
|
||||
import { createLockChannel } from "./channel";
|
||||
|
||||
describe("createLockChannel", () => {
|
||||
test("degrades gracefully when Redis connections are disabled", async () => {
|
||||
const lock = createLockChannel("test-lock");
|
||||
|
||||
const token = await lock.acquireAsync(60);
|
||||
|
||||
expect(token).toEqual(expect.any(String));
|
||||
if (!token) throw new Error("Expected the fallback lock to return a token");
|
||||
await expect(lock.releaseAsync(token)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,10 @@
|
||||
import superjson from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
|
||||
import { createRedisConnection } from "./connection";
|
||||
|
||||
const logger = createLogger({ module: "redisChannel" });
|
||||
|
||||
const publisher = createRedisConnection();
|
||||
const lastDataClient = createRedisConnection();
|
||||
|
||||
@@ -114,10 +111,11 @@ export const createGetSetChannel = <TData>(name: string) => {
|
||||
* @param options optional TTL in seconds
|
||||
*/
|
||||
setAsync: async (data: TData, options?: { ttlSeconds?: number }) => {
|
||||
await getSetClient.set(name, superjson.stringify(data));
|
||||
if (options?.ttlSeconds) {
|
||||
await getSetClient.expire(name, options.ttlSeconds);
|
||||
await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds);
|
||||
return;
|
||||
}
|
||||
await getSetClient.set(name, superjson.stringify(data));
|
||||
},
|
||||
/**
|
||||
* Remove data from the channel
|
||||
@@ -128,32 +126,34 @@ export const createGetSetChannel = <TData>(name: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const queryCacheKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
|
||||
/**
|
||||
* Creates a short-lived distributed lock.
|
||||
*
|
||||
* The token prevents a process from releasing a lock that expired and was
|
||||
* acquired by another process in the meantime.
|
||||
*/
|
||||
export const createLockChannel = (name: string) => {
|
||||
return {
|
||||
acquireAsync: async (ttlSeconds: number) => {
|
||||
const token = createId();
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
if (!client) return token;
|
||||
|
||||
export const setQueryCacheAsync = async (
|
||||
userId: string,
|
||||
boardId: string,
|
||||
value: string,
|
||||
ttlMs: number,
|
||||
maxValueBytes: number,
|
||||
) => {
|
||||
if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
|
||||
logger.warn("Query cache value exceeded maximum size", {
|
||||
key: queryCacheKey(userId, boardId),
|
||||
valueBytes: Buffer.byteLength(value, "utf8"),
|
||||
maxValueBytes,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await getSetClient.set(queryCacheKey(userId, boardId), value, "PX", ttlMs);
|
||||
return true;
|
||||
};
|
||||
const result = await client.set(name, token, "EX", ttlSeconds, "NX");
|
||||
return result === "OK" ? token : null;
|
||||
},
|
||||
releaseAsync: async (token: string) => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
if (!client) return;
|
||||
|
||||
export const getQueryCacheAsync = async (userId: string, boardId: string) =>
|
||||
await getSetClient.get(queryCacheKey(userId, boardId));
|
||||
|
||||
export const removeQueryCacheAsync = async (userId: string, boardId: string) => {
|
||||
await getSetClient.del(queryCacheKey(userId, boardId));
|
||||
await client.eval(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
|
||||
1,
|
||||
name,
|
||||
token,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
@@ -23,24 +24,18 @@ export const createIntegrationRequestHandler = <
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
const integrationMap = new Map<string, IntegrationOfKind<TKind>>();
|
||||
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
|
||||
requestAsync: async (input) => {
|
||||
const integration = integrationMap.get(input.integrationId);
|
||||
if (!integration) {
|
||||
throw new Error(`Integration ${input.integrationId} not found in cache`);
|
||||
}
|
||||
return options.requestAsync(integration, input.options);
|
||||
},
|
||||
const inner = createRequestHandler<TData, { integration: IntegrationOfKind<TKind>; options: TInput }>({
|
||||
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
|
||||
getCacheKey: ({ integration, options: itemOptions }) =>
|
||||
JSON.stringify({ integrationId: integration.id, itemOptions }),
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
});
|
||||
|
||||
return {
|
||||
invalidateCache: inner.invalidateCache,
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
|
||||
integrationMap.set(integration.id, integration);
|
||||
return inner.handler({ integrationId: integration.id, options: itemOptions });
|
||||
},
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
|
||||
inner.handler({ integration, options: itemOptions }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./integration-request-handler";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import { createWidgetRequestHandler } from "./widget-request-handler";
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const createIntegration = (secret: string) =>
|
||||
({
|
||||
id: "integration-id",
|
||||
kind: "sonarr",
|
||||
decryptedSecrets: [{ kind: "apiKey", value: secret }],
|
||||
}) as Omit<Integration, "kind"> & {
|
||||
kind: "sonarr";
|
||||
decryptedSecrets: Pick<IntegrationSecret, "kind" | "value">[];
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createRequestHandler", () => {
|
||||
it("deduplicates in-flight work and caches completed data", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<string>();
|
||||
const handler = createRequestHandler({
|
||||
requestAsync: async () => {
|
||||
calls += 1;
|
||||
return await request.promise;
|
||||
},
|
||||
});
|
||||
|
||||
const first = handler.handler({ id: "same" }).getDataAsync();
|
||||
const second = handler.handler({ id: "same" }).getDataAsync();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
request.resolve("value");
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "value" });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<{ call: number }>();
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 0,
|
||||
requestAsync: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return await request.promise;
|
||||
return { call: calls };
|
||||
},
|
||||
});
|
||||
|
||||
const first = handler.handler({ id: "same" }).getDataAsync();
|
||||
const second = handler.handler({ id: "same" }).getDataAsync();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
request.resolve({ call: 1 });
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: { call: 1 } }, { data: { call: 1 } }]);
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("releases positive-ttl entries after expiry without another request", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 25,
|
||||
requestAsync: async () => ({ call: ++calls }),
|
||||
});
|
||||
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 1 } });
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
|
||||
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("cancels scheduled expiry work when the cache is invalidated", async () => {
|
||||
vi.useFakeTimers();
|
||||
const handler = createRequestHandler({ cacheTtlMs: 25, requestAsync: async () => "value" });
|
||||
await handler.handler({ id: "same" }).getDataAsync();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
handler.invalidateCache();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds stale-on-error fallback and releases it while idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 25,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 50,
|
||||
requestAsync: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return "last-known-good";
|
||||
throw new Error("upstream unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
|
||||
data: "last-known-good",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
|
||||
data: "last-known-good",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(51);
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).rejects.toThrow("upstream unavailable");
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("reschedules staggered stale entries by their retirement deadline without spinning", async () => {
|
||||
vi.useFakeTimers();
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 10,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 100,
|
||||
requestAsync: async ({ id }: { id: string }) => id,
|
||||
});
|
||||
|
||||
await handler.handler({ id: "first" }).getDataAsync();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await handler.handler({ id: "second" }).getDataAsync();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(90);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("evicts an empty-string cache key at the size limit", async () => {
|
||||
let calls = 0;
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 60_000,
|
||||
getCacheKey: ({ id }: { id: string }) => id,
|
||||
requestAsync: async () => ++calls,
|
||||
});
|
||||
|
||||
await handler.handler({ id: "" }).getDataAsync();
|
||||
for (let index = 0; index < 1000; index += 1) {
|
||||
await handler.handler({ id: String(index) }).getDataAsync();
|
||||
}
|
||||
await handler.handler({ id: "" }).getDataAsync();
|
||||
|
||||
expect(calls).toBe(1002);
|
||||
});
|
||||
|
||||
it("does not let a pre-invalidation request repopulate or clear newer work", async () => {
|
||||
const staleRequest = deferred<string>();
|
||||
const freshRequest = deferred<string>();
|
||||
const requests = [staleRequest, freshRequest];
|
||||
let calls = 0;
|
||||
const handler = createRequestHandler({
|
||||
requestAsync: async () => {
|
||||
const request = requests[calls++];
|
||||
if (!request) throw new Error("Unexpected request");
|
||||
return await request.promise;
|
||||
},
|
||||
});
|
||||
|
||||
const stale = handler.handler({ id: "same" }).getDataAsync();
|
||||
handler.invalidateCache();
|
||||
const fresh = handler.handler({ id: "same" }).getDataAsync();
|
||||
|
||||
staleRequest.resolve("stale");
|
||||
await expect(stale).resolves.toMatchObject({ data: "stale" });
|
||||
const joinsFresh = handler.handler({ id: "same" }).getDataAsync();
|
||||
expect(calls).toBe(2);
|
||||
|
||||
freshRequest.resolve("fresh");
|
||||
await expect(Promise.all([fresh, joinsFresh])).resolves.toMatchObject([{ data: "fresh" }, { data: "fresh" }]);
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "fresh" });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIntegrationRequestHandler", () => {
|
||||
it("keys requests by integration id and options without retaining secret values", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<string>();
|
||||
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
|
||||
requestAsync: async () => {
|
||||
calls += 1;
|
||||
return await request.promise;
|
||||
},
|
||||
});
|
||||
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
|
||||
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
request.resolve("value");
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWidgetRequestHandler", () => {
|
||||
it("shares its cache across calls while keeping distinct inputs separate", async () => {
|
||||
let calls = 0;
|
||||
const handler = createWidgetRequestHandler({ requestAsync: async ({ id }: { id: string }) => `${id}-${++calls}` });
|
||||
|
||||
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
|
||||
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
|
||||
await expect(handler.handler({ id: "two" }).getDataAsync()).resolves.toMatchObject({ data: "two-2" });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,21 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
getCacheKey?: (input: TInput) => string;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const DEFAULT_TTL_MS = 10_000;
|
||||
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
|
||||
|
||||
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of cache) {
|
||||
if (now >= entry.expiresAt) cache.delete(key);
|
||||
if (now >= entry.staleUntil) cache.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,44 +24,98 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
) => {
|
||||
const cache = new Map<string, CacheEntry<TData>>();
|
||||
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let expiryTimerAt: number | undefined;
|
||||
let generation = 0;
|
||||
|
||||
const scheduleExpiry = (candidateExpiryAt?: number) => {
|
||||
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
|
||||
if (expiryTimer) clearTimeout(expiryTimer);
|
||||
expiryTimer = undefined;
|
||||
expiryTimerAt = undefined;
|
||||
if (cache.size === 0) return;
|
||||
|
||||
const nextExpiryAt = candidateExpiryAt ?? Math.min(...[...cache.values()].map(({ staleUntil }) => staleUntil));
|
||||
expiryTimerAt = nextExpiryAt;
|
||||
expiryTimer = setTimeout(
|
||||
() => {
|
||||
expiryTimer = undefined;
|
||||
expiryTimerAt = undefined;
|
||||
evictExpired(cache);
|
||||
scheduleExpiry();
|
||||
},
|
||||
Math.max(0, nextExpiryAt - Date.now()),
|
||||
);
|
||||
expiryTimer.unref?.();
|
||||
};
|
||||
|
||||
return {
|
||||
invalidateCache: () => {
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
if (expiryTimer) clearTimeout(expiryTimer);
|
||||
expiryTimer = undefined;
|
||||
expiryTimerAt = undefined;
|
||||
},
|
||||
handler: (input: TInput) => ({
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const key = JSON.stringify(input);
|
||||
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
let cached = cache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached && now >= cached.staleUntil) {
|
||||
cache.delete(key);
|
||||
scheduleExpiry();
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = options
|
||||
const requestGeneration = generation;
|
||||
let promise: Promise<CacheEntry<TData>>;
|
||||
promise = options
|
||||
.requestAsync(input)
|
||||
.then((data) => {
|
||||
if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
const expiresAt = Date.now() + ttl;
|
||||
const staleIfErrorTtlMs = options.fallbackToStaleOnError
|
||||
? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS)
|
||||
: 0;
|
||||
const entry: CacheEntry<TData> = {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
expiresAt,
|
||||
staleUntil: expiresAt + staleIfErrorTtlMs,
|
||||
};
|
||||
if (ttl > 0 && generation === requestGeneration) {
|
||||
evictExpired(cache);
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(key, entry);
|
||||
scheduleExpiry(entry.staleUntil);
|
||||
}
|
||||
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
|
||||
cache.set(key, entry);
|
||||
inflight.delete(key);
|
||||
return entry;
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
if (options.fallbackToStaleOnError && cached) {
|
||||
if (
|
||||
options.fallbackToStaleOnError &&
|
||||
cached &&
|
||||
Date.now() < cached.staleUntil &&
|
||||
generation === requestGeneration
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(key) === promise) inflight.delete(key);
|
||||
});
|
||||
|
||||
inflight.set(key, promise);
|
||||
|
||||
@@ -6,9 +6,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => ({
|
||||
handler: (widgetOptions: TInput) =>
|
||||
createRequestHandler<TData, TInput>({
|
||||
requestAsync: options.requestAsync,
|
||||
}).handler(widgetOptions),
|
||||
});
|
||||
) => {
|
||||
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
|
||||
return inner;
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type StoredValue = {
|
||||
expiresAt: number | null;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
const values = new Map<string, StoredValue>();
|
||||
const locks = new Set<string>();
|
||||
const listReleases = vi.fn();
|
||||
const redisOperation = vi.fn();
|
||||
|
||||
return {
|
||||
env: { NO_EXTERNAL_CONNECTION: false },
|
||||
listReleases,
|
||||
redisOperation,
|
||||
values,
|
||||
locks,
|
||||
removeFreshResult() {
|
||||
for (const key of values.keys()) {
|
||||
if (key.startsWith("update-checker:fresh:")) values.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/common/env", () => ({ env: mocks.env }));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createGetSetChannel: (name: string) => ({
|
||||
getAsync: async () => {
|
||||
mocks.redisOperation();
|
||||
const stored = mocks.values.get(name);
|
||||
if (!stored) return null;
|
||||
if (stored.expiresAt !== null && Date.now() >= stored.expiresAt) {
|
||||
mocks.values.delete(name);
|
||||
return null;
|
||||
}
|
||||
return stored.value;
|
||||
},
|
||||
setAsync: async (value: unknown, options?: { ttlSeconds?: number }) => {
|
||||
mocks.redisOperation();
|
||||
mocks.values.set(name, {
|
||||
value,
|
||||
expiresAt: options?.ttlSeconds ? Date.now() + options.ttlSeconds * 1_000 : null,
|
||||
});
|
||||
},
|
||||
removeAsync: async () => {
|
||||
mocks.redisOperation();
|
||||
mocks.values.delete(name);
|
||||
},
|
||||
}),
|
||||
createLockChannel: (name: string) => ({
|
||||
acquireAsync: async () => {
|
||||
mocks.redisOperation();
|
||||
if (mocks.locks.has(name)) return null;
|
||||
mocks.locks.add(name);
|
||||
return "test-lock-token";
|
||||
},
|
||||
releaseAsync: async () => {
|
||||
mocks.redisOperation();
|
||||
mocks.locks.delete(name);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("octokit", () => ({
|
||||
Octokit: class {
|
||||
rest = {
|
||||
repos: {
|
||||
listReleases: mocks.listReleases,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const successfulResponse = {
|
||||
data: [
|
||||
{
|
||||
body_html: undefined,
|
||||
html_url: "https://github.com/homarr-labs/homarr/releases/tag/v99.0.0",
|
||||
name: "v99.0.0",
|
||||
prerelease: false,
|
||||
tag_name: "v99.0.0",
|
||||
},
|
||||
],
|
||||
headers: {},
|
||||
status: 200,
|
||||
url: "",
|
||||
};
|
||||
|
||||
const loadHandlerAsync = async () => {
|
||||
const { updateCheckerRequestHandler } = await import("../update-checker");
|
||||
return updateCheckerRequestHandler.handler({});
|
||||
};
|
||||
|
||||
describe("persisted update checker cache", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
mocks.env.NO_EXTERNAL_CONNECTION = false;
|
||||
mocks.listReleases.mockReset();
|
||||
mocks.redisOperation.mockReset();
|
||||
mocks.values.clear();
|
||||
mocks.locks.clear();
|
||||
});
|
||||
|
||||
test("checks GitHub once for repeated page loads inside 24 hours", async () => {
|
||||
mocks.listReleases.mockResolvedValue(successfulResponse);
|
||||
const handler = await loadHandlerAsync();
|
||||
|
||||
const first = await handler.getDataAsync();
|
||||
const second = await handler.getDataAsync();
|
||||
|
||||
expect(first.data).toEqual(second.data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deduplicates concurrent checks", async () => {
|
||||
mocks.listReleases.mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return successfulResponse;
|
||||
});
|
||||
const handler = await loadHandlerAsync();
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 10 }, async () => await handler.getDataAsync()));
|
||||
|
||||
expect(results.every(({ data }) => data.availableUpdates.length === 1)).toBe(true);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deduplicates concurrent checks across simulated processes", async () => {
|
||||
let completeGitHubRequest: (() => void) | undefined;
|
||||
mocks.listReleases.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<typeof successfulResponse>((resolve) => {
|
||||
completeGitHubRequest = () => resolve(successfulResponse);
|
||||
}),
|
||||
);
|
||||
const firstHandler = await loadHandlerAsync();
|
||||
vi.resetModules();
|
||||
const secondHandler = await loadHandlerAsync();
|
||||
|
||||
const firstRequest = firstHandler.getDataAsync();
|
||||
await vi.waitFor(() => expect(mocks.listReleases).toHaveBeenCalledOnce());
|
||||
const secondRequest = secondHandler.getDataAsync();
|
||||
completeGitHubRequest?.();
|
||||
|
||||
const results = await Promise.all([firstRequest, secondRequest]);
|
||||
|
||||
expect(results[0].data).toEqual(results[1].data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("refreshes at the 24-hour boundary, not before it", async () => {
|
||||
let now = 1_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
mocks.listReleases.mockResolvedValue(successfulResponse);
|
||||
const handler = await loadHandlerAsync();
|
||||
await handler.getDataAsync();
|
||||
|
||||
now += 24 * 60 * 60 * 1_000 - 1;
|
||||
await handler.getDataAsync();
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 1;
|
||||
await handler.getDataAsync();
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("reuses the Redis result after a simulated process restart", async () => {
|
||||
mocks.listReleases.mockResolvedValue(successfulResponse);
|
||||
const firstHandler = await loadHandlerAsync();
|
||||
await firstHandler.getDataAsync();
|
||||
|
||||
vi.resetModules();
|
||||
const restartedHandler = await loadHandlerAsync();
|
||||
await restartedHandler.getDataAsync();
|
||||
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("ignores update results created for an older application version", async () => {
|
||||
mocks.values.set("update-checker:fresh:v1", {
|
||||
value: { availableUpdates: [], attemptedAt: 1, checkedAt: 1 },
|
||||
expiresAt: null,
|
||||
});
|
||||
mocks.listReleases.mockResolvedValue(successfulResponse);
|
||||
|
||||
const handler = await loadHandlerAsync();
|
||||
const result = await handler.getDataAsync();
|
||||
|
||||
expect(result.data.availableUpdates).toHaveLength(1);
|
||||
expect(mocks.listReleases).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("serves stale data and retries only after 24 hours following a provider failure", async () => {
|
||||
let now = 1_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
mocks.listReleases.mockResolvedValueOnce(successfulResponse).mockRejectedValue(new Error("GitHub unavailable"));
|
||||
const handler = await loadHandlerAsync();
|
||||
const fresh = await handler.getDataAsync();
|
||||
mocks.removeFreshResult();
|
||||
|
||||
const stale = await handler.getDataAsync();
|
||||
now += 24 * 60 * 60 * 1_000 - 1;
|
||||
const repeated = await handler.getDataAsync();
|
||||
|
||||
expect(stale.data).toEqual(fresh.data);
|
||||
expect(repeated.data).toEqual(fresh.data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
|
||||
|
||||
now += 1;
|
||||
const retried = await handler.getDataAsync();
|
||||
|
||||
expect(retried.data).toEqual(fresh.data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("does not contact GitHub when external connections are disabled", async () => {
|
||||
mocks.env.NO_EXTERNAL_CONNECTION = true;
|
||||
const handler = await loadHandlerAsync();
|
||||
|
||||
await expect(handler.getDataAsync()).resolves.toMatchObject({ data: { availableUpdates: [] } });
|
||||
expect(mocks.listReleases).not.toHaveBeenCalled();
|
||||
expect(mocks.redisOperation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,114 @@ import { env } from "@homarr/common/env";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
import { createGetSetChannel, createLockChannel } from "@homarr/redis";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
|
||||
const logger = createLogger({ module: "updateCheckerRequestHandler" });
|
||||
|
||||
export const updateCheckerRequestHandler = createRequestHandler({
|
||||
async requestAsync(_) {
|
||||
const updateCheckTtlSeconds = 24 * 60 * 60;
|
||||
const updateCheckLockTtlSeconds = 2 * 60;
|
||||
const updateCheckWaitIntervalMs = 100;
|
||||
const updateCheckWaitAttempts = 50;
|
||||
|
||||
type UpdateCheckCacheEntry = {
|
||||
availableUpdates: Update[];
|
||||
attemptedAt: number;
|
||||
checkedAt: number | null;
|
||||
};
|
||||
|
||||
const updateCheckCacheVersion = `v2:${packageJson.version}`;
|
||||
const freshUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
|
||||
`update-checker:fresh:${updateCheckCacheVersion}`,
|
||||
);
|
||||
const staleUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
|
||||
`update-checker:stale:${updateCheckCacheVersion}`,
|
||||
);
|
||||
const updateCheckLock = createLockChannel(`update-checker:lock:${updateCheckCacheVersion}`);
|
||||
|
||||
const waitAsync = async (durationMs: number) =>
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
|
||||
const waitForConcurrentUpdateCheckAsync = async () => {
|
||||
for (let attempt = 0; attempt < updateCheckWaitAttempts; attempt++) {
|
||||
await waitAsync(updateCheckWaitIntervalMs);
|
||||
const cached = await freshUpdateCheckChannel.getAsync();
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
return await staleUpdateCheckChannel.getAsync();
|
||||
};
|
||||
|
||||
const getCachedAvailableUpdatesAsync = async (): Promise<UpdateCheckCacheEntry> => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) {
|
||||
return {
|
||||
availableUpdates: [],
|
||||
attemptedAt: Date.now(),
|
||||
checkedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const cached = await freshUpdateCheckChannel.getAsync();
|
||||
if (cached) return cached;
|
||||
|
||||
const staleBeforeRefresh = await staleUpdateCheckChannel.getAsync();
|
||||
const lockToken = await updateCheckLock.acquireAsync(updateCheckLockTtlSeconds);
|
||||
if (!lockToken) {
|
||||
if (staleBeforeRefresh) return staleBeforeRefresh;
|
||||
const concurrentResult = await waitForConcurrentUpdateCheckAsync();
|
||||
if (concurrentResult) return concurrentResult;
|
||||
throw new Error("Timed out waiting for the update check");
|
||||
}
|
||||
|
||||
try {
|
||||
const attemptedAt = Date.now();
|
||||
const availableUpdates = await getAvailableUpdatesAsync(packageJson.version);
|
||||
return { availableUpdates };
|
||||
},
|
||||
cacheTtlMs: 30 * 60 * 1000,
|
||||
});
|
||||
const result: UpdateCheckCacheEntry = {
|
||||
availableUpdates,
|
||||
attemptedAt,
|
||||
checkedAt: attemptedAt,
|
||||
};
|
||||
await staleUpdateCheckChannel.setAsync(result);
|
||||
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const stale = await staleUpdateCheckChannel.getAsync();
|
||||
const result: UpdateCheckCacheEntry = {
|
||||
availableUpdates: stale?.availableUpdates ?? [],
|
||||
attemptedAt: Date.now(),
|
||||
checkedAt: stale?.checkedAt ?? null,
|
||||
};
|
||||
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
|
||||
logger.warn("Failed to fetch available updates; suppressing retries for 24 hours", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
servingStale: stale !== null,
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
try {
|
||||
await updateCheckLock.releaseAsync(lockToken);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to release the update-check lock", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateCheckerRequestHandler = {
|
||||
handler: (_input: Record<string, never>) => ({
|
||||
getDataAsync: async () => {
|
||||
const result = await getCachedAvailableUpdatesAsync();
|
||||
return {
|
||||
data: { availableUpdates: result.availableUpdates },
|
||||
timestamp: new Date(result.checkedAt ?? result.attemptedAt),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
interface Update {
|
||||
name: string | null;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./component": "./src/component.ts",
|
||||
"./open": "./src/open.ts",
|
||||
"./styles.css": "./src/styles.css"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"use client";
|
||||
|
||||
export { Spotlight } from "./components/spotlight";
|
||||
@@ -11,18 +11,18 @@ import { hotkeys } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { OpenMediaRequestSearchOptions } from "../index";
|
||||
import {
|
||||
consumePendingMediaRequestSearch,
|
||||
consumePendingSpotlightOpen,
|
||||
mediaRequestSearchEvent,
|
||||
spotlightOpenEvent,
|
||||
} from "../open";
|
||||
import type { OpenMediaRequestSearchOptions } from "../open";
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
|
||||
import {
|
||||
mediaRequestSearchEvent,
|
||||
mediaRequestSearchScopeAtom,
|
||||
selectAction,
|
||||
spotlightActions,
|
||||
spotlightStore,
|
||||
} from "../spotlight-store";
|
||||
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
@@ -64,6 +64,17 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
|
||||
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = () => {
|
||||
consumePendingSpotlightOpen();
|
||||
spotlightActions.open();
|
||||
};
|
||||
|
||||
window.addEventListener(spotlightOpenEvent, handleOpen);
|
||||
if (consumePendingSpotlightOpen()) spotlightActions.open();
|
||||
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
|
||||
}, []);
|
||||
|
||||
const clearChildrenStack = useCallback(() => {
|
||||
setChildrenStack([]);
|
||||
}, []);
|
||||
@@ -84,17 +95,24 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
}, [setQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMediaRequestSearch = (event: Event) => {
|
||||
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||
const applyMediaRequestSearch = ({ integrationIds, query: searchQuery }: OpenMediaRequestSearchOptions) => {
|
||||
setMediaRequestSearchScope({ integrationIds });
|
||||
setMode("media");
|
||||
clearChildrenStack();
|
||||
setQuery(query ?? "");
|
||||
setQuery(searchQuery ?? "");
|
||||
spotlightActions.open();
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
};
|
||||
|
||||
const handleMediaRequestSearch = (event: Event) => {
|
||||
consumePendingMediaRequestSearch();
|
||||
const { integrationIds, query: searchQuery } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||
applyMediaRequestSearch({ integrationIds, query: searchQuery });
|
||||
};
|
||||
|
||||
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
const pendingSearch = consumePendingMediaRequestSearch();
|
||||
if (pendingSearch) applyMediaRequestSearch(pendingSearch);
|
||||
return () => {
|
||||
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
};
|
||||
@@ -134,6 +152,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
store={spotlightStore}
|
||||
>
|
||||
<MantineSpotlight.Search
|
||||
data-homarr-dev-benchmark-spotlight
|
||||
placeholder={`${t("search.placeholder")}...`}
|
||||
ref={inputRef}
|
||||
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
|
||||
|
||||
export { Spotlight } from "./components/spotlight";
|
||||
export { openSpotlight, openMediaRequestSearch };
|
||||
export { openSpotlight, openMediaRequestSearch, mediaRequestSearchEvent } from "./open";
|
||||
export {
|
||||
SpotlightProvider,
|
||||
useRegisterSpotlightContextResults,
|
||||
useRegisterSpotlightContextActions,
|
||||
} from "./modes/home/context";
|
||||
|
||||
const openSpotlight = spotlightActions.open;
|
||||
|
||||
export interface OpenMediaRequestSearchOptions {
|
||||
integrationIds?: string[];
|
||||
query?: string;
|
||||
}
|
||||
|
||||
const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
||||
}
|
||||
|
||||
spotlightActions.open();
|
||||
};
|
||||
|
||||
export { mediaRequestSearchEvent };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
consumePendingMediaRequestSearch,
|
||||
consumePendingSpotlightOpen,
|
||||
hasPendingSpotlightOpen,
|
||||
mediaRequestSearchEvent,
|
||||
openMediaRequestSearch,
|
||||
openSpotlight,
|
||||
spotlightOpenEvent,
|
||||
} from "./open";
|
||||
|
||||
describe("lazy Spotlight open bridge", () => {
|
||||
beforeEach(() => {
|
||||
consumePendingSpotlightOpen();
|
||||
consumePendingMediaRequestSearch();
|
||||
});
|
||||
|
||||
it("keeps a pre-mount open request until the lazy component consumes it", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(spotlightOpenEvent, listener);
|
||||
|
||||
openSpotlight();
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(consumePendingSpotlightOpen()).toBe(true);
|
||||
expect(consumePendingSpotlightOpen()).toBe(false);
|
||||
window.removeEventListener(spotlightOpenEvent, listener);
|
||||
});
|
||||
|
||||
it("lets a listener installed after the request discover the pending open", () => {
|
||||
openSpotlight();
|
||||
|
||||
expect(hasPendingSpotlightOpen()).toBe(true);
|
||||
expect(consumePendingSpotlightOpen()).toBe(true);
|
||||
expect(hasPendingSpotlightOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("retains media options while dispatching both mount and media events", () => {
|
||||
const openListener = vi.fn();
|
||||
const mediaListener = vi.fn();
|
||||
window.addEventListener(spotlightOpenEvent, openListener);
|
||||
window.addEventListener(mediaRequestSearchEvent, mediaListener);
|
||||
|
||||
openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" });
|
||||
|
||||
expect(openListener).toHaveBeenCalledOnce();
|
||||
expect(mediaListener).toHaveBeenCalledOnce();
|
||||
expect(consumePendingSpotlightOpen()).toBe(true);
|
||||
expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" });
|
||||
window.removeEventListener(spotlightOpenEvent, openListener);
|
||||
window.removeEventListener(mediaRequestSearchEvent, mediaListener);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
export const spotlightOpenEvent = "homarr:spotlight:open";
|
||||
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
|
||||
|
||||
export interface OpenMediaRequestSearchOptions {
|
||||
integrationIds?: string[];
|
||||
query?: string;
|
||||
}
|
||||
|
||||
let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null;
|
||||
let pendingSpotlightOpen = false;
|
||||
|
||||
const requestSpotlightMount = () => {
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new Event(spotlightOpenEvent));
|
||||
};
|
||||
|
||||
export const openSpotlight = () => {
|
||||
pendingSpotlightOpen = true;
|
||||
requestSpotlightMount();
|
||||
};
|
||||
|
||||
export const consumePendingSpotlightOpen = () => {
|
||||
const pending = pendingSpotlightOpen;
|
||||
pendingSpotlightOpen = false;
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const hasPendingSpotlightOpen = () => pendingSpotlightOpen;
|
||||
|
||||
export const consumePendingMediaRequestSearch = () => {
|
||||
const pending = pendingMediaRequestSearch;
|
||||
pendingMediaRequestSearch = null;
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
|
||||
pendingMediaRequestSearch = options;
|
||||
openSpotlight();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
||||
}
|
||||
};
|
||||
@@ -12,7 +12,6 @@ export interface MediaRequestSearchScope {
|
||||
}
|
||||
|
||||
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
|
||||
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
|
||||
|
||||
export const setSelectedAction = (index: number, store: SpotlightStore) => {
|
||||
store.updateState((state) => ({ ...state, selected: index }));
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"./client": "./src/client/index.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./middleware": "./src/middleware.ts",
|
||||
"./languages": "./src/languages.ts",
|
||||
"./request": "./src/request.ts",
|
||||
"./dayjs": "./src/dayjs.ts"
|
||||
},
|
||||
|
||||
@@ -15,13 +15,10 @@ declare module "next-intl" {
|
||||
}
|
||||
}
|
||||
|
||||
export const { useI18n, useScopedI18n } = {
|
||||
useI18n: useTranslations,
|
||||
useScopedI18n: useTranslations,
|
||||
};
|
||||
|
||||
export const { useI18nMessages } = {
|
||||
useI18nMessages: () => useMessages(),
|
||||
};
|
||||
// Keep these as direct exports so Turbopack and webpack can statically inspect
|
||||
// the client package's public surface.
|
||||
export const useI18n = useTranslations;
|
||||
export const useScopedI18n = useTranslations;
|
||||
export const useI18nMessages = () => useMessages();
|
||||
|
||||
export { useTranslations };
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { MRT_Localization } from "mantine-react-table";
|
||||
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import type { SupportedLanguage } from "./languages";
|
||||
|
||||
export { supportedLanguages } from "./languages";
|
||||
export type { SupportedLanguage } from "./languages";
|
||||
|
||||
export const localeConfigurations = {
|
||||
ca: {
|
||||
@@ -433,10 +436,7 @@ export type LanguageIconDefinition =
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const supportedLanguages = objectKeys(localeConfigurations);
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const fallbackLocale = "en" satisfies SupportedLanguage;
|
||||
export { fallbackLocale } from "./languages";
|
||||
|
||||
export const isLocaleRTL = (locale: SupportedLanguage) =>
|
||||
"isRTL" in localeConfigurations[locale] && localeConfigurations[locale].isRTL;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SupportedLanguage } from "./config";
|
||||
import { supportedLanguages } from "./config";
|
||||
import type { SupportedLanguage } from "./languages";
|
||||
import { supportedLanguages } from "./languages";
|
||||
import type { stringOrTranslation, TranslationFunction } from "./type";
|
||||
|
||||
export * from "./type";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { localeConfigurations } from "./config";
|
||||
import { supportedLanguages } from "./languages";
|
||||
|
||||
describe("supported translation languages", () => {
|
||||
it("matches the locale configuration registry", () => {
|
||||
expect(Object.keys(localeConfigurations)).toEqual([...supportedLanguages]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Keep the locale list dependency-free. This module is used by Next's proxy,
|
||||
* where importing the full translation barrel would widen every route graph.
|
||||
*/
|
||||
export const supportedLanguages = [
|
||||
"ca",
|
||||
"cn",
|
||||
"cr",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"de-CH",
|
||||
"en-gb",
|
||||
"en",
|
||||
"el",
|
||||
"es",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"he",
|
||||
"hr",
|
||||
"hu",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lt",
|
||||
"lv",
|
||||
"nl",
|
||||
"no",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sl",
|
||||
"sv",
|
||||
"tr",
|
||||
"zh",
|
||||
"uk",
|
||||
"vi",
|
||||
] as const;
|
||||
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const fallbackLocale = "en" satisfies SupportedLanguage;
|
||||
@@ -1,7 +1,7 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
|
||||
import type { SupportedLanguage } from ".";
|
||||
import { supportedLanguages } from "./config";
|
||||
import { supportedLanguages } from "./languages";
|
||||
import { createRouting } from "./routing";
|
||||
|
||||
export const createI18nMiddleware = (defaultLocale: SupportedLanguage) =>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
import { localeCookieKey } from "@homarr/definitions";
|
||||
import { localeCookieKey } from "@homarr/definitions/cookie";
|
||||
|
||||
import type { SupportedLanguage } from "./config";
|
||||
import { supportedLanguages } from "./config";
|
||||
import type { SupportedLanguage } from "./languages";
|
||||
import { supportedLanguages } from "./languages";
|
||||
|
||||
export const createRouting = (defaultLocale: SupportedLanguage) =>
|
||||
defineRouting({
|
||||
|
||||
@@ -16,11 +16,16 @@
|
||||
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./custom-api/component": "./src/custom-api/component.tsx",
|
||||
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
||||
"./catalog": "./src/catalog.ts",
|
||||
"./definition": "./src/definition.ts",
|
||||
"./errors": "./src/errors/component.tsx",
|
||||
"./manifest": "./src/manifest.ts",
|
||||
"./options": "./src/options.ts",
|
||||
"./errors/classes": "./src/errors/index.ts",
|
||||
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
||||
"./modals": "./src/modals/index.ts",
|
||||
"./prefetch": "./src/prefetch.ts"
|
||||
"./prefetch": "./src/prefetch.ts",
|
||||
"./refetch-intervals": "./src/refetch-intervals.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
|
||||
icon: IconNotes,
|
||||
queryKey: [["widget", "anchorNotes"]],
|
||||
supportedIntegrations: ["anchor"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -15,6 +15,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("app", {
|
||||
icon: IconApps,
|
||||
queryKeys: [[["app", "byId"]], [["widget", "app"]]],
|
||||
createOptions(settings) {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||
|
||||
const logger = createLogger({ module: "appWidgetPrefetch" });
|
||||
|
||||
@@ -16,7 +16,7 @@ const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
|
||||
});
|
||||
|
||||
for (const app of dbApps) {
|
||||
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
|
||||
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
|
||||
}
|
||||
|
||||
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
|
||||
icon: IconBell,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||
icon: IconBookmark,
|
||||
queryKeys: [[["app", "byIds"]]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||
|
||||
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
||||
|
||||
@@ -21,7 +21,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
trpc.app.byIds.queryKey(item.options.items),
|
||||
createTrpcQueryKey("app.byIds", item.options.items),
|
||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
IconActivityHeartbeat,
|
||||
IconAd,
|
||||
IconApi,
|
||||
IconApps,
|
||||
IconArchive,
|
||||
IconBadgeCc,
|
||||
IconBatteryCharging,
|
||||
IconBell,
|
||||
IconBinaryTree,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconBrandMinecraft,
|
||||
IconBrowser,
|
||||
IconBuildingBank,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconChartAreaLine,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconCloud,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconGraphFilled,
|
||||
IconHeadphones,
|
||||
IconHeartRateMonitor,
|
||||
IconHeartbeat,
|
||||
IconLayoutGrid,
|
||||
IconMessage,
|
||||
IconMovie,
|
||||
IconNotes,
|
||||
IconPhoto,
|
||||
IconReportSearch,
|
||||
IconRocket,
|
||||
IconRoute,
|
||||
IconRss,
|
||||
IconServer2,
|
||||
IconShieldCheck,
|
||||
IconShieldLock,
|
||||
IconSpeedboat,
|
||||
IconTable,
|
||||
IconTicket,
|
||||
IconTopologyFull,
|
||||
IconTransform,
|
||||
IconVideo,
|
||||
IconWall,
|
||||
IconZoomQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
clock: IconClock,
|
||||
weather: IconCloud,
|
||||
app: IconApps,
|
||||
iframe: IconBrowser,
|
||||
video: IconDeviceCctv,
|
||||
notebook: IconNotes,
|
||||
anchorNote: IconNotes,
|
||||
dnsHoleSummary: IconAd,
|
||||
dnsHoleControls: IconDeviceGamepad,
|
||||
"smartHome-entityState": IconBinaryTree,
|
||||
"smartHome-executeAutomation": IconBinaryTree,
|
||||
stockPrice: IconBuildingBank,
|
||||
mediaServer: IconVideo,
|
||||
calendar: IconCalendar,
|
||||
downloads: IconDownload,
|
||||
"mediaRequests-requestList": IconZoomQuestion,
|
||||
"mediaRequests-requestStats": IconChartBar,
|
||||
mediaTranscoding: IconTransform,
|
||||
mediaMissing: IconMovie,
|
||||
minecraftServerStatus: IconBrandMinecraft,
|
||||
networkControllerSummary: IconTopologyFull,
|
||||
networkControllerStatus: IconTopologyFull,
|
||||
rssFeed: IconRss,
|
||||
bookmarks: IconBookmark,
|
||||
indexerManager: IconReportSearch,
|
||||
healthMonitoring: IconHeartRateMonitor,
|
||||
releases: IconRocket,
|
||||
mediaReleases: IconTicket,
|
||||
dockerContainers: IconBrandDocker,
|
||||
firewall: IconWall,
|
||||
notifications: IconMessage,
|
||||
systemResources: IconGraphFilled,
|
||||
coolify: IconCloud,
|
||||
systemDisks: IconServer2,
|
||||
timetable: IconBusStop,
|
||||
"immich-serverStats": IconGraphFilled,
|
||||
"immich-albumCarousel": IconPhoto,
|
||||
paperlessNgx: IconFileText,
|
||||
patchmon: IconShieldCheck,
|
||||
bazarr: IconBadgeCc,
|
||||
tracearr: IconActivityHeartbeat,
|
||||
speedtestTracker: IconSpeedboat,
|
||||
uptimeKuma: IconHeartbeat,
|
||||
audioStats: IconHeadphones,
|
||||
umami: IconChartBar,
|
||||
vpn: IconShieldLock,
|
||||
archiveTeamWarrior: IconArchive,
|
||||
ups: IconBatteryCharging,
|
||||
beszelSystemTable: IconTable,
|
||||
beszelSystemGrid: IconLayoutGrid,
|
||||
beszelAlerts: IconBell,
|
||||
beszelSystemStats: IconChartAreaLine,
|
||||
traefik: IconRoute,
|
||||
customApi: IconApi,
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
queryKey: [["widget", "weather"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -70,6 +70,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
|
||||
export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
queryKey?: QueryKey;
|
||||
queryKeys?: QueryKey[];
|
||||
refetchInterval?: number | null;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
@@ -90,6 +91,9 @@ export interface WidgetDefinition {
|
||||
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
|
||||
}
|
||||
|
||||
export const getWidgetQueryKeys = (definition: WidgetDefinition, kind: WidgetKind): QueryKey[] =>
|
||||
definition.queryKeys ?? [definition.queryKey ?? [["widget", kind]]];
|
||||
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
|
||||
integrationIds: string[];
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
@@ -17,7 +17,7 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="md">
|
||||
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
|
||||
<props.icon size={40} />
|
||||
<Stack gap={0}>
|
||||
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { IconExclamationCircle, IconShield } from "@tabler/icons-react";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetDefinition } from "..";
|
||||
import { widgetImports } from "..";
|
||||
import type { WidgetDefinition } from "../definition";
|
||||
import { ErrorBoundaryError } from "./base";
|
||||
import type { BaseWidgetErrorProps } from "./base-component";
|
||||
import { BaseWidgetError } from "./base-component";
|
||||
|
||||
interface WidgetErrorProps {
|
||||
kind: WidgetKind;
|
||||
definition?: WidgetDefinition;
|
||||
error: unknown;
|
||||
resetErrorBoundary: () => void;
|
||||
}
|
||||
|
||||
export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProps) => {
|
||||
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
|
||||
|
||||
export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErrorProps) => {
|
||||
if (error instanceof ErrorBoundaryError) {
|
||||
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
|
||||
const widgetTrpcErrorData = definition ? handleWidgetTrpcError(error, definition) : null;
|
||||
if (widgetTrpcErrorData) {
|
||||
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
@@ -34,13 +28,7 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp
|
||||
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseWidgetError
|
||||
icon={IconExclamationCircle}
|
||||
message={(error as { toString: () => string }).toString()}
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
|
||||
};
|
||||
|
||||
const handleWidgetTrpcError = (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
|
||||
icon: IconHeartRateMonitor,
|
||||
queryKeys: [[["widget", "healthMonitoring"]], [["integration", "byIds"]]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
createRetryableLoader,
|
||||
loadAllWidgetDefinitions,
|
||||
loadWidgetComponent,
|
||||
loadWidgetDefinition,
|
||||
loadWidgetModule,
|
||||
loadWidgetResources,
|
||||
reduceWidgetOptionsWithDefinition,
|
||||
widgetKinds,
|
||||
} from "./manifest";
|
||||
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
|
||||
import { widgetCatalogIcons } from "./catalog";
|
||||
|
||||
describe("widget manifest promise stability", () => {
|
||||
it("returns the same module promise for every render", () => {
|
||||
expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock"));
|
||||
});
|
||||
|
||||
it("returns the same derived definition promise for every render", () => {
|
||||
expect(loadWidgetDefinition("clock")).toBe(loadWidgetDefinition("clock"));
|
||||
});
|
||||
|
||||
it("returns stable component and combined resource promises", () => {
|
||||
expect(loadWidgetComponent("clock")).toBe(loadWidgetComponent("clock"));
|
||||
expect(loadWidgetResources("clock")).toBe(loadWidgetResources("clock"));
|
||||
});
|
||||
|
||||
it("returns the same all-definitions promise for every modal render", () => {
|
||||
expect(loadAllWidgetDefinitions()).toBe(loadAllWidgetDefinitions());
|
||||
});
|
||||
|
||||
it("retries a failed loader instead of retaining its rejected promise", async () => {
|
||||
let attempts = 0;
|
||||
const loader = createRetryableLoader(
|
||||
new Map([
|
||||
[
|
||||
"example",
|
||||
() => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error("temporary failure"));
|
||||
return Promise.resolve("loaded");
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(loader("example")).rejects.toThrow("temporary failure");
|
||||
await expect(loader("example")).resolves.toBe("loaded");
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("covers every declared widget kind", () => {
|
||||
expect(new Set(widgetKinds)).toEqual(new Set(definedWidgetKinds));
|
||||
});
|
||||
|
||||
it("loads matching definitions and component loaders for every widget", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
|
||||
await Promise.all(
|
||||
widgetKinds.map(async (kind) => {
|
||||
const module = await loadWidgetModule(kind);
|
||||
const canonicalComponent = await module.componentLoader();
|
||||
const component = await loadWidgetComponent(kind);
|
||||
const resources = await loadWidgetResources(kind);
|
||||
expect(definitions.get(kind)).toBe(module.definition);
|
||||
expect(component.default).toBe(canonicalComponent.default);
|
||||
expect(component.default).toBeDefined();
|
||||
expect(resources.definition).toBe(module.definition);
|
||||
expect(resources.Component).toBe(component.default);
|
||||
}),
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
it("preserves every widget option default", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
|
||||
|
||||
for (const definition of definitions.values()) {
|
||||
const options = definition.createOptions(settings);
|
||||
const reduced = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
expect(Object.keys(reduced)).toEqual(Object.keys(options));
|
||||
expect(Object.values(reduced).every((value) => value !== undefined)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the lightweight integration support map aligned with widget definitions", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
|
||||
for (const kind of widgetKinds) {
|
||||
const definition = definitions.get(kind);
|
||||
const supportedIntegrations =
|
||||
definition && "supportedIntegrations" in definition ? (definition.supportedIntegrations ?? []) : [];
|
||||
expect([...(widgetIntegrationSupport[kind] ?? [])].toSorted()).toEqual([...supportedIntegrations].toSorted());
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the lightweight catalog icons aligned with widget definitions", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
|
||||
for (const kind of widgetKinds) {
|
||||
expect(widgetCatalogIcons[kind]).toBe(definitions.get(kind)?.icon);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares the real query prefix for widget kinds that share a router", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
const expectedQueryKeys = new Map([
|
||||
["anchorNote", [["widget", "anchorNotes"]]],
|
||||
["beszelAlerts", [["widget", "beszel"]]],
|
||||
["clock", [["widget", "weather"]]],
|
||||
["mediaMissing", [["widget", "mediaOrganizer"]]],
|
||||
["mediaRequests-requestList", [["widget", "mediaRequests"]]],
|
||||
["mediaRequests-requestStats", [["widget", "mediaRequests"]]],
|
||||
["smartHome-entityState", [["widget", "smartHome"]]],
|
||||
] as const);
|
||||
|
||||
for (const [kind, queryKey] of expectedQueryKeys) {
|
||||
expect(definitions.get(kind)?.queryKey).toEqual(queryKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the client polling policy aligned with widget definitions", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
type PollingPolicy = { queryKey: QueryKey; intervalSeconds: number | null };
|
||||
const expectedByQueryKey = new Map<string, PollingPolicy>();
|
||||
|
||||
for (const kind of widgetKinds) {
|
||||
const definition = definitions.get(kind);
|
||||
if (definition?.refetchInterval === undefined) continue;
|
||||
|
||||
const entry = {
|
||||
queryKey: definition.queryKey ?? [["widget", kind]],
|
||||
intervalSeconds: definition.refetchInterval,
|
||||
} as const;
|
||||
const serializedQueryKey = JSON.stringify(entry.queryKey);
|
||||
const existing = expectedByQueryKey.get(serializedQueryKey);
|
||||
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
|
||||
expectedByQueryKey.set(serializedQueryKey, entry);
|
||||
}
|
||||
|
||||
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
|
||||
expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual(
|
||||
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
|
||||
import type { WidgetComponentProps, WidgetDefinition } from "./definition";
|
||||
import type { WidgetOptionDefinition } from "./options";
|
||||
|
||||
type WidgetModule = {
|
||||
definition: WidgetDefinition;
|
||||
componentLoader: () => Promise<{ default: ComponentType<WidgetComponentProps<WidgetKind>> }>;
|
||||
};
|
||||
|
||||
type WidgetComponentModule = {
|
||||
default: ComponentType<WidgetComponentProps<WidgetKind>>;
|
||||
};
|
||||
|
||||
export type WidgetResources = {
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
|
||||
};
|
||||
|
||||
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
clock: () => import("./clock") as Promise<WidgetModule>,
|
||||
weather: () => import("./weather") as Promise<WidgetModule>,
|
||||
app: () => import("./app") as Promise<WidgetModule>,
|
||||
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise<WidgetModule>,
|
||||
anchorNote: () => import("./anchor-note") as Promise<WidgetModule>,
|
||||
notebook: () => import("./notebook") as Promise<WidgetModule>,
|
||||
iframe: () => import("./iframe") as Promise<WidgetModule>,
|
||||
video: () => import("./video") as Promise<WidgetModule>,
|
||||
dnsHoleSummary: () => import("./dns-hole/summary") as Promise<WidgetModule>,
|
||||
dnsHoleControls: () => import("./dns-hole/controls") as Promise<WidgetModule>,
|
||||
"smartHome-entityState": () => import("./smart-home/entity-state") as Promise<WidgetModule>,
|
||||
"smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise<WidgetModule>,
|
||||
stockPrice: () => import("./stocks") as Promise<WidgetModule>,
|
||||
mediaServer: () => import("./media-server") as Promise<WidgetModule>,
|
||||
calendar: () => import("./calendar") as Promise<WidgetModule>,
|
||||
downloads: () => import("./downloads") as Promise<WidgetModule>,
|
||||
"mediaRequests-requestList": () => import("./media-requests/list") as Promise<WidgetModule>,
|
||||
"mediaRequests-requestStats": () => import("./media-requests/stats") as Promise<WidgetModule>,
|
||||
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
|
||||
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
|
||||
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
|
||||
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
|
||||
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
|
||||
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
|
||||
indexerManager: () => import("./indexer-manager") as Promise<WidgetModule>,
|
||||
healthMonitoring: () => import("./health-monitoring") as Promise<WidgetModule>,
|
||||
mediaTranscoding: () => import("./media-transcoding") as Promise<WidgetModule>,
|
||||
minecraftServerStatus: () => import("./minecraft/server-status") as Promise<WidgetModule>,
|
||||
dockerContainers: () => import("./docker") as Promise<WidgetModule>,
|
||||
releases: () => import("./releases") as Promise<WidgetModule>,
|
||||
firewall: () => import("./firewall") as Promise<WidgetModule>,
|
||||
notifications: () => import("./notifications") as Promise<WidgetModule>,
|
||||
mediaReleases: () => import("./media-releases") as Promise<WidgetModule>,
|
||||
systemResources: () => import("./system-resources") as Promise<WidgetModule>,
|
||||
coolify: () => import("./coolify") as Promise<WidgetModule>,
|
||||
systemDisks: () => import("./system-disks") as Promise<WidgetModule>,
|
||||
timetable: () => import("./timetable") as Promise<WidgetModule>,
|
||||
"immich-serverStats": () => import("./immich/server-stats") as Promise<WidgetModule>,
|
||||
"immich-albumCarousel": () => import("./immich/album-carousel") as Promise<WidgetModule>,
|
||||
paperlessNgx: () => import("./paperless-ngx") as Promise<WidgetModule>,
|
||||
patchmon: () => import("./patchmon") as Promise<WidgetModule>,
|
||||
tracearr: () => import("./tracearr") as Promise<WidgetModule>,
|
||||
speedtestTracker: () => import("./speedtest-tracker") as Promise<WidgetModule>,
|
||||
uptimeKuma: () => import("./uptime-kuma") as Promise<WidgetModule>,
|
||||
audioStats: () => import("./audio-stats") as Promise<WidgetModule>,
|
||||
umami: () => import("./umami") as Promise<WidgetModule>,
|
||||
vpn: () => import("./vpn") as Promise<WidgetModule>,
|
||||
ups: () => import("./ups") as Promise<WidgetModule>,
|
||||
beszelSystemTable: () => import("./beszel-system-table") as Promise<WidgetModule>,
|
||||
beszelSystemGrid: () => import("./beszel-system-grid") as Promise<WidgetModule>,
|
||||
beszelAlerts: () => import("./beszel-alerts") as Promise<WidgetModule>,
|
||||
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
|
||||
traefik: () => import("./traefik") as Promise<WidgetModule>,
|
||||
customApi: () => import("./custom-api") as Promise<WidgetModule>,
|
||||
};
|
||||
|
||||
// Keep these imports explicit so Turbopack can create one discoverable chunk per
|
||||
// widget implementation. Loading this registry does not load the implementations.
|
||||
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
|
||||
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
|
||||
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
|
||||
app: () => import("./app/component") as Promise<WidgetComponentModule>,
|
||||
archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise<WidgetComponentModule>,
|
||||
anchorNote: () => import("./anchor-note/component") as Promise<WidgetComponentModule>,
|
||||
notebook: () => import("./notebook/component") as Promise<WidgetComponentModule>,
|
||||
iframe: () => import("./iframe/component") as Promise<WidgetComponentModule>,
|
||||
video: () => import("./video/component") as Promise<WidgetComponentModule>,
|
||||
dnsHoleSummary: () => import("./dns-hole/summary/component") as Promise<WidgetComponentModule>,
|
||||
dnsHoleControls: () => import("./dns-hole/controls/component") as Promise<WidgetComponentModule>,
|
||||
"smartHome-entityState": () => import("./smart-home/entity-state/component") as Promise<WidgetComponentModule>,
|
||||
"smartHome-executeAutomation": () =>
|
||||
import("./smart-home/execute-automation/component") as Promise<WidgetComponentModule>,
|
||||
stockPrice: () => import("./stocks/component") as Promise<WidgetComponentModule>,
|
||||
mediaServer: () => import("./media-server/component") as Promise<WidgetComponentModule>,
|
||||
calendar: () => import("./calendar/component") as Promise<WidgetComponentModule>,
|
||||
downloads: () => import("./downloads/component") as Promise<WidgetComponentModule>,
|
||||
"mediaRequests-requestList": () => import("./media-requests/list/component") as Promise<WidgetComponentModule>,
|
||||
"mediaRequests-requestStats": () => import("./media-requests/stats/component") as Promise<WidgetComponentModule>,
|
||||
mediaMissing: () => import("./media-missing/component") as Promise<WidgetComponentModule>,
|
||||
networkControllerSummary: () => import("./network-controller/summary/component") as Promise<WidgetComponentModule>,
|
||||
networkControllerStatus: () =>
|
||||
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
|
||||
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
|
||||
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
|
||||
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
|
||||
indexerManager: () => import("./indexer-manager/component") as Promise<WidgetComponentModule>,
|
||||
healthMonitoring: () => import("./health-monitoring/component") as Promise<WidgetComponentModule>,
|
||||
mediaTranscoding: () => import("./media-transcoding/component") as Promise<WidgetComponentModule>,
|
||||
minecraftServerStatus: () => import("./minecraft/server-status/component") as Promise<WidgetComponentModule>,
|
||||
dockerContainers: () => import("./docker/component") as Promise<WidgetComponentModule>,
|
||||
releases: () => import("./releases/component") as Promise<WidgetComponentModule>,
|
||||
firewall: () => import("./firewall/component") as Promise<WidgetComponentModule>,
|
||||
notifications: () => import("./notifications/component") as Promise<WidgetComponentModule>,
|
||||
mediaReleases: () => import("./media-releases/component") as Promise<WidgetComponentModule>,
|
||||
systemResources: () => import("./system-resources/component") as Promise<WidgetComponentModule>,
|
||||
coolify: () => import("./coolify/component") as Promise<WidgetComponentModule>,
|
||||
systemDisks: () => import("./system-disks/component") as Promise<WidgetComponentModule>,
|
||||
timetable: () => import("./timetable/component") as Promise<WidgetComponentModule>,
|
||||
"immich-serverStats": () => import("./immich/server-stats/component") as Promise<WidgetComponentModule>,
|
||||
"immich-albumCarousel": () => import("./immich/album-carousel/component") as Promise<WidgetComponentModule>,
|
||||
paperlessNgx: () => import("./paperless-ngx/component") as Promise<WidgetComponentModule>,
|
||||
patchmon: () => import("./patchmon/component") as Promise<WidgetComponentModule>,
|
||||
tracearr: () => import("./tracearr/component") as Promise<WidgetComponentModule>,
|
||||
speedtestTracker: () => import("./speedtest-tracker/component") as Promise<WidgetComponentModule>,
|
||||
uptimeKuma: () => import("./uptime-kuma/component") as Promise<WidgetComponentModule>,
|
||||
audioStats: () => import("./audio-stats/component") as Promise<WidgetComponentModule>,
|
||||
umami: () => import("./umami/component") as Promise<WidgetComponentModule>,
|
||||
vpn: () => import("./vpn/component") as Promise<WidgetComponentModule>,
|
||||
ups: () => import("./ups/component") as Promise<WidgetComponentModule>,
|
||||
beszelSystemTable: () => import("./beszel-system-table/component") as Promise<WidgetComponentModule>,
|
||||
beszelSystemGrid: () => import("./beszel-system-grid/component") as Promise<WidgetComponentModule>,
|
||||
beszelAlerts: () => import("./beszel-alerts/component") as Promise<WidgetComponentModule>,
|
||||
beszelSystemStats: () => import("./beszel-system-stats/component") as Promise<WidgetComponentModule>,
|
||||
traefik: () => import("./traefik/component") as Promise<WidgetComponentModule>,
|
||||
customApi: () => import("./custom-api/component") as Promise<WidgetComponentModule>,
|
||||
};
|
||||
|
||||
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
|
||||
|
||||
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
|
||||
|
||||
export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) => {
|
||||
const promises = new Map<TKey, Promise<TValue>>();
|
||||
|
||||
return (key: TKey) => {
|
||||
const existing = promises.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const loader = loaders.get(key);
|
||||
if (!loader) throw new Error(`No loader is registered for ${String(key)}`);
|
||||
|
||||
const promise = loader();
|
||||
promises.set(key, promise);
|
||||
void promise.catch(() => {
|
||||
if (promises.get(key) === promise) promises.delete(key);
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
};
|
||||
|
||||
export const loadWidgetModule = createRetryableLoader(
|
||||
new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise<WidgetModule>][]),
|
||||
);
|
||||
|
||||
export const loadWidgetComponent = createRetryableLoader(
|
||||
new Map(Object.entries(componentLoaders) as [WidgetKind, () => Promise<WidgetComponentModule>][]),
|
||||
);
|
||||
|
||||
export const loadWidgetResources = createRetryableLoader(
|
||||
new Map(
|
||||
(Object.keys(moduleLoaders) as WidgetKind[]).map((kind) => [
|
||||
kind,
|
||||
async () => {
|
||||
const [{ definition }, { default: Component }] = await Promise.all([
|
||||
loadWidgetModule(kind),
|
||||
loadWidgetComponent(kind),
|
||||
]);
|
||||
return { definition, Component } satisfies WidgetResources;
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
export const loadWidgetDefinition = (kind: WidgetKind) => {
|
||||
const existing = definitionPromises.get(kind);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = loadWidgetModule(kind).then(({ definition }) => definition);
|
||||
definitionPromises.set(kind, promise);
|
||||
void promise.catch(() => {
|
||||
if (definitionPromises.get(kind) === promise) definitionPromises.delete(kind);
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
let allWidgetDefinitionsPromise: Promise<Map<WidgetKind, WidgetDefinition>> | undefined;
|
||||
|
||||
export const loadAllWidgetDefinitions = () => {
|
||||
if (allWidgetDefinitionsPromise) return allWidgetDefinitionsPromise;
|
||||
|
||||
const promise = Promise.all(widgetKinds.map(async (kind) => [kind, await loadWidgetDefinition(kind)] as const)).then(
|
||||
(entries) => new Map(entries),
|
||||
);
|
||||
allWidgetDefinitionsPromise = promise;
|
||||
void promise.catch(() => {
|
||||
if (allWidgetDefinitionsPromise === promise) allWidgetDefinitionsPromise = undefined;
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const reduceWidgetOptionsWithDefinition = (
|
||||
definition: WidgetDefinition,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
currentValue: Record<string, unknown> = {},
|
||||
) => {
|
||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
||||
return objectEntries(options).reduce(
|
||||
(prev, [key, value]) => ({
|
||||
...prev,
|
||||
[key]: currentValue[key] ?? value.defaultValue,
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
|
||||
icon: IconMovie,
|
||||
queryKey: [["widget", "mediaOrganizer"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showMissing: factory.switch({ defaultValue: true }),
|
||||
|
||||
@@ -11,6 +11,7 @@ const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
|
||||
icon: IconZoomQuestion,
|
||||
queryKey: [["widget", "mediaRequests"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createWidgetDefinition } from "../../definition";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
|
||||
icon: IconChartBar,
|
||||
queryKey: [["widget", "mediaRequests"]],
|
||||
createOptions() {
|
||||
return {};
|
||||
},
|
||||
|
||||
@@ -14,11 +14,11 @@ import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zodErrorMap } from "@homarr/validation/form/i18n";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
import { getInputForType } from "../_inputs";
|
||||
import { FormProvider, useForm } from "../_inputs/form";
|
||||
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
|
||||
import type { OptionsBuilderResult } from "../options";
|
||||
import type { WidgetDefinition } from "../definition";
|
||||
import { OPTIONS_SUPER_REFINE } from "../options";
|
||||
import type { IntegrationSelectOption } from "../widget-integration-select";
|
||||
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
||||
@@ -32,8 +32,9 @@ export interface WidgetEditModalState {
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}
|
||||
|
||||
interface ModalProps<TSort extends WidgetKind> {
|
||||
export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
kind: TSort;
|
||||
definition: WidgetDefinition;
|
||||
value: WidgetEditModalState;
|
||||
onSuccessfulEdit: (value: WidgetEditModalState) => void;
|
||||
integrationData: IntegrationSelectOption[];
|
||||
@@ -43,7 +44,7 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||
@@ -53,7 +54,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
z.config({
|
||||
customError: zodErrorMap(t),
|
||||
});
|
||||
const { definition } = widgetImports[innerProps.kind];
|
||||
const { definition } = innerProps;
|
||||
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
|
||||
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
|
||||
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
|
||||
@@ -124,10 +125,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={
|
||||
((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) >
|
||||
1
|
||||
}
|
||||
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import { cache } from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createSettings } from "@homarr/settings/creator";
|
||||
|
||||
import { reduceWidgetOptionsWithDefaultValues } from ".";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest";
|
||||
import prefetchForApps from "./app/prefetch";
|
||||
import prefetchForBookmarks from "./bookmarks/prefetch";
|
||||
import prefetchForDownloads from "./downloads/prefetch";
|
||||
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
|
||||
import type { inferOptionsFromCreator } from "./options";
|
||||
|
||||
const cachedGetServerSettingsAsync = cache(getServerSettingsAsync);
|
||||
|
||||
const prefetchCallbacks: Partial<{
|
||||
[TKind in WidgetKind]: Prefetch<TKind>;
|
||||
}> = {
|
||||
bookmarks: prefetchForBookmarks,
|
||||
app: prefetchForApps,
|
||||
downloads: prefetchForDownloads,
|
||||
};
|
||||
|
||||
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
||||
@@ -36,11 +30,15 @@ export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
||||
return;
|
||||
}
|
||||
|
||||
const serverSettings = await cachedGetServerSettingsAsync(db);
|
||||
const [serverSettings, definition] = await Promise.all([getRscServerSettingsAsync(), loadWidgetDefinition(kind)]);
|
||||
|
||||
const itemsWithDefaultOptions = items.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, createSettings({ user: null, serverSettings }), item.options),
|
||||
options: reduceWidgetOptionsWithDefinition(
|
||||
definition,
|
||||
createSettings({ user: null, serverSettings }),
|
||||
item.options,
|
||||
),
|
||||
}));
|
||||
|
||||
await callback(queryClient, itemsWithDefaultOptions as never[]);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Query policy used by the root client provider.
|
||||
*
|
||||
* Keep this module free of widget component/definition imports. Importing the
|
||||
* full widget registry from a root provider makes every widget part of the
|
||||
* initial client graph, even before a board has rendered one widget.
|
||||
*/
|
||||
export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
|
||||
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "downloads"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "mediaServer"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "immich"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "mediaTranscoding"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "minecraft"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "releases"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "rssFeed"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
|
||||
] as const;
|
||||
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
|
||||
icon: IconBinaryTree,
|
||||
queryKey: [["widget", "smartHome"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
entityId: factory.text({
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { createLanguageMapping } from "@homarr/translation";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
import { loadAllWidgetDefinitions } from "../manifest";
|
||||
|
||||
describe("Widget properties with description should have matching translations", async () => {
|
||||
const enTranslation = await createLanguageMapping().en();
|
||||
objectEntries(widgetImports).forEach(([key, value]) => {
|
||||
Object.entries(value.definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
|
||||
const widgetDefinitions = await loadAllWidgetDefinitions();
|
||||
const enTranslation = await createLanguageMapping().en();
|
||||
|
||||
describe("Widget properties with description should have matching translations", () => {
|
||||
for (const [key, definition] of widgetDefinitions) {
|
||||
Object.entries(definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
|
||||
const optionValue = optionValue_ as { withDescription: boolean };
|
||||
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
|
||||
const option = enTranslation.default.widget[key].option;
|
||||
@@ -21,13 +22,12 @@ describe("Widget properties with description should have matching translations",
|
||||
expect("description" in value).toBe(optionValue.withDescription);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Widget properties should have matching name translations", async () => {
|
||||
const enTranslation = await createLanguageMapping().en();
|
||||
objectEntries(widgetImports).forEach(([key, value]) => {
|
||||
Object.keys(value.definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
|
||||
describe("Widget properties should have matching name translations", () => {
|
||||
for (const [key, definition] of widgetDefinitions) {
|
||||
Object.keys(definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
|
||||
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
|
||||
const option = enTranslation.default.widget[key].option;
|
||||
if (!(optionKey in option)) {
|
||||
@@ -38,5 +38,5 @@ describe("Widget properties should have matching name translations", async () =>
|
||||
expect("label" in value).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTrpcQueryKey } from "./trpc-query-key";
|
||||
|
||||
describe("createTrpcQueryKey", () => {
|
||||
test("matches the tRPC TanStack query key shape", () => {
|
||||
expect(createTrpcQueryKey("widget.downloads.getJobsAndStatuses", { integrationIds: ["one"], limit: 50 })).toEqual([
|
||||
["widget", "downloads", "getJobsAndStatuses"],
|
||||
{ input: { integrationIds: ["one"], limit: 50 }, type: "query" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const createTrpcQueryKey = <TInput>(path: string, input: TInput) => [
|
||||
path.split("."),
|
||||
{ input, type: "query" as const },
|
||||
];
|
||||
@@ -43,7 +43,7 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
|
||||
}
|
||||
|
||||
interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options"> {
|
||||
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
||||
weather: NonNullable<RouterOutputs["widget"]["weather"]["atLocation"]>;
|
||||
}
|
||||
|
||||
const DailyWeather = ({ options, weather }: WeatherProps) => {
|
||||
|
||||
Reference in new issue
Block a user