Compare commits

..
Author SHA1 Message Date
Releases Homarr 57e18f6ce6 chore(release): 1.69.2 [skip ci] 2026-07-06 08:05:24 +00:00
Thomas 29369e23c1 Merge pull request #6208 from homarr-labs/proxmox-status-zindex
fix(layout): raise AppShellHeader z-index above Proxmox status dots
2026-07-06 10:04:04 +02:00
ajnart 687edb9226 fix(layout): raise AppShellHeader z-index above Indicator dots
The Mantine Indicator component (used by the Proxmox cluster health
widget for VM/LXC status dots) sits at z-index 200, which is higher
than the AppShellHeader's default 100. This caused the status dots
to bleed above the header when scrolling.

Set the header z-index to 201 so it stacks above Indicator content
without conflicting with modal/popover layers (300+).

Closes #6194
2026-07-06 01:50:13 +02:00
Releases Homarr ba860aacaa chore(release): 1.69.1 [skip ci] 2026-07-04 09:02:40 +00:00
homarr-releases[bot] 161dafbdb8 chore(release): automatic release v1.69.1 2026-07-04 09:01:34 +00:00
Thomas a382c75968 fix: unblock query cache mutations in demo mode and add cache buster (#6176) 2026-07-04 10:47:09 +02:00
Releases Homarr 6f47a05cc2 chore(release): 1.69.0 [skip ci] 2026-07-03 18:08:44 +00:00
homarr-releases[bot] d8d5894b28 chore(release): automatic release v1.69.0 2026-07-03 18:07:40 +00:00
Releases Homarr 46ae756d15 chore(release): 1.68.1 [skip ci] 2026-06-30 12:02:44 +00:00
Thomas 8b63799f1b Merge pull request #6124 from homarr-labs/fix/source-maps-memory-regression
fix: make production source maps opt-in to restore v1.67.0 memory baseline
2026-06-30 14:01:42 +02:00
ajnart 60a69e31ef fix: revert production source maps to restore v1.67.0 memory baseline
CodeRabbit review addressed:
- Removed the env-var gating (simpler: just remove source maps entirely)
- CI health check now fails fast if container never becomes healthy

Changes vs origin/main:
- next.config.ts: remove productionBrowserSourceMaps and serverSourceMaps
  (they default to false, restoring pre-v1.68.0 behavior)
- Dockerfile: remove .map copy step and NODE_OPTIONS=--enable-source-maps
- CI: add memory-regression job (<700MB guardrail)

Closes #6123
2026-06-29 15:13:08 +02:00
ajnart 57ef0af4a2 fix: make source maps opt-in to restore v1.67.0 memory baseline
Production source maps (--enable-source-maps) caused a ~50% memory increase
(600MB to 1000MB) in v1.68.0. Node.js loads all .map files into memory when
this flag is enabled, and Next.js server chunks generate hundreds of map files.

Changes:
- Guard productionBrowserSourceMaps and serverSourceMaps behind
  HOMARR_ENABLE_SOURCE_MAPS env var (default off)
- Guard .map copy step and NODE_OPTIONS in Dockerfile behind
  ENABLE_SOURCE_MAPS build arg (default false)
- run.sh checks HOMARR_ENABLE_SOURCE_MAPS before setting --enable-source-maps
- Add Docker-based memory regression CI job (<700MB threshold)

Closes #6123
2026-06-29 10:08:52 +02:00
Thomas 597b5fbc8d Merge pull request #6097 from homarr-labs/docs/update-helm-docs
Update Helm chart documentation
2026-06-27 13:24:44 +02:00
github-actions[bot] 5412577519 docs(helm): Update Helm chart documentation 2026-06-27 11:22:16 +00:00
19 changed files with 117 additions and 353 deletions

No files matched your search

+12
View File
@@ -1,3 +1,15 @@
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
### Bug Fixes
* **layout:** raise AppShellHeader z-index above Indicator dots ([687edb9](https://github.com/homarr-labs/homarr/commit/687edb9226307cb83986097abb24fe7f672e1778)), closes [#6194](https://github.com/homarr-labs/homarr/issues/6194)
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
### Bug Fixes
* unblock query cache mutations in demo mode and add cache buster ([#6176](https://github.com/homarr-labs/homarr/issues/6176)) ([a382c75](https://github.com/homarr-labs/homarr/commit/a382c75968e73bafff55b5890b1e95f0779b50d3))
## [1.69.0](https://github.com/homarr-labs/homarr/compare/v1.68.1...v1.69.0) (2026-07-03)
### Features
+1 -13
View File
@@ -70,16 +70,4 @@ API key authentication enables programmatic access for:
- Automation scripts and CI/CD pipelines
- Third-party integrations
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
- Create, duplicate, rename and delete boards
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
- Container orchestration systems that need to sync app definitions
+1 -1
View File
@@ -1 +1 @@
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.1.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.0.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
@@ -24,6 +24,7 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
isPersistableWidgetQueryKey,
queryCacheBuster,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
@@ -136,6 +137,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
client={queryClient}
persistOptions={{
persister,
buster: queryCacheBuster,
maxAge: queryCacheDefaultGcTimeMs,
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
@@ -7,6 +7,8 @@ import "~/styles/gridstack.scss";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import superjson from "superjson";
import { queryCacheBuster } from "@homarr/api/query-cache";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
@@ -121,7 +123,8 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null;
const persisted = JSON.parse(serialized) as PersistedClient;
const persisted = superjson.parse<PersistedClient>(serialized);
if (persisted.buster !== queryCacheBuster) return null;
if (!persisted?.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />;
+1 -1
View File
@@ -18,7 +18,7 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
<AppShellHeader maw="100vw" style={{ overflowX: "hidden" }}>
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.69.0",
"version": "1.69.2",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
-2
View File
@@ -9,7 +9,6 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
@@ -29,7 +28,6 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
+1 -3
View File
@@ -6,7 +6,6 @@ import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
@@ -15,14 +14,13 @@ export const openApiRouter = createTRPCRouter({
boardRouter,
infoRouter,
inviteRouter,
serverSettingsRouter,
userRouter,
});
export const openApiDocument = (base: string) =>
generateOpenApiDocument(openApiRouter, {
title: "Homarr API documentation",
version: "1.1.0",
version: "1.0.0",
baseUrl: base,
docsUrl: "https://homarr.dev",
securitySchemes: {
+1
View File
@@ -5,6 +5,7 @@ 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";
let activeQueryCacheBoardId: string | null = null;
+31 -95
View File
@@ -330,16 +330,7 @@ export const boardRouter = createTRPCRouter({
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
openapi: { method: "POST", path: "/api/boards/{id}/duplicate", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
},
})
.input(boardDuplicateSchema)
.output(z.object({ boardId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await noBoardWithSimilarNameAsync(ctx.db, input.name);
@@ -553,57 +544,33 @@ export const boardRouter = createTRPCRouter({
});
},
});
return { boardId: newBoardId };
}),
renameBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/name", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Rename a board by ID. Requires full permission on the board. REQUIRED: id (board ID), name (new unique board name)",
},
})
.input(boardRenameSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/visibility", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Change board visibility. Requires full permission on the board. REQUIRED: id (board ID), visibility ('public' or 'private'). Home boards cannot be made private",
},
})
.input(boardChangeVisibilitySchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure.input(boardChangeVisibilitySchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure
.meta({
openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
@@ -620,38 +587,16 @@ export const boardRouter = createTRPCRouter({
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's desktop home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/mobile-home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's mobile home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
@@ -807,16 +752,7 @@ export const boardRouter = createTRPCRouter({
}
}),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Update visual and behavior settings for a board. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, and disableStatus",
},
})
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
.output(z.void())
.input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+3 -3
View File
@@ -5,7 +5,7 @@ import { boards } from "@homarr/db/schema";
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { createTRPCRouter, internalProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheBoardInput = z.object({
@@ -21,7 +21,7 @@ const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForb
};
export const queryCacheRouter = createTRPCRouter({
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
return {
@@ -34,7 +34,7 @@ export const queryCacheRouter = createTRPCRouter({
),
};
}),
removeItem: publicProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
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);
+1 -79
View File
@@ -1,14 +1,6 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
import { boards, serverSettings } from "@homarr/db/schema";
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -16,15 +8,6 @@ import { settingsInitSchema } from "@homarr/validation/settings";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
@@ -32,67 +15,6 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "GET", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
},
})
.input(z.void())
.output(boardServerSettingsSchema)
.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "board");
}),
updateBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "PATCH", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
.output(boardServerSettingsSchema)
.mutation(async ({ ctx, input }) => {
const inputBoardIds = [input.homeBoardId, input.mobileHomeBoardId].filter(
(id) => id !== undefined && id !== null,
);
if (inputBoardIds.length > 0) {
const publicBoards = await ctx.db.query.boards.findMany({
columns: { id: true },
where: and(inArray(boards.id, inputBoardIds), eq(boards.isPublic, true)),
});
const publicBoardIds = new Set(publicBoards.map((board) => board.id));
const invalidBoardIds = inputBoardIds.filter((id) => !publicBoardIds.has(id));
if (invalidBoardIds.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Board settings home board IDs must reference public boards: ${invalidBoardIds.join(", ")}`,
});
}
}
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(
@@ -1,9 +1,9 @@
import { stringify } from "superjson";
import SuperJSON from "superjson";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, serverSettings } from "@homarr/db/schema";
import { serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -33,7 +33,7 @@ describe("getAll server settings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: stringify(defaultServerSettings.analytics),
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
@@ -67,7 +67,7 @@ describe("saveSettings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: stringify(defaultServerSettings.analytics),
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
@@ -82,7 +82,7 @@ describe("saveSettings", () => {
expect(dbSettings).toStrictEqual([
{
settingKey: "analytics",
value: stringify({
value: SuperJSON.stringify({
enableGeneral: true,
instanceId: null,
}),
@@ -90,91 +90,3 @@ describe("saveSettings", () => {
]);
});
});
describe("board settings API", () => {
test("getBoardSettings should return board defaults", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
const result = await caller.updateBoardSettings({
homeBoardId: boardId,
forceDisableStatus: true,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
forceDisableStatus: true,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should update existing settings", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "private", isPublic: false });
const actAsync = async () => await caller.updateBoardSettings({ homeBoardId: boardId });
await expect(actAsync()).rejects.toThrow("must reference public boards");
});
});
-8
View File
@@ -2,20 +2,16 @@ import { expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { boardRouter } from "../router/board";
import { infoRouter } from "../router/info";
import { inviteRouter } from "../router/invite";
import { serverSettingsRouter } from "../router/serverSettings";
import { createTRPCRouter } from "../trpc";
vi.mock("@homarr/auth", () => ({}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
board: boardRouter,
info: infoRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
});
test("MCP tools should be extractable from the router", () => {
@@ -32,12 +28,8 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_all");
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
expect(toolNames).toContain("serverSettings_getBoardSettings");
expect(toolNames).toContain("serverSettings_updateBoardSettings");
});
test("MCP tools should have descriptions", () => {
-9
View File
@@ -14,12 +14,3 @@ test("OpenAPI documentation should be generated", () => {
// Assert
expect(act).not.toThrow();
});
test("OpenAPI documentation should expose board automation endpoints", () => {
const document = openApiDocument("https://homarr.dev");
expect(document.info.version).toBe("1.1.0");
expect(document.paths).toHaveProperty("/api/boards/{id}/settings");
expect(document.paths).toHaveProperty("/api/boards/{id}/duplicate");
expect(document.paths).toHaveProperty("/api/settings/board");
});
+2
View File
@@ -116,6 +116,8 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
*/
export const publicProcedure = baseProcedure;
export const internalProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -60,8 +60,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/connections"
| "/docs/tags/custom-widgets"
| "/docs/tags/customization"
| "/docs/tags/data"
| "/docs/tags/data-sources"
| "/docs/tags/data"
| "/docs/tags/database"
| "/docs/tags/developer"
| "/docs/tags/development"
@@ -99,8 +99,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/redis"
| "/docs/tags/responsive"
| "/docs/tags/roles"
| "/docs/tags/search"
| "/docs/tags/search-engines"
| "/docs/tags/search"
| "/docs/tags/secrets"
| "/docs/tags/security"
| "/docs/tags/self-signed"
@@ -132,6 +132,15 @@ export type HomarrDocumentationPath =
| "/docs/advanced/running-as-different-user"
| "/docs/advanced/single-sign-on"
| "/docs/advanced/styling"
| "/docs/category/advanced"
| "/docs/category/community"
| "/docs/category/developer-guides"
| "/docs/category/getting-started"
| "/docs/category/installation-1"
| "/docs/category/installation"
| "/docs/category/integrations"
| "/docs/category/management"
| "/docs/category/widgets"
| "/docs/community/donate"
| "/docs/community/faq"
| "/docs/community/get-in-touch"
@@ -196,11 +205,9 @@ export type HomarrDocumentationPath =
| "/docs/integrations/slskd"
| "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/synology"
| "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr"
| "/docs/integrations/traefik"
| "/docs/integrations/transmission"
| "/docs/integrations/truenas"
| "/docs/integrations/umami"
@@ -259,11 +266,9 @@ export type HomarrDocumentationPath =
| "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price"
| "/docs/widgets/system-disks"
| "/docs/widgets/system-resources"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/traefik"
| "/docs/widgets/umami"
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
@@ -6,12 +6,9 @@ interface Options<TData, TInput extends Record<string, unknown>> {
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
const MAX_CACHE_SIZE = 1000;
const cache = new Map<string, CacheEntry<unknown>>();
const inflight = new Map<string, Promise<CacheEntry<unknown>>>();
const DEFAULT_TTL_MS = 10_000;
const evictExpired = () => {
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);
@@ -20,41 +17,46 @@ const evictExpired = () => {
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => ({
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
) => {
const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
const cached = cache.get(key) as CacheEntry<TData> | undefined;
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
return {
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
const existing = inflight.get(key);
if (existing) return existing as Promise<CacheEntry<TData>>;
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
const promise = options
.requestAsync(input)
.then((data) => {
if (cache.size >= MAX_CACHE_SIZE) evictExpired();
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
cache.set(key, entry as CacheEntry<unknown>);
inflight.delete(key);
return entry;
})
.catch((err) => {
inflight.delete(key);
throw err;
});
const existing = inflight.get(key);
if (existing) return existing;
inflight.set(key, promise as Promise<CacheEntry<unknown>>);
return promise;
},
}),
});
const 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 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);
throw err;
});
inflight.set(key, promise);
return promise;
},
}),
};
};