Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb58c60a3 | ||
|
|
5f84ce590b | ||
|
|
a0149d5894 | ||
|
|
8579fcbfc2 | ||
|
|
b6f3aba23e | ||
|
|
31e218295d | ||
|
|
aca3b5d861 |
No files matched your search
@@ -1,30 +1,27 @@
|
||||
---
|
||||
title: 'App'
|
||||
description: 'Embeds an app into the board.'
|
||||
title: "App"
|
||||
description: "Embeds an app into the board."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { appWidget } from '.';
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { appWidget } from ".";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={appWidget}
|
||||
categories={['Links', 'Apps']}
|
||||
/>
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import githubApp from './img/github.png';
|
||||
import jellyseerrApp from './img/jellyseerr-description-tooltip.png';
|
||||
import piHoleApp from './img/pi-hole-ping-icon.png';
|
||||
import githubApp from "./img/github.png";
|
||||
import jellyseerrApp from "./img/jellyseerr-description-tooltip.png";
|
||||
import piHoleApp from "./img/pi-hole-ping-icon.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
|
||||
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
|
||||
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
|
||||
@@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={appWidget.configuration} />
|
||||
@@ -1,29 +1,26 @@
|
||||
---
|
||||
title: 'Bookmarks'
|
||||
description: 'Displays multiple app links'
|
||||
title: "Bookmarks"
|
||||
description: "Displays multiple app links"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { bookmarksWidget } from '.';
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { bookmarksWidget } from ".";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={bookmarksWidget}
|
||||
categories={['Links', 'Apps']}
|
||||
/>
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget allows you to display multiple app links in a single widget.
|
||||
You can add links to your favorite applications, websites, or any other resources you want quick access to.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import gridIconsOnly from './img/grid-icons-only.png';
|
||||
import gridWithTitle from './img/grid-with-title.png';
|
||||
import verticalListAll from './img/vertical-list-all.png';
|
||||
import vericalListSimple from './img/vertical-list-simple.png';
|
||||
import gridIconsOnly from "./img/grid-icons-only.png";
|
||||
import gridWithTitle from "./img/grid-with-title.png";
|
||||
import verticalListAll from "./img/vertical-list-all.png";
|
||||
import vericalListSimple from "./img/vertical-list-simple.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png';
|
||||
|
||||
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
|
||||
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={bookmarksWidget.configuration} />
|
||||
@@ -14,6 +14,7 @@ import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
import { serializeMcpToolResult } from "../_serialize-result";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
|
||||
@@ -203,7 +204,7 @@ const mcpHandler = createMcpHandler(
|
||||
const input = args && Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await procedure(input);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serializeMcpToolResult } from "./_serialize-result";
|
||||
|
||||
describe("serializeMcpToolResult", () => {
|
||||
it("serializes objects as JSON strings", () => {
|
||||
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
|
||||
});
|
||||
|
||||
it("serializes primitive values", () => {
|
||||
expect(serializeMcpToolResult("hello")).toBe('"hello"');
|
||||
expect(serializeMcpToolResult(42)).toBe("42");
|
||||
expect(serializeMcpToolResult(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("serializes null as the string null", () => {
|
||||
expect(serializeMcpToolResult(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("serializes void results as the string null so MCP content text is always a string", () => {
|
||||
expect(serializeMcpToolResult(undefined)).toBe("null");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serializeMcpToolResult(result: unknown): string {
|
||||
return JSON.stringify(result) ?? "null";
|
||||
}
|
||||
@@ -44,9 +44,13 @@ interface WidgetContextMenuProps {
|
||||
|
||||
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
|
||||
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
|
||||
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
|
||||
if (isLinkWidget && !isEditMode) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||
|
||||
@@ -301,6 +301,8 @@ interface UseCssVariableConfiguration {
|
||||
isDynamic: boolean;
|
||||
}
|
||||
|
||||
const minimumResizeWidthDelta = 20;
|
||||
|
||||
/**
|
||||
* This hook is used to configure the css variables for the gridstack
|
||||
* Those css variables are used to define the size of the gridstack items
|
||||
@@ -319,9 +321,30 @@ const useCssVariableConfiguration = ({
|
||||
columnCount,
|
||||
isDynamic,
|
||||
}: UseCssVariableConfiguration) => {
|
||||
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!gridRef.current) return;
|
||||
|
||||
const currentWidth = wrapperRef.current.clientWidth;
|
||||
const currentHeight = wrapperRef.current.clientHeight;
|
||||
const prevSize = prevSizeRef.current;
|
||||
|
||||
// Only skip recalculation when the width change is insignificant
|
||||
// and, for dynamic sections, the height is unchanged. This prevents infinite
|
||||
// feedback loops when the vertical scrollbar appears/disappears, causing
|
||||
// clientWidth to oscillate by ~15px, while still reacting to height changes.
|
||||
if (
|
||||
prevSize !== null &&
|
||||
Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta &&
|
||||
(!isDynamic || currentHeight === prevSize.height)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevSizeRef.current = { width: currentWidth, height: currentHeight };
|
||||
|
||||
handleResizeChange(
|
||||
wrapperRef.current,
|
||||
gridRef.current,
|
||||
|
||||
@@ -5,4 +5,5 @@ export {
|
||||
createCustomCheckServerIdentity,
|
||||
createHttpsAgentAsync,
|
||||
fetchWithTrustedCertificatesAsync,
|
||||
mergeHeadersWithUserAgent,
|
||||
} from "./request";
|
||||
@@ -18,6 +18,14 @@ 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 => {
|
||||
@@ -77,30 +85,18 @@ 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, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
async (signal) => fetch(url, { ...buildFetchOptions(fetchOptions), signal, dispatcher: agent }),
|
||||
options.timeout,
|
||||
);
|
||||
}
|
||||
|
||||
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
dispatcher: agent,
|
||||
});
|
||||
return fetch(url, { ...buildFetchOptions(fetchOptions), dispatcher: agent });
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
// @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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,16 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
const systemInfo = await this.getSystemInformationAsync();
|
||||
|
||||
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
|
||||
const cpuCount = systemInfo.info.cpu.cores;
|
||||
const cpuCount = systemInfo.metrics.cpu.cpus.length;
|
||||
|
||||
// Each entry in cpus[] is one logical CPU (thread). Dividing the summed per-thread
|
||||
// utilization by the thread count (not physical cores) gives the correct average and
|
||||
// avoids over-reporting on SMT systems. Guard against zero to avoid Infinity/NaN.
|
||||
let cpuUtilizationNormalized = 0;
|
||||
if (cpuCount > 0) {
|
||||
cpuUtilizationNormalized = cpuUtilization / cpuCount;
|
||||
}
|
||||
|
||||
// We use "info" object instead of the stats since this is the exact amount the kernel sees, which is what Unraid displays.
|
||||
const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0);
|
||||
const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);
|
||||
const uptime = dayjs(systemInfo.info.os.uptime);
|
||||
@@ -47,7 +54,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
return {
|
||||
version: systemInfo.info.os.release,
|
||||
cpuModelName: systemInfo.info.cpu.brand,
|
||||
cpuUtilization: cpuUtilization / cpuCount,
|
||||
cpuUtilization: cpuUtilizationNormalized,
|
||||
memUsedInBytes: usedMemory,
|
||||
memAvailableInBytes: totalMemory - usedMemory,
|
||||
uptime: dayjs().diff(uptime, "seconds"),
|
||||
@@ -139,7 +146,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
},
|
||||
memory {
|
||||
layout {
|
||||
size
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user