Compare commits

..
Author SHA1 Message Date
ajnart 268c864801 fix: narrow PR to verified bug fixes 2026-08-22 10:52:15 +02:00
ajnart 3f9941f9c7 Merge remote-tracking branch 'origin/dev' into feat/pr-6510-polish 2026-08-22 10:47:15 +02:00
ajnart 6868e4b8d2 test(auth): assert complete membership set for invalid OIDC claim
Address CodeRabbit: query all memberships instead of findFirst so a
handler that preserves the existing row but adds a membership is caught.
2026-08-08 12:25:11 +02:00
ajnart cc4e11fce4 Merge remote-tracking branch 'origin/dev' into fix/three-xs-fixes
# Conflicts:
#	apps/nextjs/src/components/board/items/widget-context-menu.tsx
2026-08-08 12:07:10 +02:00
ajnart 71b986f249 fix: gate scrollbar resize guard on size, preserve parse causes
Address CodeRabbit review on #6510:
- gridstack: only suppress recalculation when the width delta is
  insignificant AND (non-dynamic OR height unchanged), so dynamic
  sections still rebuild on height changes
- beszel: propagate the response parse failure via { cause } in both
  error wrappers
- auth tests: cover invalid (non-string) OIDC groups claims
2026-08-08 11:35:42 +02:00
ajnart e0ebdd9047 test(auth): add regression test for single-string OIDC groups claim 2026-08-08 11:16:39 +02:00
Thomas Camlong b2a4dcad1c fix: revert ineffective Uptime-Kuma paused fix; harden Unraid CPU divisor
The Uptime-Kuma status-page API (toPublicJSON) does not emit a status
field on monitor objects, so the added status schema field and paused
early-return were dead code and did not resolve #6002. Paused monitors
stop producing heartbeats and the two consumed endpoints expose no
paused signal, so revert the change.

Unraid CPU: divide by cpus.length (the array actually being averaged)
instead of info.cpu.threads, which is more robust if the two ever
disagree, while keeping the SMT over-reporting fix.
2026-08-03 17:41:24 +02:00
ajnart 751dd751e0 style: auto-fix lint and format 2026-08-03 15:31:09 +00:00
Thomas Camlong 7dd7d9280a chore: remove leaked docs sitemap entry from this PR 2026-08-03 17:30:00 +02:00
Thomas Camlong ebd8e00119 fix: scope app right-click bypass to view mode only 2026-08-03 17:21:38 +02:00
ajnart c51dbca9bc style: auto-fix lint and format 2026-08-03 15:17:53 +00:00
Thomas Camlong 60b0f59fe7 chore: remove board hotkeys from this PR (owned by #6393) 2026-08-03 17:16:50 +02:00
Thomas Camlong bc71cb7f16 chore: remove ISSUES.md from PR 2026-08-03 17:13:40 +02:00
Thomas Camlong 6e9136c5a0 chore: remove ISSUES.md from PR, merge BoardHotkeys into hook file 2026-08-03 17:11:04 +02:00
Thomas Camlong ef9ead60ba fix: address CodeRabbit review — guard OIDC group types and CPU division 2026-08-03 17:09:05 +02:00
ajnart ac8e0b1abe style: auto-fix lint and format 2026-08-03 15:04:34 +00:00
Thomas Camlong ce6a637220 fix: right-click apps, board keyboard shortcuts, scrollbar oscillation, Beszel JSON errors
- #6416: Skip widget context menu for app widgets (pass through native right-click)
- #6392: Add board keyboard shortcuts (mod+shift+arrows to cycle, mod+shift+1-9 for direct)
- #6080: Prevent GridStack scrollbar oscillation by thresholding resize events
- #6024: Add try-catch for Beszel response.json() calls in auth and testing paths
2026-08-03 17:03:24 +02:00
ajnart afe5f2b6b4 style: auto-fix lint and format 2026-08-03 14:52:40 +00:00
Thomas Camlong 7de2fc04ec fix: correct Unraid CPU calculation, OIDC single-group sync, and Uptime-Kuma paused status
- **Unraid CPU calculation** (#6082): Use thread count instead of core count
  when normalizing CPU utilization. Each cpus[] entry is one thread, so
  dividing by physical cores (cores) over-reports on SMT systems.

- **OIDC single-group sync** (#5716): Normalize the groups claim to always
  be an array. Some providers (e.g. Jumpcloud) send a single group as a
  plain string instead of an array, which caused the Array.isArray check
  to skip group synchronization entirely.

- **Uptime-Kuma paused status** (#6002): Add the status field to the
  monitor schema and check it before falling back to heartbeat inference.
  When a monitor is paused, heartbeats stop arriving, so the latest
  heartbeat may still show 'up' from before pausing. The status page
  returns status=2 for paused monitors, which this change now respects.
2026-08-03 16:51:21 +02:00
6 changed files with 19 additions and 177 deletions

No files matched your search

-2
View File
@@ -1,5 +1,3 @@
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.76.1",
"version": "1.76.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -5,5 +5,4 @@ export {
createCustomCheckServerIdentity,
createHttpsAgentAsync,
fetchWithTrustedCertificatesAsync,
mergeHeadersWithUserAgent,
} from "./request";
@@ -18,14 +18,6 @@ import packageJson from "../../../../../package.json";
export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`;
export const mergeHeadersWithUserAgent = (headers: unknown): Headers => {
const mergedHeaders = new Headers(headers as HeadersInit);
if (!mergedHeaders.has("user-agent")) {
mergedHeaders.set("user-agent", getDefaultUserAgent());
}
return mergedHeaders;
};
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => {
@@ -85,18 +77,30 @@ export const fetchWithTrustedCertificatesAsync = async (
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
));
const buildFetchOptions = (fetchOptions: Omit<RequestInit, "dispatcher" | "bodyTimeout" | "timeout">) => ({
...fetchOptions,
headers: mergeHeadersWithUserAgent(fetchOptions.headers) as unknown as RequestInit["headers"],
});
if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) => fetch(url, { ...buildFetchOptions(fetchOptions), signal, dispatcher: agent }),
async (signal) =>
fetch(url, {
...fetchOptions,
headers: {
"User-Agent": getDefaultUserAgent(),
...fetchOptions.headers,
},
signal,
dispatcher: agent,
}),
options.timeout,
);
}
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
return fetch(url, { ...buildFetchOptions(fetchOptions), dispatcher: agent });
return fetch(url, {
...fetchOptions,
headers: {
"User-Agent": getDefaultUserAgent(),
...fetchOptions.headers,
},
dispatcher: agent,
});
};
@@ -1,120 +0,0 @@
// @vitest-environment node
import { createServer } from "node:http";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import type { RequestInit } from "undici";
import { describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync, mergeHeadersWithUserAgent } from "@homarr/core/infrastructure/http";
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn(async () => []),
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
}));
describe("mergeHeadersWithUserAgent", () => {
test("should keep existing headers when given a Headers instance", () => {
// Arrange
const headers = new Headers({ "x-api-key": "some-api-key", Accept: "application/json" });
// Act
const merged = mergeHeadersWithUserAgent(headers);
// Assert
expect(merged.get("x-api-key")).toBe("some-api-key");
expect(merged.get("accept")).toBe("application/json");
expect(merged.get("user-agent")).toContain("Homarr/");
});
test("should keep existing headers when given a record", () => {
// Arrange
const headers = { "x-api-key": "some-api-key", Accept: "application/json" };
// Act
const merged = mergeHeadersWithUserAgent(headers);
// Assert
expect(merged.get("x-api-key")).toBe("some-api-key");
expect(merged.get("accept")).toBe("application/json");
expect(merged.get("user-agent")).toContain("Homarr/");
});
test("should keep existing headers when given a tuple array", () => {
// Arrange
const headers: [string, string][] = [
["x-api-key", "some-api-key"],
["Accept", "application/json"],
];
// Act
const merged = mergeHeadersWithUserAgent(headers);
// Assert
expect(merged.get("x-api-key")).toBe("some-api-key");
expect(merged.get("accept")).toBe("application/json");
expect(merged.get("user-agent")).toContain("Homarr/");
});
test("should not override an existing user-agent in any casing", () => {
// Arrange
const headers = new Headers({ "USER-AGENT": "custom-agent" });
// Act
const merged = mergeHeadersWithUserAgent(headers);
// Assert
expect(merged.get("user-agent")).toBe("custom-agent");
});
test("should fall back to the default user agent when no headers are provided", () => {
// Act
const merged = mergeHeadersWithUserAgent(undefined);
// Assert
expect(merged.get("user-agent")).toContain("Homarr/");
});
});
const startCaptureServerAsync = async (): Promise<{
server: Server;
getReceivedHeaders: () => Record<string, string | string[] | undefined>;
port: number;
}> => {
let receivedHeaders: Record<string, string | string[] | undefined> = {};
const server = createServer((req, res) => {
receivedHeaders = req.headers;
res.writeHead(200, { "Content-Type": "application/json" });
res.end("{}");
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
return {
server,
port,
getReceivedHeaders: () => receivedHeaders,
};
};
describe("fetchWithTrustedCertificatesAsync", () => {
test("should deliver a Headers instance with all headers to the remote server", async () => {
// Arrange - the @immich/sdk hands over a Headers instance after merging its defaults
const { server, port, getReceivedHeaders } = await startCaptureServerAsync();
const headers = new Headers({ "x-api-key": "immich-api-key", Accept: "application/json" });
// Act
try {
const response = await fetchWithTrustedCertificatesAsync(`http://127.0.0.1:${port}/api/server/statistics`, {
headers: headers as unknown as RequestInit["headers"],
});
// Assert
expect(response.status).toBe(200);
expect(getReceivedHeaders()["x-api-key"]).toBe("immich-api-key");
expect(getReceivedHeaders()["user-agent"]).toContain("Homarr/");
} finally {
server.close();
}
});
});
@@ -1,39 +0,0 @@
// @vitest-environment node
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { getServerStatistics, init } from "@immich/sdk";
import { describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn(async () => []),
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
}));
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
test("x-api-key set via init() reaches the server", async () => {
let receivedXApiKey: string | string[] | undefined;
const server = createServer((req, res) => {
receivedXApiKey = req.headers["x-api-key"];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
try {
const stats = await getServerStatistics({
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
});
expect(receivedXApiKey).toBe("sdk-configured-key");
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
} finally {
server.close();
}
});
});