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
9 changed files with 66 additions and 44 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) ## [1.69.0](https://github.com/homarr-labs/homarr/compare/v1.68.1...v1.69.0) (2026-07-03)
### Features ### Features
@@ -24,6 +24,7 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import {
isPersistableWidgetQueryKey, isPersistableWidgetQueryKey,
queryCacheBuster,
queryCacheDefaultGcTimeMs, queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs, queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs, queryCacheDefaultStaleTimeMs,
@@ -136,6 +137,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
client={queryClient} client={queryClient}
persistOptions={{ persistOptions={{
persister, persister,
buster: queryCacheBuster,
maxAge: queryCacheDefaultGcTimeMs, maxAge: queryCacheDefaultGcTimeMs,
dehydrateOptions: { dehydrateOptions: {
shouldDehydrateQuery: (query) => shouldDehydrateQuery: (query) =>
@@ -7,6 +7,8 @@ import "~/styles/gridstack.scss";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client"; 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 { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
@@ -121,7 +123,8 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
const serialized = await getQueryCacheAsync(userId, boardId); const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null; 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; if (!persisted?.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />; return <HydrationBoundary state={persisted.clientState} />;
+1 -1
View File
@@ -18,7 +18,7 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return ( 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%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap"> <Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />} {hasNavigation && <ClientBurger />}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.69.0", "version": "1.69.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
+1
View File
@@ -5,6 +5,7 @@ export const queryCacheDefaultRefetchIntervalMs = 30_000;
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24; export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
export const queryCacheMaxValueBytes = 1024 * 1024; export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query"; export const queryCacheStoragePrefix = "homarr-widget-query";
export const queryCacheBuster = "v2-superjson";
let activeQueryCacheBoardId: string | null = null; let activeQueryCacheBoardId: string | null = null;
+3 -3
View File
@@ -5,7 +5,7 @@ import { boards } from "@homarr/db/schema";
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis"; import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache"; import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, internalProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access"; import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheBoardInput = z.object({ const queryCacheBoardInput = z.object({
@@ -21,7 +21,7 @@ const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForb
}; };
export const queryCacheRouter = createTRPCRouter({ 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); await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous"; const userId = ctx.session?.user.id ?? "anonymous";
return { 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); await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous"; const userId = ctx.session?.user.id ?? "anonymous";
await removeQueryCacheAsync(userId, input.boardId); await removeQueryCacheAsync(userId, input.boardId);
+2
View File
@@ -116,6 +116,8 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
*/ */
export const publicProcedure = baseProcedure; export const publicProcedure = baseProcedure;
export const internalProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -6,12 +6,9 @@ interface Options<TData, TInput extends Record<string, unknown>> {
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number }; type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
const MAX_CACHE_SIZE = 1000; 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 DEFAULT_TTL_MS = 10_000;
const evictExpired = () => { const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
const now = Date.now(); const now = Date.now();
for (const [key, entry] of cache) { for (const [key, entry] of cache) {
if (now >= entry.expiresAt) cache.delete(key); if (now >= entry.expiresAt) cache.delete(key);
@@ -20,41 +17,46 @@ const evictExpired = () => {
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>( export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>, options: Options<TData, TInput>,
) => ({ ) => {
handler: (input: TInput) => ({ const cache = new Map<string, CacheEntry<TData>>();
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { const inflight = new Map<string, Promise<CacheEntry<TData>>>();
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
const cached = cache.get(key) as CacheEntry<TData> | undefined; return {
if (cached && Date.now() < cached.expiresAt) { handler: (input: TInput) => ({
return { data: cached.data, timestamp: cached.timestamp }; async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
} const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
if (cached) cache.delete(key); const key = JSON.stringify(input);
const existing = inflight.get(key); const cached = cache.get(key);
if (existing) return existing as Promise<CacheEntry<TData>>; if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
const promise = options const existing = inflight.get(key);
.requestAsync(input) if (existing) return existing;
.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;
});
inflight.set(key, promise as Promise<CacheEntry<unknown>>); const promise = options
return promise; .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;
},
}),
};
};