chore(release): automatic release v2.2.0

This commit is contained in:
homarr-releases[bot] authored and GitHub committed 2026-10-05 12:58:15 +00:00
commit 1980046b45
83 files changed
+127609 -47208

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 2.1.2
- 2.0.0
- 1.77.0
- 1.76.2
+2
View File
@@ -23,6 +23,8 @@ Icons in Homarr are automatically requested from multiple sources:
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
GitHub icon indexes are refreshed on startup and weekly. A GitHub rate limit can temporarily prevent Dashboard Icons, selfh.st, and Simple Icons from refreshing; previously cached icons remain available. Invalid or incomplete GitHub tree responses also leave the cached provider icons intact. The server log reports the HTTP status and, when GitHub provides them, remaining request quota and reset time (Unix seconds). After the limit resets, an administrator can rerun **Icons Updater** under **Manage → Tools → Tasks**. Direct image URLs and local uploads remain usable while indexing is unavailable.
### Finding Missing Icons
If you can't find the icon you're looking for in Homarr's built-in picker, we strongly recommend checking [Dashboard Icons](https://dashboardicons.com/). It offers a modern, user-friendly interface and a curated collection of high-quality icons specifically designed for dashboards and app directories.
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.30.0](https://img.shields.io/badge/Version-8.30.0-informational?style=flat)
![Version: 8.31.1](https://img.shields.io/badge/Version-8.31.1-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v2.0.0](https://img.shields.io/badge/AppVersion-v2.0.0-informational?style=flat)
![AppVersion: v2.1.2](https://img.shields.io/badge/AppVersion-v2.1.2-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v2.0.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v2.1.2"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
@@ -25,6 +25,8 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
CPU usage is averaged across logical processors, including SMT threads.
Storage includes array data disks and storage pools, including pool-only setups. Pool capacity is shown for the member reporting filesystem statistics; members with missing statistics and zero-size filesystems are omitted. Parity and boot devices are excluded.
### Adding the integration
<AddingIntegration />
+14
View File
@@ -54,6 +54,20 @@ on every layout, and caps width to the available columns.
`board.updateItemLayout` (`PATCH /api/boards/{boardId}/items/{itemId}/layouts/{layoutId}`) moves or resizes one item in
one layout without replacing the board. It cannot move the item outside its section or overlap another item/container.
## Root CA automation
Administrators can inspect a named CA with `GET /api/certificates/{fileName}` (`certificates.getCertificate`) or
create it with `POST /api/certificates/root` (`certificates.ensureRootCertificate`), using JSON `{ fileName, certificate }`.
Names are limited to 128 characters, start with a letter or digit, contain only letters, digits, dots, underscores and
hyphens, and end in `.crt` or `.pem`. Supply one currently valid self-signed PEM root CA, at most 64 KiB, without a chain
or private key.
Inspection returns public metadata or `null` when absent. Creation returns `{ created, certificate }`; identical PEM
bytes return `created: false`, while different existing bytes or unsafe file paths return `CONFLICT` without replacement.
`sha256` hashes the exact UTF-8 PEM bytes, including whitespace; `fingerprint256` identifies the DER certificate.
Dates are ISO 8601. Certificate contents are never returned. Expired stored roots remain inspectable. Trust applies to
subsequent integration requests without a restart; it does not bypass hostname verification.
## Docker targets
Docker actions require `{ endpointId, id }` targets from `docker.getContainers`; `endpointId` distinguishes hosts.
@@ -1,6 +1,6 @@
---
title: "Beszel System Stats"
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
description: "Time-series charts for CPU, memory, disk, network, GPU, and Docker container metrics from Beszel."
hide_title: true
---
@@ -17,7 +17,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage and Disk I/O charts combine the root disk and every extra filesystem reported by Beszel, with separate read and write series for each filesystem in the I/O chart. Use the widget controls to switch between the realtime Live view and historical periods ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, GPU, and Docker container metrics. GPU utilization is shown for every GPU reported by Beszel; used VRAM and power draw are shown when Beszel reports those metrics. The Disk Usage and Disk I/O charts combine the root disk and every extra filesystem reported by Beszel, with separate read and write series for each filesystem in the I/O chart. Use the widget controls to switch between the realtime Live view and historical periods ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -4,7 +4,7 @@ import { IconChartAreaLine } from "@tabler/icons-react";
export const beszelSystemStatsWidget: WidgetDefinition = {
icon: IconChartAreaLine,
name: "Beszel System Stats",
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.",
description: "Time-series charts for CPU, memory, disk, network, GPU, and Docker container metrics from Beszel.",
path: "../../widgets/beszel-system-stats",
configuration: {
items: [
@@ -54,6 +54,24 @@ export const beszelSystemStatsWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show GPU usage",
description: "Displays GPU utilization for each GPU reported by Beszel",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show GPU memory",
description: "Displays used VRAM for each GPU when reported by Beszel, in standard storage units",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show GPU power",
description: "Displays power draw for each GPU when reported by Beszel",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show Docker CPU",
description: "Displays Docker container CPU usage chart",
+14
View File
@@ -98,6 +98,20 @@ export const downloadsWidget: WidgetDefinition = {
values: "Any whole number from 1 upward",
defaultValue: "50",
},
{
name: "Include archived SABnzbd history",
description:
"When enabled, the selected day window applies to finished normal and archived SABnzbd history. Queue and processing jobs remain visible.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Archived history window (days)",
description:
"At most 10,000 archived records are scanned. The item limit still controls how many rows are shown.",
values: "Any positive whole number of days",
defaultValue: "7",
},
],
},
};
+267
View File
@@ -5545,6 +5545,273 @@
}
}
},
"/api/certificates/{fileName}": {
"get": {
"operationId": "certificates-getCertificate",
"summary": "Inspect a named custom root CA certificate",
"description": "Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.",
"tags": [
"certificates"
],
"security": [
{
"apikey": []
}
],
"parameters": [
{
"in": "path",
"name": "fileName",
"schema": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"required": true
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"fileName": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"sha256": {
"type": "string"
},
"fingerprint256": {
"type": "string"
},
"ca": {
"type": "boolean",
"const": true
},
"validFrom": {
"type": "string"
},
"validTo": {
"type": "string"
}
},
"required": [
"fileName",
"sha256",
"fingerprint256",
"ca",
"validFrom",
"validTo"
],
"additionalProperties": false
},
{
"type": "null"
}
]
}
}
}
},
"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/certificates/root": {
"post": {
"operationId": "certificates-ensureRootCertificate",
"summary": "Ensure a named custom root CA certificate without overwriting",
"description": "Accept one currently valid self-signed PEM root CA (64 KiB maximum). Identical bytes are idempotent; different existing bytes return CONFLICT. Requires admin permission.",
"tags": [
"certificates"
],
"security": [
{
"apikey": []
}
],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fileName": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"certificate": {
"type": "string",
"minLength": 1,
"maxLength": 65536
}
},
"required": [
"fileName",
"certificate"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"created": {
"type": "boolean"
},
"certificate": {
"type": "object",
"properties": {
"fileName": {
"type": "string",
"maxLength": 128,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
},
"sha256": {
"type": "string"
},
"fingerprint256": {
"type": "string"
},
"ca": {
"type": "boolean",
"const": true
},
"validFrom": {
"type": "string"
},
"validTo": {
"type": "string"
}
},
"required": [
"fileName",
"sha256",
"fingerprint256",
"ca",
"validFrom",
"validTo"
],
"additionalProperties": false
}
},
"required": [
"created",
"certificate"
],
"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/integrations/request": {
"post": {
"operationId": "integration-request",
@@ -34,6 +34,7 @@ import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { useSession } from "@homarr/auth/client";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { DemoReadOnlyProvider } from "@homarr/widgets/demo-read-only";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { useAuthContext } from "./session";
@@ -66,7 +67,7 @@ const constructWebsocketUrl = () => {
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
};
export function TRPCReactProvider({ children }: PropsWithChildren) {
export function TRPCReactProvider({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) {
const { data: session } = useSession();
const { logoutRedirectInProgress } = useAuthContext();
const sessionQueryScope = getSessionQueryScope(session);
@@ -81,7 +82,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
currentScope={sessionQueryScope}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider demoReadOnly={demoReadOnly}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
@@ -102,7 +103,7 @@ const clearLegacyDashboardPersistence = () => {
}
};
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
const ScopedTRPCReactProvider = ({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) => {
useEffect(clearLegacyDashboardPersistence, []);
const wsClient = useMemo(
() =>
@@ -150,21 +151,26 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
},
});
client.setQueryDefaults([["widget"]], {
refetchInterval: queryCacheDefaultRefetchIntervalMs,
refetchInterval: demoReadOnly ? false : queryCacheDefaultRefetchIntervalMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
for (const queryDefaults of widgetQueryRefetchIntervals) {
const policy: { refetchInterval?: number | false; staleTime?: number } = {};
if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
if (typeof queryDefaults.intervalSeconds === "number") {
if (demoReadOnly || queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
else if (typeof queryDefaults.intervalSeconds === "number") {
policy.refetchInterval = queryDefaults.intervalSeconds * 1000;
}
if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000;
client.setQueryDefaults(queryDefaults.queryKey, policy);
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
client.setQueryDefaults(
queryKey,
demoReadOnly
? { ...policy, refetchInterval: false, refetchOnWindowFocus: false, refetchOnReconnect: false }
: policy,
);
}
if (typeof window === "undefined") {
client.getQueryCache().subscribe((event) => {
@@ -239,7 +245,7 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
},
}}
>
{children}
<DemoReadOnlyProvider value={demoReadOnly}>{children}</DemoReadOnlyProvider>
</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</QueryClientProvider>
+1 -1
View File
@@ -167,7 +167,7 @@ export default async function Layout(props: {
/>
),
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} />,
(innerProps) => (
+5
View File
@@ -4,6 +4,7 @@ import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { ensureRootCertificateProcedure, getCertificateProcedure } from "./router/certificates/certificate-router";
import { integrationRequestProcedure } from "./router/integration/integration-request";
import { integrationTestStoredConnectionProcedure } from "./router/integration/integration-test-stored-connection";
import { infoRouter } from "./router/info";
@@ -21,6 +22,10 @@ export const openApiRouter = createTRPCRouter({
inviteRouter,
serverSettingsRouter,
userRouter,
certificates: createTRPCRouter({
getCertificate: getCertificateProcedure,
ensureRootCertificate: ensureRootCertificateProcedure,
}),
integration: createTRPCRouter({
request: integrationRequestProcedure,
testConnection: integrationTestStoredConnectionProcedure,
@@ -5,18 +5,83 @@ import { z } from "zod/v4";
import {
addCustomRootCertificateAsync,
ensureCustomRootCertificateAsync,
getCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
RootCertificateError,
} from "@homarr/core/infrastructure/certificates";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { trustedCertificateHostnames } from "@homarr/db/schema";
import { certificateValidFileNameSchema, checkCertificateFile } from "@homarr/validation/certificates";
import {
certificateValidFileNameSchema,
checkCertificateFile,
rootCertificateFileNameSchema,
rootCertificateMetadataSchema,
} from "@homarr/validation/certificates";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
const logger = createLogger({ module: "certificateRouter" });
const withCertificateErrorsAsync = async <T>(operation: () => Promise<T>) => {
try {
return await operation();
} catch (error) {
if (error instanceof RootCertificateError) {
throw new TRPCError({ code: error.code, message: error.message });
}
throw error;
}
};
export const getCertificateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: {
method: "GET",
path: "/api/certificates/{fileName}",
tags: ["certificates"],
protect: true,
summary: "Inspect a named custom root CA certificate",
description:
"Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.",
},
})
.input(z.object({ fileName: rootCertificateFileNameSchema }))
.output(rootCertificateMetadataSchema.nullable())
.query(({ input }) => withCertificateErrorsAsync(() => getCustomRootCertificateAsync(input.fileName)));
export const ensureRootCertificateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: {
method: "POST",
path: "/api/certificates/root",
tags: ["certificates"],
protect: true,
summary: "Ensure a named custom root CA certificate without overwriting",
description:
"Accept one currently valid self-signed PEM root CA (64 KiB maximum). Identical bytes are idempotent; different existing bytes return CONFLICT. Requires admin permission.",
},
})
.input(
z.object({
fileName: rootCertificateFileNameSchema,
certificate: z
.string()
.min(1)
.max(64 * 1024),
}),
)
.output(z.object({ created: z.boolean(), certificate: rootCertificateMetadataSchema }))
.mutation(({ input }) =>
withCertificateErrorsAsync(() => ensureCustomRootCertificateAsync(input.fileName, input.certificate)),
);
export const certificateRouter = createTRPCRouter({
getCertificate: getCertificateProcedure,
ensureRootCertificate: ensureRootCertificateProcedure,
addCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(
+13 -3
View File
@@ -52,16 +52,26 @@ export const downloadsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50). For SABnzbd, includeArchivedHistory includes archived jobs when true and defaults to false; historyWindowDays is a positive whole number of days and defaults to 7. Archived history is scanned in pages of 100, up to 100 pages.",
},
})
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.input(
z.object({
limitPerIntegration: z.number().default(50),
includeArchivedHistory: z.boolean().default(false),
historyWindowDays: z.number().int().min(1).default(7),
}),
)
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
ctx.integrations,
async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const innerHandler = downloadClientRequestHandler.handler(integration, {
limit: input.limitPerIntegration,
includeArchivedHistory: input.includeArchivedHistory,
historyWindowDays: input.historyWindowDays,
});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { rootCertificates } from "node:tls";
const getCertificateFolder = () => {
export const getCertificateFolder = () => {
if (process.env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
};
@@ -0,0 +1,170 @@
import { createHash, randomUUID, X509Certificate } from "node:crypto";
import { constants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { createLogger } from "../../logs";
import { getCertificateFolder } from "./index";
const logger = createLogger({ module: "rootCertificate" });
const maxCertificateBytes = 64 * 1024;
const fileNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/;
const pemPattern = /^\s*-----BEGIN CERTIFICATE-----\s+([A-Za-z0-9+/=][A-Za-z0-9+/=\s]*)-----END CERTIFICATE-----\s*$/;
export class RootCertificateError extends Error {
constructor(
public readonly code: "BAD_REQUEST" | "CONFLICT",
message: string,
) {
super(message);
}
}
const validateFileName = (fileName: string) => {
if (fileName.length > 128 || !fileNamePattern.test(fileName)) {
throw new RootCertificateError("BAD_REQUEST", "Invalid certificate file name");
}
};
const describeCertificate = (fileName: string, content: Buffer, requireCurrent: boolean) => {
if (content.length > maxCertificateBytes) {
throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB");
}
const pem = pemPattern.exec(content.toString("utf8"));
if (!pem?.[1]) {
throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB");
}
let certificate: X509Certificate;
try {
certificate = new X509Certificate(content);
// Reject extra DER data as well as PEM chains, keys, leaf certificates and non-root CAs.
const der = Buffer.from(pem[1].replace(/\s/g, ""), "base64");
if (
!certificate.raw.equals(der) ||
!certificate.ca ||
!certificate.checkIssued(certificate) ||
!certificate.verify(certificate.publicKey)
) {
throw new Error("Expected a self-signed root CA certificate");
}
} catch {
throw new RootCertificateError("BAD_REQUEST", "Expected a self-signed root CA certificate");
}
const validFrom = new Date(certificate.validFrom);
const validTo = new Date(certificate.validTo);
const now = Date.now();
if (requireCurrent && (validFrom.getTime() > now || validTo.getTime() <= now)) {
throw new RootCertificateError("BAD_REQUEST", "Root CA certificate is not currently valid");
}
return {
fileName,
sha256: createHash("sha256").update(content).digest("hex"),
fingerprint256: certificate.fingerprint256,
ca: true as const,
validFrom: validFrom.toISOString(),
validTo: validTo.toISOString(),
};
};
const hasCode = (error: unknown, code: string) => {
return error instanceof Error && "code" in error && error.code === code;
};
const readCertificateAsync = async (fullPath: string) => {
let file;
try {
// NONBLOCK prevents opening a FIFO from hanging before the regular-file check.
file = await fs.open(fullPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if (hasCode(error, "ENOENT")) return null;
if (hasCode(error, "ELOOP") || hasCode(error, "ENXIO")) {
throw new RootCertificateError("CONFLICT", "Certificate path is not a regular file");
}
throw error;
}
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > maxCertificateBytes) {
throw new RootCertificateError("CONFLICT", "Certificate path is not a bounded regular file");
}
const content = Buffer.alloc(maxCertificateBytes + 1);
let size = 0;
while (size < content.length) {
const { bytesRead } = await file.read(content, size, content.length - size, size);
if (bytesRead === 0) break;
size += bytesRead;
}
if (size > maxCertificateBytes) {
throw new RootCertificateError("CONFLICT", "Certificate file exceeds 64 KiB");
}
return content.subarray(0, size);
} finally {
await file.close();
}
};
export const getCustomRootCertificateAsync = async (fileName: string) => {
validateFileName(fileName);
const folder = getCertificateFolder();
if (!folder) return null;
const content = await readCertificateAsync(path.join(folder, fileName));
if (!content) return null;
return describeCertificate(fileName, content, false);
};
export const ensureCustomRootCertificateAsync = async (fileName: string, pem: string) => {
validateFileName(fileName);
const content = Buffer.from(pem, "utf8");
const certificate = describeCertificate(fileName, content, true);
const folder = getCertificateFolder();
if (!folder) {
throw new Error("Set LOCAL_CERTIFICATE_PATH to an absolute path to manage local certificates");
}
await fs.mkdir(folder, { recursive: true });
const fullPath = path.join(folder, fileName);
const existing = await readCertificateAsync(fullPath);
if (existing) {
if (!existing.equals(content)) {
throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name");
}
return { created: false, certificate };
}
// Publish a complete file atomically without replacing an existing directory entry.
const temporaryPath = path.join(folder, `.root-certificate-${randomUUID()}.tmp`);
const temporaryFile = await fs.open(
temporaryPath,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o644,
);
try {
try {
await temporaryFile.writeFile(content);
await temporaryFile.sync();
} finally {
await temporaryFile.close();
}
try {
await fs.link(temporaryPath, fullPath);
} catch (error) {
if (!hasCode(error, "EEXIST")) throw error;
const concurrent = await readCertificateAsync(fullPath);
if (!concurrent?.equals(content)) {
throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name");
}
return { created: false, certificate };
}
return { created: true, certificate };
} finally {
// Cleanup must not turn a published certificate into a failed operation.
try {
await fs.unlink(temporaryPath);
} catch (error) {
logger.warn("Failed to remove temporary root certificate file", { error });
}
}
};
@@ -1,4 +1,9 @@
export { getTrustedCertificateHostnamesAsync } from "./hostnames";
export {
ensureCustomRootCertificateAsync,
getCustomRootCertificateAsync,
RootCertificateError,
} from "./files/root-certificate";
export {
addCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
@@ -2,6 +2,7 @@ import { parse } from "path";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group";
@@ -26,7 +27,26 @@ export class GitHubIconRepository extends IconRepository {
}
const response = await withTimeoutAsync(async (signal) => fetchWithTrustedCertificatesAsync(url, { signal }));
const listOfFiles = (await response.json()) as GitHubApiResponse;
if (!response.ok) {
await response.body?.cancel();
throw new ErrorWithMetadata("GitHub icon index request failed", {
status: response.status,
rateLimitRemaining: response.headers.get("x-ratelimit-remaining"),
rateLimitReset: response.headers.get("x-ratelimit-reset"),
retryAfter: response.headers.get("retry-after"),
});
}
const listOfFiles = (await response.json()) as GitHubApiResponse | null;
if (!listOfFiles || !Array.isArray(listOfFiles.tree)) {
throw new Error("GitHub icon index response does not contain a file tree");
}
if (typeof listOfFiles.truncated !== "boolean") {
throw new Error("GitHub icon index response does not contain a boolean truncated flag");
}
if (listOfFiles.truncated) {
throw new Error("GitHub icon index response contains a truncated file tree");
}
return {
success: true,
@@ -194,9 +194,9 @@ export interface BeszelExtraFsStats {
export interface BeszelGPUData {
/** GPU name/model */
n: string;
/** memory used (bytes) */
/** memory used (MB) */
mu?: number;
/** memory total (bytes) */
/** memory total (MB) */
mt?: number;
/** GPU utilization (%) */
u: number;
@@ -0,0 +1,49 @@
import type { SabnzbdHistorySlot } from "./sabnzbd-schema";
const SECONDS_PER_DAY = 24 * 60 * 60;
const ARCHIVED_HISTORY_PAGE_SIZE = 100;
const MAX_ARCHIVED_HISTORY_PAGES = 100;
interface GetSabnzbdHistorySlotsInput {
activeSlots: SabnzbdHistorySlot[];
historyWindowDays: number;
fetchPageAsync: (input: { start: number; limit: number }) => Promise<SabnzbdHistorySlot[]>;
now?: number;
}
const isProcessingSlot = (slot: SabnzbdHistorySlot) => slot.status !== "Completed" && slot.status !== "Failed";
export async function getSabnzbdHistorySlotsAsync({
activeSlots,
historyWindowDays,
fetchPageAsync,
now = Date.now(),
}: GetSabnzbdHistorySlotsInput): Promise<SabnzbdHistorySlot[]> {
const cutoff = Math.floor(now / 1000) - historyWindowDays * SECONDS_PER_DAY;
const archivedSlots: SabnzbdHistorySlot[] = [];
for (let page = 0; page < MAX_ARCHIVED_HISTORY_PAGES; page += 1) {
const slots = await fetchPageAsync({
start: page * ARCHIVED_HISTORY_PAGE_SIZE,
limit: ARCHIVED_HISTORY_PAGE_SIZE,
});
archivedSlots.push(...slots);
if (slots.length < ARCHIVED_HISTORY_PAGE_SIZE || slots.every((slot) => slot.completed < cutoff)) {
break;
}
}
const seenIds = new Set<string>();
return [
...activeSlots.filter(isProcessingSlot),
...[...activeSlots, ...archivedSlots]
.filter((slot) => !isProcessingSlot(slot) && slot.completed >= cutoff)
.toSorted((first, second) => second.completed - first.completed),
].filter((slot) => {
if (seenIds.has(slot.nzo_id)) return false;
seenIds.add(slot.nzo_id);
return true;
});
}
@@ -12,6 +12,7 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
import { getSabnzbdHistorySlotsAsync } from "./sabnzbd-history";
import { historySchema, queueSchema } from "./sabnzbd-schema";
dayjs.extend(duration);
@@ -23,7 +24,11 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
return { success: true };
}
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
public async getClientJobsAndStatusAsync(input: {
limit: number;
includeArchivedHistory?: boolean;
historyWindowDays?: number;
}): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const [queueResult, historyResult] = await Promise.all([
this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }).then((result) =>
@@ -35,6 +40,24 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
]);
const { queue } = queueResult;
const { history } = historyResult;
let historySlots = history.slots;
if (input.includeArchivedHistory) {
historySlots = await getSabnzbdHistorySlotsAsync({
activeSlots: history.slots,
historyWindowDays: input.historyWindowDays ?? 7,
fetchPageAsync: async ({ start, limit }) => {
const { history: archivedHistory } = await historySchema.parseAsync(
await this.sabNzbApiCallAsync("history", {
archive: "1",
start: start.toString(),
limit: limit.toString(),
}),
);
return archivedHistory.slots;
},
});
}
const status: DownloadClientStatus = {
paused: queue.paused,
// SABnzbd's legacy kbpersec field is kibibytes per second; normalize it to bytes per second.
@@ -68,7 +91,7 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
};
})
.concat(
history.slots.map((slot, index): DownloadClientItem => {
historySlots.map((slot, index): DownloadClientItem => {
const state = SabnzbdIntegration.getUsenetHistoryState(slot.status);
return {
type,
@@ -35,3 +35,5 @@ export const historySchema = z.object({
),
}),
});
export type SabnzbdHistorySlot = z.infer<typeof historySchema>["history"]["slots"][number];
@@ -0,0 +1,115 @@
import { describe, expect, test, vi } from "vitest";
import type { SabnzbdHistorySlot } from "../sabnzbd-schema";
import { getSabnzbdHistorySlotsAsync } from "../sabnzbd-history";
const NOW = Date.UTC(2026, 7, 10);
function createSlot(id: string, daysAgo: number): SabnzbdHistorySlot {
return {
category: "test",
download_time: 60,
status: "Completed",
completed: Math.floor(NOW / 1000) - daysAgo * 24 * 60 * 60,
nzo_id: id,
postproc_time: 30,
name: id,
bytes: 1000,
};
}
describe("getSabnzbdHistorySlotsAsync", () => {
test("merges active and archived history, filters by window, sorts newest first, and deduplicates", async () => {
const result = await getSabnzbdHistorySlotsAsync({
activeSlots: [createSlot("active", 1), createSlot("duplicate", 3)],
historyWindowDays: 10,
now: NOW,
fetchPageAsync: vi
.fn()
.mockResolvedValue([createSlot("archived", 2), createSlot("duplicate", 3), createSlot("expired", 11)]),
});
expect(result.map((slot) => slot.nzo_id)).toEqual(["active", "archived", "duplicate"]);
});
test.each([1, 2, 7, 14, 28])("includes the exact %i-day cutoff", async (historyWindowDays) => {
const result = await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays,
now: NOW,
fetchPageAsync: vi
.fn()
.mockResolvedValue([createSlot("inside", historyWindowDays), createSlot("outside", historyWindowDays + 1)]),
});
expect(result.map((slot) => slot.nzo_id)).toEqual(["inside"]);
});
test("requests another page when the archive page is full", async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => createSlot(`page-1-${index}`, 1));
const fetchPageAsync = vi
.fn()
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([createSlot("page-2", 2)]);
await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays: 10,
now: NOW,
fetchPageAsync,
});
expect(fetchPageAsync).toHaveBeenNthCalledWith(1, { start: 0, limit: 100 });
expect(fetchPageAsync).toHaveBeenNthCalledWith(2, { start: 100, limit: 100 });
expect(fetchPageAsync).toHaveBeenCalledTimes(2);
});
test("stops after a full page that is entirely outside the selected window", async () => {
const expiredPage = Array.from({ length: 100 }, (_, index) => createSlot(`expired-${index}`, 11));
const fetchPageAsync = vi.fn().mockResolvedValue(expiredPage);
await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays: 10,
now: NOW,
fetchPageAsync,
});
expect(fetchPageAsync).toHaveBeenCalledTimes(1);
});
test("stops after a partial archive page", async () => {
const fetchPageAsync = vi.fn().mockResolvedValue([createSlot("archived", 1)]);
await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays: 10,
now: NOW,
fetchPageAsync,
});
expect(fetchPageAsync).toHaveBeenCalledTimes(1);
});
test("keeps processing jobs ahead of finished history even without a completion date", async () => {
const processingSlot = { ...createSlot("processing", 0), status: "Extracting", completed: 0 };
const result = await getSabnzbdHistorySlotsAsync({
activeSlots: [processingSlot, createSlot("expired", 8)],
historyWindowDays: 7,
now: NOW,
fetchPageAsync: vi.fn().mockResolvedValue([createSlot("archived", 1)]),
});
expect(result.map((slot) => slot.nzo_id)).toEqual(["processing", "archived"]);
});
test("bounds requests when the server keeps returning a full recent page", async () => {
const recentPage = Array.from({ length: 100 }, (_, index) => createSlot(`recent-${index}`, 1));
const fetchPageAsync = vi.fn().mockResolvedValue(recentPage);
await getSabnzbdHistorySlotsAsync({ activeSlots: [], historyWindowDays: 7, now: NOW, fetchPageAsync });
expect(fetchPageAsync).toHaveBeenCalledTimes(100);
expect(fetchPageAsync).toHaveBeenLastCalledWith({ start: 9900, limit: 100 });
});
});
@@ -1,7 +1,7 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
@@ -12,6 +12,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import type { IntegrationSecret } from "../../../base/types";
import { SabnzbdIntegration } from "../sabnzbd-integration";
import type { SabnzbdHistorySlot } from "../sabnzbd-schema";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
@@ -19,6 +20,17 @@ vi.mock("@homarr/core/infrastructure/http", () => ({
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "test-api-key" }];
const NOW = Date.UTC(2026, 9, 4);
const createHistorySlot = (id: string, daysAgo: number): SabnzbdHistorySlot => ({
category: "test",
download_time: 60,
status: "Completed",
completed: Math.floor(NOW / 1000) - daysAgo * 24 * 60 * 60,
nzo_id: id,
postproc_time: 30,
name: id,
bytes: 1000,
});
const createIntegration = () =>
new SabnzbdIntegration({
@@ -34,6 +46,10 @@ describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => {
mockFetch.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
test("starts queue and history requests together", async () => {
const releaseRequests = Promise.withResolvers<void>();
const startedModes: string[] = [];
@@ -68,4 +84,101 @@ describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => {
items: [],
});
});
test.each([undefined, false])(
"keeps normal history when archive is disabled (%s)",
async (includeArchivedHistory) => {
mockFetch.mockImplementation(async (url) => {
const params = new URL(String(url)).searchParams;
expect(params.has("archive")).toBe(false);
const result =
params.get("mode") === "queue"
? { queue: { paused: false, kbpersec: "0", slots: [] } }
: { history: { slots: [createHistorySlot("old-normal", 30)] } };
return new Response(JSON.stringify(result));
});
const result = await createIntegration().getClientJobsAndStatusAsync({
limit: 10,
includeArchivedHistory,
historyWindowDays: 1,
});
expect(result.items.map((item) => item.id)).toEqual(["old-normal"]);
expect(mockFetch).toHaveBeenCalledTimes(2);
},
);
test("merges archived history without displacing active jobs and applies the row limit", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
mockFetch.mockImplementation(async (url) => {
const params = new URL(String(url)).searchParams;
let result;
if (params.get("mode") === "queue") {
result = {
queue: {
paused: false,
kbpersec: "1",
slots: [
{
status: "Downloading",
index: 0,
mb: "1",
filename: "queue",
cat: "test",
timeleft: "0:00:10",
percentage: "50",
nzo_id: "queue",
},
],
},
};
} else if (params.get("archive") === "1") {
expect(params.get("start")).toBe("0");
expect(params.get("limit")).toBe("100");
result = { history: { slots: [createHistorySlot("archived", 3), createHistorySlot("duplicate", 2)] } };
} else {
result = {
history: {
slots: [
{ ...createHistorySlot("processing", 0), status: "Extracting", completed: 0 },
createHistorySlot("normal", 1),
createHistorySlot("duplicate", 2),
createHistorySlot("expired", 8),
],
},
};
}
return new Response(JSON.stringify(result));
});
const integration = createIntegration();
const input = { limit: 10, includeArchivedHistory: true, historyWindowDays: 7 };
const result = await integration.getClientJobsAndStatusAsync(input);
expect(result.items.map((item) => item.id)).toEqual(["queue", "processing", "normal", "duplicate", "archived"]);
expect(result.status.rates.down).toBe(1024);
const limited = await integration.getClientJobsAndStatusAsync({ ...input, limit: 3 });
expect(limited.items.map((item) => item.id)).toEqual(["queue", "processing", "normal"]);
});
test("rejects malformed archive timestamps through the existing history schema", async () => {
mockFetch.mockImplementation(async (url) => {
const params = new URL(String(url)).searchParams;
const result =
params.get("mode") === "queue"
? { queue: { paused: false, kbpersec: "0", slots: [] } }
: {
history: {
slots: params.has("archive") ? [{ ...createHistorySlot("invalid", 0), completed: "bad" }] : [],
},
};
return new Response(JSON.stringify(result));
});
await expect(
createIntegration().getClientJobsAndStatusAsync({ limit: 10, includeArchivedHistory: true }),
).rejects.toThrow();
});
});
@@ -46,6 +46,21 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
const totalMemory = systemInfo.metrics.memory.total;
const usedMemory = Math.max(totalMemory - systemInfo.metrics.memory.available, 0);
const uptime = dayjs(systemInfo.info.os.uptime);
const disks = [...systemInfo.array.disks, ...systemInfo.array.caches];
const fileSystem = disks.flatMap((disk) => {
// Only the pool member reporting filesystem statistics represents its capacity.
if (disk.fsSize === null || disk.fsFree === null || disk.fsUsed === null) return [];
if (disk.fsSize === 0) return [];
return [
{
deviceName: disk.name ?? disk.device ?? disk.id,
used: `${disk.fsUsed * 1024}`, // API filesystem sizes are in KiB.
available: `${disk.fsFree * 1024}`,
percentage: (disk.fsUsed / disk.fsSize) * 100,
},
];
});
return {
version: systemInfo.info.os.release,
@@ -59,16 +74,11 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
rebootRequired: false,
availablePkgUpdates: 0,
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
smart: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
fileSystem,
smart: disks.map((disk) => ({
deviceName: disk.name ?? disk.device ?? disk.id,
temperature: disk.temp ?? null,
overallStatus: disk.status,
overallStatus: disk.status ?? "UNKNOWN",
// See ArrayDiskStatus from https://studio.apollographql.com/public/Unraid-API/variant/current/explorer
healthy: disk.status === "DISK_OK",
})),
@@ -111,8 +121,20 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
}
}
disks {
id
name
size
device
fsSize
fsFree
fsUsed
status
temp
}
caches {
id
name
device
fsSize
fsFree
fsUsed
status
@@ -1,5 +1,21 @@
import z from "zod";
const filesystemSizeSchema = z
.union([z.number(), z.string().regex(/^\d+$/).transform(Number)])
.pipe(z.number().int().min(0).max(Number.MAX_SAFE_INTEGER))
.nullable();
const unraidDiskSchema = z.object({
id: z.string(),
name: z.string().nullable(),
device: z.string().nullable(),
fsSize: filesystemSizeSchema,
fsFree: filesystemSizeSchema,
fsUsed: filesystemSizeSchema,
status: z.string().nullable(),
temp: z.number().nullish(),
});
export const unraidSystemInfoSchema = z.object({
metrics: z.object({
cpu: z.object({
@@ -27,16 +43,8 @@ export const unraidSystemInfoSchema = z.object({
used: z.coerce.number(),
}),
}),
disks: z.array(
z.object({
name: z.string(),
size: z.number(),
fsFree: z.number(),
fsUsed: z.number(),
status: z.string(),
temp: z.number().nullish(),
}),
),
disks: z.array(unraidDiskSchema),
caches: z.array(unraidDiskSchema),
}),
info: z.object({
devices: z.object({
+5 -1
View File
@@ -7,7 +7,11 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
export const downloadClientRequestHandler = createIntegrationRequestHandler<
DownloadClientJobsAndStatus,
IntegrationKindByCategory<"downloadClient">,
{ limit: number }
{
limit: number;
includeArchivedHistory: boolean;
historyWindowDays: number;
}
>({
cacheNamespace: "downloads:jobs-and-status",
cacheTtlMs: 10 * 1000,
@@ -322,6 +322,30 @@ describe("createRequestHandler", () => {
});
describe("createIntegrationRequestHandler", () => {
it("keeps SABnzbd history windows and archive modes in separate cache entries", async () => {
let calls = 0;
const handler = createIntegrationRequestHandler<
number,
"sabNzbd",
{ limit: number; includeArchivedHistory: boolean; historyWindowDays: number }
>({ requestAsync: async () => ++calls });
const integration = { ...createIntegration("test-secret"), kind: "sabNzbd" as const };
const input = { limit: 50, includeArchivedHistory: true, historyWindowDays: 7 };
await expect(handler.handler(integration, input).getDataAsync()).resolves.toMatchObject({ data: 1 });
await expect(handler.handler(integration, input).getDataAsync()).resolves.toMatchObject({ data: 1 });
await expect(
handler.handler(integration, { ...input, historyWindowDays: 14 }).getDataAsync(),
).resolves.toMatchObject({ data: 2 });
await expect(
handler.handler(integration, { ...input, includeArchivedHistory: false }).getDataAsync(),
).resolves.toMatchObject({ data: 3 });
await expect(
handler.handler(integration, { ...input, historyWindowDays: 14 }).getDataAsync(),
).resolves.toMatchObject({ data: 2 });
expect(calls).toBe(3);
});
it("does not deduplicate requests across rotated integration secrets", async () => {
let calls = 0;
const request = deferred<string>();
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30 -1
View File
@@ -4137,6 +4137,14 @@
"showCompletedHttp": {
"label": "Show Miscellaneous entries marked as completed"
},
"includeArchivedHistory": {
"label": "Include archived SABnzbd history",
"description": "Show jobs moved to Archive."
},
"historyWindowDays": {
"label": "Archived history window (days)",
"description": "Limit finished SABnzbd history to this many days. Increase the item limit to show more results."
},
"activeTorrentThreshold": {
"label": "Hide completed torrent under this threshold"
},
@@ -5086,7 +5094,7 @@
},
"beszelSystemStats": {
"name": "Beszel System Stats",
"description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics",
"description": "Time-series charts for CPU, memory, disk, network, GPU, and Docker container metrics",
"option": {
"systemId": {
"label": "System",
@@ -5110,6 +5118,15 @@
"showNetwork": {
"label": "Show network chart"
},
"showGpuUsage": {
"label": "Show GPU usage chart"
},
"showGpuMemory": {
"label": "Show GPU memory chart"
},
"showGpuPower": {
"label": "Show GPU power chart"
},
"showDockerCpu": {
"label": "Show Docker CPU chart"
},
@@ -5164,6 +5181,18 @@
"sent": "Sent",
"recv": "Received"
},
"gpuUsage": {
"title": "GPU Usage",
"subtitle": "Utilization by GPU"
},
"gpuMemory": {
"title": "GPU Memory",
"subtitle": "VRAM used by GPU"
},
"gpuPower": {
"title": "GPU Power",
"subtitle": "Power draw by GPU"
},
"dockerCpu": {
"title": "Docker CPU Usage",
"subtitle": "Average CPU utilization of containers"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -4,6 +4,20 @@ import { createCustomErrorParams } from "./form/i18n";
export const certificateValidFileNameSchema = z.string().regex(/^[\w\-. ]+$/);
export const rootCertificateFileNameSchema = z
.string()
.max(128)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/);
export const rootCertificateMetadataSchema = z.object({
fileName: rootCertificateFileNameSchema,
sha256: z.string(),
fingerprint256: z.string(),
ca: z.literal(true),
validFrom: z.string(),
validTo: z.string(),
});
export const checkCertificateFile: z.core.CheckFn<File> = (context) => {
const result = certificateValidFileNameSchema.safeParse(context.value.name);
if (!result.success) {
+1
View File
@@ -19,6 +19,7 @@
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
"./demo-read-only": "./src/demo-read-only.tsx",
"./assistant/context": "./src/assistant/context.tsx",
"./catalog": "./src/catalog.ts",
"./definition": "./src/definition.ts",
@@ -271,6 +271,9 @@ export default function BeszelSystemStatsWidget({
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
gpuUsage: options.showGpuUsage,
gpuMemory: options.showGpuMemory,
gpuPower: options.showGpuPower,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
@@ -66,6 +66,9 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDisk: factory.switch({ defaultValue: true }),
showDiskIO: factory.switch({ defaultValue: true }),
showNetwork: factory.switch({ defaultValue: true }),
showGpuUsage: factory.switch({ defaultValue: true }),
showGpuMemory: factory.switch({ defaultValue: true }),
showGpuPower: factory.switch({ defaultValue: true }),
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
@@ -2,7 +2,14 @@ import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, normalizeBeszelByteRate, padLiveTimeGrid } from "./chart";
import {
buildDiskChartData,
buildGpuChartData,
buildGpuDevices,
hasGpuMetric,
normalizeBeszelByteRate,
padLiveTimeGrid,
} from "./chart";
const GIBIBYTE = 1024 ** 3;
@@ -103,3 +110,74 @@ describe("padLiveTimeGrid", () => {
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
});
});
describe("buildGpuChartData", () => {
test("uses stable GPU ID and model labels across samples", () => {
const first = record("2026-07-11T13:46:00.000Z", 0, undefined);
first.stats.g = {
"0": { n: "RTX 3090", u: 10, mu: 1024, p: 150 },
"1": { n: "RTX 3090", u: 20, mu: 2048, p: 175 },
};
const second = record("2026-07-11T13:47:00.000Z", 0, undefined);
second.stats.g = { "0": { n: "RTX 3090", u: 30, mu: 4096, p: 200 } };
const devices = [
{ id: "0", seriesName: "RTX 3090 (0)" },
{ id: "1", seriesName: "RTX 3090 (1)" },
];
const data = buildGpuChartData([second, first], devices, "usage", "1h");
expect(data.map((point) => ({ zero: point["RTX 3090 (0)"], one: point["RTX 3090 (1)"] }))).toEqual([
{ zero: 10, one: 20 },
{ zero: 30, one: 0 },
]);
expect(buildGpuChartData([second, first], devices, "memory", "1h").map((point) => point["RTX 3090 (0)"])).toEqual([
1024 * 1024 * 1024,
4096 * 1024 * 1024,
]);
expect(buildGpuChartData([second, first], devices, "power", "1h").map((point) => point["RTX 3090 (0)"])).toEqual([
150, 200,
]);
});
test("returns no data when no GPU stats are available", () => {
expect(buildGpuDevices(undefined)).toEqual([]);
expect(buildGpuChartData(undefined, [], "usage")).toEqual([]);
});
test("keeps same-model GPUs distinct and orders them by device ID", () => {
const sample = record("2026-07-11T13:46:00.000Z", 0, undefined);
sample.stats.g = {
"1": { n: "RTX 3090", u: 20 },
"0": { n: "RTX 3090", u: 10 },
};
expect(buildGpuDevices([sample])).toEqual([
{ id: "0", seriesName: "RTX 3090 (0)" },
{ id: "1", seriesName: "RTX 3090 (1)" },
]);
});
test("uses zero for unavailable optional metrics and preserves the live window", () => {
const sample = record("2026-07-11T13:51:30.000Z", 0, undefined);
sample.stats.g = { "0": { n: "RTX 3090", u: 10 } };
const devices = [{ id: "0", seriesName: "RTX 3090 (0)" }];
expect(buildGpuChartData([sample], devices, "memory", "1m").at(-1)?.["RTX 3090 (0)"]).toBe(0);
expect(buildGpuChartData([sample], devices, "power", "1m").at(-1)?.["RTX 3090 (0)"]).toBe(0);
expect(buildGpuChartData([sample], devices, "usage", "1m")).toHaveLength(60);
});
test("distinguishes missing optional GPU metrics from reported zero values", () => {
const sample = record("2026-07-11T13:51:30.000Z", 0, undefined);
sample.stats.g = { "0": { n: "RTX 3090", u: 0 } };
expect(hasGpuMetric([sample], "memory")).toBe(false);
expect(hasGpuMetric([sample], "power")).toBe(false);
sample.stats.g = { "0": { n: "RTX 3090", u: 0, mu: 0, p: 0 } };
expect(hasGpuMetric([sample], "memory")).toBe(true);
expect(hasGpuMetric([sample], "power")).toBe(true);
});
});
+67 -1
View File
@@ -6,7 +6,7 @@ import type { AreaChartProps } from "@mantine/charts";
import { AreaChart } from "@mantine/charts";
import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import type { BeszelContainerStatsRecord, BeszelGPUData, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { formatLocalizedDate } from "../../common/locale";
@@ -324,3 +324,69 @@ export const useDockerChartData = (
});
return padTimeGrid(mapped, timePeriod, locale);
}, [containerStats, containerNames, metric, timePeriod, locale]);
export interface BeszelGpuDevice {
id: string;
seriesName: string;
}
export type BeszelGpuMetric = "usage" | "memory" | "power";
export const buildGpuDevices = (systemStats: BeszelSystemStatsRecord[] | undefined) => {
const devices = new Map<string, BeszelGpuDevice>();
for (const record of systemStats ?? []) {
for (const [id, gpu] of Object.entries(record.stats.g ?? {})) {
if (!devices.has(id)) devices.set(id, { id, seriesName: `${gpu.n} (${id})` });
}
}
return [...devices.values()].toSorted((a, b) => a.id.localeCompare(b.id));
};
export const useGpuDevices = (systemStats: BeszelSystemStatsRecord[] | undefined) =>
useMemo(() => buildGpuDevices(systemStats), [systemStats]);
export const hasGpuMetric = (systemStats: BeszelSystemStatsRecord[] | undefined, metric: BeszelGpuMetric) => {
if (metric === "usage") return (systemStats ?? []).some((record) => Object.keys(record.stats.g ?? {}).length > 0);
return (systemStats ?? []).some((record) =>
Object.values(record.stats.g ?? {}).some((device) => (metric === "memory" ? device.mu : device.p) !== undefined),
);
};
const gpuExtractors: Record<BeszelGpuMetric, (device: BeszelGPUData | undefined) => number> = {
usage: (device) => device?.u ?? 0,
memory: (device) => (device?.mu ?? 0) * 1024 * 1024,
power: (device) => device?.p ?? 0,
};
export const useGpuChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
devices: BeszelGpuDevice[],
metric: BeszelGpuMetric,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(
() => buildGpuChartData(systemStats, devices, metric, timePeriod, locale),
[systemStats, devices, metric, timePeriod, locale],
);
export const buildGpuChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
devices: BeszelGpuDevice[],
metric: BeszelGpuMetric,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) => {
if (!systemStats?.length) return [];
const extract = gpuExtractors[metric];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
for (const device of devices) {
point[device.seriesName] = extract(record.stats.g?.[device.id]);
}
return point;
});
return padTimeGrid(mapped, timePeriod, locale);
};
@@ -20,6 +20,8 @@ export const createByteChartAxisFormatters = (
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const formatWatts = (value: number): string => `${value.toFixed(1)} W`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const formatUptime = (seconds: number): string => dayjs.duration(seconds, "seconds").humanize();
@@ -0,0 +1,148 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import type * as ChartModule from "./chart";
import { BeszelStatsView } from "./stats-view";
import type { BeszelStatsVisibility } from "./stats-view";
const systemStats: BeszelSystemStatsRecord[] = [
{
id: "gpu-sample",
system: "llama01",
type: "1m",
created: "2026-07-11T13:51:30.000Z",
updated: "2026-07-11T13:51:30.000Z",
stats: {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
s: 0,
su: 0,
d: 0,
du: 0,
dp: 0,
g: { "0": { n: "RTX 3090", u: 10, mu: 1024, p: 150 } },
},
},
];
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
vi.mock("@homarr/api/client", () => ({
clientApi: {
widget: {
beszel: {
getSystemStats: { useQuery: () => ({ data: { systemStats, containerStats: [] } }) },
},
},
},
}));
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
useCurrentIntlLocale: () => "en-US",
}));
vi.mock("@homarr/settings", () => ({
useByteFormatter: () => ({ formatBytes: String, formatByteRate: String }),
}));
vi.mock("./use-live-stats", () => ({ useLiveStats: () => ({ data: undefined }) }));
vi.mock("./chart", async (importOriginal) => {
const actual = await importOriginal<typeof ChartModule>();
return {
...actual,
BeszelChartPanel: ({ title }: { title: string }) => createElement("div", { "data-chart": title }),
};
});
const hiddenCharts: BeszelStatsVisibility = {
cpu: false,
memory: false,
disk: false,
diskIO: false,
network: false,
gpuUsage: false,
gpuMemory: false,
gpuPower: false,
dockerCpu: false,
dockerMemory: false,
dockerNetwork: false,
};
const roots: ReturnType<typeof createRoot>[] = [];
const setGpuStats = (gpu: BeszelSystemStatsRecord["stats"]["g"]) => {
const sample = systemStats[0];
if (!sample) throw new Error("Expected a GPU test sample");
sample.stats.g = gpu;
};
afterEach(() => {
for (const root of roots.splice(0)) root.unmount();
document.body.replaceChildren();
setGpuStats({ "0": { n: "RTX 3090", u: 10, mu: 1024, p: 150 } });
});
const renderStatsView = async (visibility: BeszelStatsVisibility) => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
roots.push(root);
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(BeszelStatsView, {
integrationIds: ["beszel"],
systemId: "llama01",
timePeriod: "1h",
visibility,
columns: 1,
}),
),
);
});
return container;
};
describe("BeszelStatsView GPU charts", () => {
test("renders only the GPU charts enabled by widget options", async () => {
const container = await renderStatsView({ ...hiddenCharts, gpuUsage: true, gpuPower: true });
expect(container.querySelector('[data-chart="chart.gpuUsage.title"]')).not.toBeNull();
expect(container.querySelector('[data-chart="chart.gpuPower.title"]')).not.toBeNull();
expect(container.querySelector('[data-chart="chart.gpuMemory.title"]')).toBeNull();
});
test("does not render GPU charts for a non-GPU system", async () => {
setGpuStats(undefined);
const container = await renderStatsView({ ...hiddenCharts, gpuUsage: true, gpuMemory: true, gpuPower: true });
expect(container.querySelectorAll("[data-chart]")).toHaveLength(0);
});
test("does not render optional GPU charts when Beszel does not report their metrics", async () => {
setGpuStats({ "0": { n: "RTX 3090", u: 10 } });
const container = await renderStatsView({ ...hiddenCharts, gpuMemory: true, gpuPower: true });
expect(container.querySelector('[data-chart="chart.gpuMemory.title"]')).toBeNull();
expect(container.querySelector('[data-chart="chart.gpuPower.title"]')).toBeNull();
});
});
@@ -18,16 +18,20 @@ import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
hasGpuMetric,
normalizeBeszelByteRate,
useContainerNames,
useDiskChartData,
useDiskIOChartData,
useDockerChartData,
useGpuChartData,
useGpuDevices,
useSystemChartData,
} from "./chart";
import { createByteChartAxisFormatters, formatPercent } from "./format";
import { createByteChartAxisFormatters, formatPercent, formatWatts } from "./format";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
import { useDemoReadOnly } from "../../demo-read-only";
const CHART_HEIGHT = 180;
const MEBIBYTE = 1024 * 1024;
@@ -39,6 +43,9 @@ export interface BeszelStatsVisibility {
disk: boolean;
diskIO: boolean;
network: boolean;
gpuUsage: boolean;
gpuMemory: boolean;
gpuPower: boolean;
dockerCpu: boolean;
dockerMemory: boolean;
dockerNetwork: boolean;
@@ -92,14 +99,16 @@ export function BeszelStatsView({
rate: makeTooltipProps(formatByteRate, true),
percentTotal: makeTooltipProps(formatPercent, true),
bytesTotal: makeTooltipProps(formatBytes, true),
watts: makeTooltipProps(formatWatts),
}),
[formatByteRate, formatBytes],
);
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const demoReadOnly = useDemoReadOnly();
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
{ refetchInterval: isLive || demoReadOnly ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
@@ -155,6 +164,41 @@ export function BeszelStatsView({
locale,
);
const gpuDevices = useGpuDevices(
whenVisible(visibility.gpuUsage || visibility.gpuMemory || visibility.gpuPower, systemStats),
);
const gpuSeries = useMemo(
() =>
gpuDevices.map((device, index) => ({
name: device.seriesName,
color: containerColors[index % containerColors.length] as string,
})),
[gpuDevices],
);
const gpuUsageData = useGpuChartData(
whenVisible(visibility.gpuUsage, systemStats),
gpuDevices,
"usage",
timePeriod,
locale,
);
const gpuMemoryData = useGpuChartData(
whenVisible(visibility.gpuMemory, systemStats),
gpuDevices,
"memory",
timePeriod,
locale,
);
const gpuPowerData = useGpuChartData(
whenVisible(visibility.gpuPower, systemStats),
gpuDevices,
"power",
timePeriod,
locale,
);
const hasGpuMemory = hasGpuMetric(systemStats, "memory");
const hasGpuPower = hasGpuMetric(systemStats, "power");
const efsPaths = useMemo(() => {
const paths = new Set<string>();
for (const record of systemStats ?? []) {
@@ -281,6 +325,9 @@ export function BeszelStatsView({
(visibility.disk && diskData.length > 0 ? 1 : 0) +
(visibility.diskIO && diskIOData.length > 0 ? 1 : 0) +
(visibility.network && networkData.length > 0 ? 1 : 0) +
(visibility.gpuUsage && gpuSeries.length > 0 && gpuUsageData.length > 0 ? 1 : 0) +
(visibility.gpuMemory && hasGpuMemory && gpuMemoryData.length > 0 ? 1 : 0) +
(visibility.gpuPower && hasGpuPower && gpuPowerData.length > 0 ? 1 : 0) +
(showDocker && containerSeries.length > 0
? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) +
(visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) +
@@ -365,6 +412,53 @@ export function BeszelStatsView({
}}
/>
)}
{gpuSeries.length > 0 && (
<>
{visibility.gpuUsage && gpuUsageData.length > 0 && (
<BeszelChartPanel
title={t("chart.gpuUsage.title")}
subtitle={t("chart.gpuUsage.subtitle")}
chartProps={{
h: chartHeight,
withXAxis: showXAxis,
data: gpuUsageData,
series: gpuSeries,
yAxisFormatter: formatPercent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltips.percent,
}}
/>
)}
{visibility.gpuMemory && hasGpuMemory && gpuMemoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.gpuMemory.title")}
subtitle={t("chart.gpuMemory.subtitle")}
chartProps={{
h: chartHeight,
withXAxis: showXAxis,
data: gpuMemoryData,
series: gpuSeries,
yAxisFormatter: byteAxisFormatters.bytes,
tooltipProps: tooltips.bytesTotal,
}}
/>
)}
{visibility.gpuPower && hasGpuPower && gpuPowerData.length > 0 && (
<BeszelChartPanel
title={t("chart.gpuPower.title")}
subtitle={t("chart.gpuPower.subtitle")}
chartProps={{
h: chartHeight,
withXAxis: showXAxis,
data: gpuPowerData,
series: gpuSeries,
yAxisFormatter: formatWatts,
tooltipProps: tooltips.watts,
}}
/>
)}
</>
)}
{showDocker && containerSeries.length > 0 && (
<>
{visibility.dockerCpu && dockerCpuData.length > 0 && (
@@ -23,6 +23,9 @@ const allCharts = {
disk: true,
diskIO: true,
network: true,
gpuUsage: true,
gpuMemory: true,
gpuPower: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
@@ -11,6 +11,7 @@ import { Link, zoomCompensatedSize } from "@homarr/ui";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { useDemoReadOnly } from "../demo-read-only";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
@@ -23,6 +24,7 @@ export default function CustomApiWidget({
}: WidgetComponentProps<"customApi">) {
const t = useI18n("widget.customApi");
const tCustomJsx = useI18n("widget.customApi.customJsx");
const demoReadOnly = useDemoReadOnly();
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const definitionId = options.definitionId;
@@ -33,8 +35,9 @@ export default function CustomApiWidget({
{
enabled: Boolean(itemId) && Boolean(definitionId),
refetchInterval: (currentQuery) =>
isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3,
demoReadOnly || isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
retry: (failureCount, error) =>
!demoReadOnly && !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3,
},
);
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { createContext, useContext } from "react";
const DemoReadOnlyContext = createContext(false);
export const DemoReadOnlyProvider = DemoReadOnlyContext.Provider;
export const useDemoReadOnly = () => useContext(DemoReadOnlyContext);
@@ -319,6 +319,8 @@ export default function DownloadClientsWidget({
const downloadsQuery = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
includeArchivedHistory: options.includeArchivedHistory,
historyWindowDays: options.historyWindowDays,
});
const currentItems = getUsableWidgetQueryData(downloadsQuery);
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { definition } from ".";
describe("Downloads widget history options", () => {
test.each([1, 7, 14, 28])("accepts an arbitrary %i-day window", (days) => {
const options = definition.createOptions();
expect(options.historyWindowDays.validate.safeParse(days).success).toBe(true);
});
test.each([0, -1, 1.5, "7"])("rejects an invalid day window %s", (days) => {
const options = definition.createOptions();
expect(options.historyWindowDays.validate.safeParse(days).success).toBe(false);
});
test("only matches results for its own archive settings", () => {
const input = {
integrationIds: ["sabnzbd-1"],
limitPerIntegration: 50,
includeArchivedHistory: true,
historyWindowDays: 7,
};
const scope = {
itemId: "widget-1",
boardId: "board-1",
integrationIds: input.integrationIds,
options: input,
runtimeQueries: [],
};
const query = { path: ["widget", "downloads", "getJobsAndStatuses"], input };
expect(definition.queryMatcher(query, scope)).toBe(true);
expect(definition.queryMatcher({ ...query, input: { ...input, historyWindowDays: 14 } }, scope)).toBe(false);
expect(definition.queryMatcher({ ...query, input: { ...input, includeArchivedHistory: false } }, scope)).toBe(
false,
);
});
});
+19
View File
@@ -30,6 +30,8 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
limitPerIntegration: scope.options.limitPerIntegration,
includeArchivedHistory: scope.options.includeArchivedHistory,
historyWindowDays: scope.options.historyWindowDays,
}),
refetchInterval: 10,
createOptions() {
@@ -62,6 +64,16 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
showCompletedHttp: factory.switch({
defaultValue: true,
}),
includeArchivedHistory: factory.switch({
defaultValue: false,
withDescription: true,
}),
historyWindowDays: factory.number({
defaultValue: 7,
validate: z.number().int().min(1),
step: 1,
withDescription: true,
}),
activeTorrentThreshold: factory.number({
validate: z.number().min(0),
defaultValue: 0,
@@ -89,6 +101,13 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
includeArchivedHistory: {
shouldHide: (_, integrationKinds) => !integrationKinds.includes("sabNzbd"),
},
historyWindowDays: {
shouldHide: ({ includeArchivedHistory }, integrationKinds) =>
!integrationKinds.includes("sabNzbd") || !includeArchivedHistory,
},
showCompletedUsenet: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
+6 -4
View File
@@ -22,6 +22,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useDemoReadOnly } from "../demo-read-only";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { statsEntriesSchema } from "./config";
import { StatsTable } from "./table";
@@ -41,6 +42,7 @@ export default function StatsWidget({
displayScale = 1,
}: WidgetComponentProps<"stats">) {
const board = useRequiredBoard();
const demoReadOnly = useDemoReadOnly();
const advanced = displayMode === "advanced";
const t = useI18n("widget.stats");
const utils = clientApi.useUtils();
@@ -81,8 +83,8 @@ export default function StatsWidget({
{ integrationId },
{
staleTime: 30_000,
refetchInterval: 60_000,
refetchOnWindowFocus: true,
refetchInterval: demoReadOnly ? false : 60_000,
refetchOnWindowFocus: !demoReadOnly,
refetchIntervalInBackground: false,
retry: false,
},
@@ -153,13 +155,13 @@ export default function StatsWidget({
});
useEffect(() => {
if (document.hidden) return;
if (demoReadOnly || document.hidden) return;
for (const [index, integrationId] of visibleIds.entries()) {
const snapshot = snapshots[index];
if (!snapshot?.data || snapshot.error) continue;
if (snapshot.data.updatedAt === null && snapshot.data.retryAt <= Date.now()) void refresh(integrationId, false);
}
}, [visibleIds, snapshots, refresh]);
}, [demoReadOnly, visibleIds, snapshots, refresh]);
const tableEntries = advanced
? ids.flatMap((id) => {
+2 -1
View File
@@ -12,7 +12,7 @@ import classes from "./component.module.css";
import { UmamiEventsContent } from "./umami-events-content";
import { getUmamiLayout } from "./umami-layout";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import { formatTimeFrameLabel, formatXLabel, useUmamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -48,6 +48,7 @@ export function UmamiContent({
}: UmamiContentProps) {
const t = useI18n("widget.umami");
const locale = useCurrentIntlLocale();
const umamiQueryOptions = useUmamiQueryOptions();
const tickColor = "var(--mantine-color-dimmed)";
const statsQuery = clientApi.widget.umami.getVisitorStats.useQuery(
@@ -7,7 +7,7 @@ import type { UmamiMetricItem } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import { umamiQueryOptions } from "./umami-utils";
import { useUmamiQueryOptions } from "./umami-utils";
import { getUsableWidgetQueryData } from "../common/query-state";
interface UmamiTopListProps {
@@ -19,6 +19,7 @@ interface UmamiTopListProps {
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useI18n("widget.umami");
const umamiQueryOptions = useUmamiQueryOptions();
const data =
getUsableWidgetQueryData(
clientApi.widget.umami.getTopPages.useQuery(
@@ -31,6 +32,7 @@ export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, lim
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useI18n("widget.umami");
const umamiQueryOptions = useUmamiQueryOptions();
const data =
getUsableWidgetQueryData(
clientApi.widget.umami.getTopReferrers.useQuery(
+4 -1
View File
@@ -1,8 +1,11 @@
import type { useI18n } from "@homarr/translation/client";
import { useDemoReadOnly } from "../demo-read-only";
import { timeFrameValues } from "./index";
export const umamiQueryOptions = { refetchInterval: 60_000 } as const;
export const useUmamiQueryOptions = (): { refetchInterval: number | false } => ({
refetchInterval: useDemoReadOnly() ? false : 60_000,
});
export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"];