Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4d9473f
feat(integrations): add beszel
Meierschlumpf Oct 15, 2025
8b519f4
Merge branch 'dev' into beszel-integration
Meierschlumpf Oct 24, 2025
785aa4d
feat(system-usage): add mock integration
Meierschlumpf Oct 24, 2025
28fdfed
test(beszel): add integration test
Meierschlumpf Oct 24, 2025
d0f30bc
fix: increase timeout after start for beszel
Meierschlumpf Oct 24, 2025
59652bb
chore(debug): result of system details on github
Meierschlumpf Oct 24, 2025
26f3b57
fix: use tmp directory on github for beszel socket
Meierschlumpf Oct 24, 2025
f31f1c4
fix: add host.docker.internal as extra host
Meierschlumpf Oct 24, 2025
3d3c7fe
chore: remove temporary debug logs
Meierschlumpf Oct 24, 2025
d1db4f4
test(beszel): check test connection functionality
Meierschlumpf Oct 24, 2025
3101e61
fix: deepsource issue
Meierschlumpf Oct 24, 2025
5a8af91
chore: remove comment
Meierschlumpf Oct 24, 2025
1da41c2
feat: add dynamic select input
Meierschlumpf Oct 25, 2025
1e51d3f
feat: add cron-job for live updates
Meierschlumpf Oct 25, 2025
07c123d
fix: network bytes in mock system shows NaI
Meierschlumpf Oct 25, 2025
c322929
fix: deepsoruce issue
Meierschlumpf Oct 25, 2025
8d975c6
refactor: split up system-usage component
Meierschlumpf Oct 31, 2025
a4a2165
fix: tests are failing caused by node 24 upgrade
Meierschlumpf Oct 31, 2025
1cf2c5b
feat: add self signed certificate support
Meierschlumpf Oct 31, 2025
3a144b0
Merge branch 'main' into beszel-integration
Meierschlumpf Oct 31, 2025
2228726
Merge branch 'dev' into beszel-integration
Meierschlumpf Oct 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
"trpc-to-openapi": "patches/trpc-to-openapi.patch",
"vitest": "patches/[email protected]"
},
"allowUnusedPatches": true,
"ignoredBuiltDependencies": [
Expand Down
14 changes: 13 additions & 1 deletion packages/api/src/middlewares/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { IntegrationKind } from "@homarr/definitions";

import { publicProcedure } from "../trpc";

export type IntegrationAction = "query" | "interact";
export type IntegrationAction = "query" | "interact" | "use";

/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
Expand Down Expand Up @@ -167,6 +167,18 @@ const throwIfActionIsNotAllowedAsync = async (
});
}

if (action === "use") {
const haveAllUseAccess = integrations
.map((integration) => constructIntegrationPermissions(integration, session))
.every(({ hasUseAccess }) => hasUseAccess);
if (haveAllUseAccess) return;

throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to use at least one of the specified integrations",
});
}

const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);

if (hasQueryAccess) return;
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { releasesRouter } from "./releases";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
import { stockPriceRouter } from "./stocks";
import { systemUsageRouter } from "./system-usage";
import { weatherRouter } from "./weather";

export const widgetRouter = createTRPCRouter({
Expand All @@ -43,4 +44,5 @@ export const widgetRouter = createTRPCRouter({
networkController: networkControllerRouter,
firewall: firewallRouter,
notifications: notificationsRouter,
systemUsage: systemUsageRouter,
});
49 changes: 49 additions & 0 deletions packages/api/src/router/widgets/system-usage.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once again, I'm not sure whether it makes sense to add a new widget, router and handler for this, since everything is so similar with the existing ones.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be your suggestion instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean the widget shows similar data to system-resources and health-monitoring. But at the same time it shows other data as well and it also has an option to choose a system

Copy link
Member

@manuel-rw manuel-rw Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean the widget shows similar data to system-resources and health-monitoring. But at the same time it shows other data as well and it also has an option to choose a system

To decide that, we need to know the differences between them.
What other data does it show? Remember, we can keep this new widget and make it compatible for Dash., TrueNAS, ...etc.

What would be your suggestion instead?

Since Hardware monitoring will be one of the main use cases of Homarr in general, I think we should add your new widget to the existing hardware monitoring category and then update that category to display additional information if available (e.g. by Bazel). That is more work, but better for the user, since they can choose any of the widgets and less confusing (why would the Bezel widget not be compatible with Dash. and the other way around? Why does the new widget not have "Bezel" in it's name if it's only strictly compatible with said app?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it and the others should be available in all 3 widgets if possible, however the structure of all 3 is quite different. Especially the heal monitoring which distinguishes between different drives etc.

This widget only has the percentage usage and additional infos that are not on the system health monitoring widget like network usage and the agent it is using. Also it shows the name of the system directly.

Do you think it makes more sense to not merge this today and discuss how we move forward further or that we release it today and if necessary change it (for example using a migration script) in an upcoming release?

I think the people would be quite happy with the addition and we can still improve it in upcoming releases

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait we are back in normal time, true 😂
Release was just now

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { observable } from "@trpc/server/observable";
import z from "zod";

import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { System } from "@homarr/integrations/types";
import { systemUsageRequestHandler } from "@homarr/request-handler/system-usage";

import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";

const supportedIntegrations = getIntegrationKindsByCategory("systemUsage");

export const systemUsageRouter = createTRPCRouter({
listSystems: protectedProcedure
.concat(createOneIntegrationMiddleware("use", ...supportedIntegrations))
.query(async ({ ctx: { integration } }) => {
const instance = await createIntegrationAsync(integration);

return await instance.getSystemsAsync();
}),
get: publicProcedure
.input(z.object({ systemId: z.string() }))
.concat(createOneIntegrationMiddleware("query", ...supportedIntegrations))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = systemUsageRequestHandler.handler(integration, { systemId: input.systemId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribe: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...supportedIntegrations))
.input(z.object({ systemId: z.string() }))
.subscription(({ input, ctx }) => {
return observable<{
system: System;
}>((emit) => {
const innerHandler = systemUsageRequestHandler.handler(ctx.integration, {
systemId: input.systemId,
});
const unsubscribe = innerHandler.subscribe((system) => {
emit.next({ system });
});

return () => {
unsubscribe();
};
});
}),
});
1 change: 1 addition & 0 deletions packages/common/src/errors/http/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./ofetch-http-error-handler";
export * from "./axios-http-error-handler";
export * from "./tsdav-http-error-handler";
export * from "./octokit-http-error-handler";
export * from "./pocketbase-http-error-handler";
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ClientResponseError } from "pocketbase";

import { logger } from "@homarr/log";

import type { AnyRequestError } from "../request-error";
import { ResponseError } from "../response-error";
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
import { HttpErrorHandler } from "./http-error-handler";

export class PocketBaseHttpErrorHandler extends HttpErrorHandler {
handleRequestError(error: unknown): AnyRequestError | undefined {
if (!(error instanceof ClientResponseError)) return undefined;
return new FetchHttpErrorHandler("pocketbase").handleRequestError(error.cause);
}

handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof ClientResponseError)) return undefined;

let status = error.status;
if (status === 400 && error.data.message === "Failed to authenticate") {
status = 401;
}

logger.debug("Received pocketbase response error", {
status,
url: error.url,
});

return new ResponseError({ status: 401, url: error.url }, { cause: error });
}
}
2 changes: 2 additions & 0 deletions packages/cron-jobs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { systemUsageJob } from "./jobs/integrations/system-usage";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
Expand Down Expand Up @@ -50,6 +51,7 @@ export const jobGroup = createCronJobGroup({
firewallInterfaces: firewallInterfacesJob,
refreshNotifications: refreshNotificationsJob,
weather: weatherJob,
systemUsage: systemUsageJob,
});

export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
15 changes: 15 additions & 0 deletions packages/cron-jobs/src/jobs/integrations/system-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { systemUsageRequestHandler } from "@homarr/request-handler/system-usage";

import { createCronJob } from "../../lib";

export const systemUsageJob = createCronJob("systemUsage", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(systemUsageRequestHandler.handler, {
widgetKinds: ["systemUsage"],
getInput: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
systemUsage: (options) => ({ systemId: options.systemId! }),
},
}),
);
9 changes: 9 additions & 0 deletions packages/definitions/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ export const integrationDefs = {
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
},
beszel: {
name: "Beszel",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/beszel.svg",
category: ["systemUsage"],
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
Expand All @@ -316,6 +323,7 @@ export const integrationDefs = {
"networkController",
"notifications",
"smartHomeServer",
"systemUsage",
],
documentationUrl: null,
},
Expand Down Expand Up @@ -384,6 +392,7 @@ export const integrationCategories = [
"releasesProvider",
"notifications",
"firewall",
"systemUsage",
] as const;

export type IntegrationCategory = (typeof integrationCategories)[number];
1 change: 1 addition & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ export const widgetKinds = [
"firewall",
"notifications",
"systemResources",
"systemUsage",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
1 change: 1 addition & 0 deletions packages/integrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"maria2": "^0.4.1",
"node-ical": "^0.22.1",
"octokit": "^5.0.4",
"pocketbase": "^0.26.2",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.5",
"undici": "7.16.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { IntegrationKind } from "@homarr/definitions";

import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DockerHubIntegration } from "../docker-hub/docker-hub-integration";
Expand Down Expand Up @@ -101,6 +102,7 @@ export const integrationCreators = {
ntfy: NTFYIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
beszel: BeszelIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;

type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/errors/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
FetchHttpErrorHandler,
OctokitHttpErrorHandler,
OFetchHttpErrorHandler,
PocketBaseHttpErrorHandler,
TsdavHttpErrorHandler,
} from "@homarr/common/server";

Expand All @@ -13,3 +14,4 @@ export const integrationOFetchHttpErrorHandler = new IntegrationHttpErrorHandler
export const integrationAxiosHttpErrorHandler = new IntegrationHttpErrorHandler(new AxiosHttpErrorHandler());
export const integrationTsdavHttpErrorHandler = new IntegrationHttpErrorHandler(new TsdavHttpErrorHandler());
export const integrationOctokitHttpErrorHandler = new IntegrationHttpErrorHandler(new OctokitHttpErrorHandler());
export const integrationPocketBaseHttpErrorHandler = new IntegrationHttpErrorHandler(new PocketBaseHttpErrorHandler());
121 changes: 121 additions & 0 deletions packages/integrations/src/beszel/beszel-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import PocketBase from "pocketbase";
import type { fetch as undiciFetch } from "undici";
import z from "zod";

import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";

import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationPocketBaseHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemUsageIntegration } from "../interfaces/system-usage/system-usage-integration";
import type { System, SystemLoadStatus } from "../interfaces/system-usage/system-usage-types";

@HandleIntegrationErrors([integrationPocketBaseHttpErrorHandler])
export class BeszelIntegration extends Integration implements ISystemUsageIntegration {
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
const client = this.createClient(fetchAsync);
return await this.authenticateAsync(client).then(() => ({ success: true as const }));
}
public async getSystemsAsync() {
const client = this.createClient();
await this.authenticateAsync(client);

const records = await client.collection("systems").getFullList();
const systems = z.array(systemSchema).parse(records);
return systems.map((system) => ({
id: system.id,
name: system.name,
}));
}

public async getSystemDetailsAsync(id: string): Promise<System> {
const client = this.createClient();
await this.authenticateAsync(client);

const record = await client.collection("systems").getOne(id);
const system = systemSchema.parse(record);

return {
id: system.id,
name: system.name,
status: system.status,
agent: {
connectionType: system.info.ct === 1 ? "ssh" : system.info.ct === 2 ? "webSocket" : null,
version: system.info.v,
},
usage: {
cpuPercentage: system.info.cpu,
memoryPercentage: system.info.mp,
diskPercentage: system.info.dp,
gpuPercentage: system.info.g ?? null,
load: {
status: this.calculateLoadStatus(system.status, system.info.la, system.info.t),
averages: {
one: system.info.la[0],
five: system.info.la[1],
fifteen: system.info.la[2],
},
},
networkBytes: system.info.bb,
temperature: system.info.dt ?? null,
},
};
}

private calculateLoadStatus(
systemStatus: BeszelSystem["status"],
loadAverages: [number, number, number],
threatCount: number | undefined,
): SystemLoadStatus {
if (systemStatus !== "up") return "unknown";

const maxLoad = Math.max(...loadAverages);
const loadPerThread = maxLoad / (threatCount ?? 1);
const meter = loadPerThread * 100;
if (meter >= 90) return "critical";
if (meter >= 65) return "warning";
return "good";
}

private createClient(customFetch: typeof undiciFetch = fetchWithTrustedCertificatesAsync) {
const client = new PocketBase(this.url("/").toString());
client.beforeSend = (url, options) => {
options.fetch = customFetch as typeof fetch;
return { url, options };
};
return client;
}

private async authenticateAsync(client: PocketBase) {
return await client
.collection("users")
.authWithPassword(this.getSecretValue("username"), this.getSecretValue("password"));
}
}

const systemStatus = ["up", "down", "paused", "pending"] as const;

/**
* See https://github.com/henrygd/beszel/blob/cca7b360395e3e7e4ef870005efddc3bd75b86c4/internal/entities/system/system.go#L104
*/
const systemSchema = z.object({
id: z.string(),
info: z.object({
cpu: z.number(), // cpu usage in %
mp: z.number(), // memory usage in %
dp: z.number(), // disk usage in %
g: z.number().optional(), // gpu usage in %
la: z.tuple([z.number(), z.number(), z.number()]), // load average for last 1, 5 and 15 minutes
bb: z.number(), // network usage in bytes
dt: z.number().optional(), // dashboard temperature
v: z.string(), // agent version
ct: z.number().optional(), // connection type of agent
t: z.number().optional(), // amount of threads
}),
name: z.string(),
status: z.enum(systemStatus),
});

type BeszelSystem = z.infer<typeof systemSchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { System } from "./system-usage-types";

export interface ISystemUsageIntegration {
getSystemsAsync(): Promise<Pick<System, "id" | "name">[]>;
getSystemDetailsAsync(id: System["id"]): Promise<System>;
}
Loading