-
-
Notifications
You must be signed in to change notification settings - Fork 141
feat(integrations): add beszel #4295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
4d9473f
8b519f4
785aa4d
28fdfed
d0f30bc
59652bb
26f3b57
f31f1c4
3d3c7fe
d1db4f4
3101e61
5a8af91
1da41c2
1e51d3f
07c123d
c322929
8d975c6
a4a2165
1cf2c5b
3a144b0
2228726
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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": [ | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would be your suggestion instead?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
To decide that, we need to know the differences between them.
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?)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh wait we are back in normal time, true 😂 |
| 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(); | ||
| }; | ||
| }); | ||
| }), | ||
| }); |
| 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 }); | ||
| } | ||
| } |
| 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! }), | ||
| }, | ||
| }), | ||
| ); |
| 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>; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.