perf: cut cold route compilation and unblock preview images (#6441)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-07 20:24:26 +02:00
1 parent 622e1dcfa5
commit b32ad82850
182 files changed
+11421 -2339

No files matched your search

+3
View File
@@ -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",
+16
View File
@@ -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();
});
+46 -16
View File
@@ -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("."));
};
-1
View File
@@ -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)),
+10 -7
View File
@@ -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,
},
},
});
-42
View File
@@ -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([]);
});
});
+2 -2
View File
@@ -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);
}),
+16
View File
@@ -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));
+3 -12
View File
@@ -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);
+54 -119
View File
@@ -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,
},
]);
});
});
+24
View File
@@ -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
View File
@@ -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),
}));
};
-11
View File
@@ -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={{
+6 -2
View File
@@ -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);
}
}
});
+1
View File
@@ -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);
});
+128
View File
@@ -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);
});
});
+173
View File
@@ -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();
};
+11
View File
@@ -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(),
}),
};
+11
View File
@@ -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(),
}),
};
+11
View File
@@ -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(),
}),
};
+3
View File
@@ -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;
+1 -1
View File
@@ -4,5 +4,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
"exclude": ["node_modules", "migrations/*/migrate.cjs"]
}
+1
View File
@@ -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"],
+1 -3
View File
@@ -6,9 +6,7 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
setQueryCacheAsync,
getQueryCacheAsync,
removeQueryCacheAsync,
createLockChannel,
invalidateIntegrationCacheAsync,
} from "./lib/channel";
+21
View File
@@ -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();
});
});
+29 -29
View File
@@ -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();
});
});
+102 -7
View File
@@ -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;
+2
View File
@@ -13,6 +13,8 @@
},
"exports": {
".": "./index.ts",
"./component": "./src/component.ts",
"./open": "./src/open.ts",
"./styles.css": "./src/styles.css"
},
"scripts": {
+3
View File
@@ -0,0 +1,3 @@
"use client";
export { Spotlight } from "./components/spotlight";
+30 -11
View File
@@ -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 -21
View File
@@ -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 };
+54
View File
@@ -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);
});
});
+44
View File
@@ -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 }));
+1
View File
@@ -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"
},
+5 -8
View File
@@ -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 };
+5 -5
View File
@@ -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;
+2 -2
View File
@@ -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]);
});
});
+46
View File
@@ -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 -1
View File
@@ -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) =>
+3 -3
View File
@@ -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({
+6 -1
View File
@@ -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) => ({
+1
View File
@@ -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) => ({
+2 -2
View File
@@ -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() {
+1
View File
@@ -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(),
+2 -2
View File
@@ -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)),
);
}
+110
View File
@@ -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,
};
+1
View File
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
queryKey: [["widget", "weather"]],
createOptions() {
return optionsBuilder.from(
(factory) => ({
+4
View File
@@ -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>
+5 -17
View File
@@ -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(
+151
View File
@@ -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(),
);
});
});
+229
View File
@@ -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")}
/>
)}
+8 -10
View File
@@ -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[]);
+29
View File
@@ -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({
+12 -12
View File
@@ -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" },
]);
});
});
+4
View File
@@ -0,0 +1,4 @@
export const createTrpcQueryKey = <TInput>(path: string, input: TInput) => [
path.split("."),
{ input, type: "query" as const },
];
+1 -1
View File
@@ -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) => {