feat(integrations): recover beszel integration from worktree

Recovered all beszel integration files from the local worktree
at /Users/ajnart/.cursor/worktrees/homarr/pwxp that were built
and tested with the homarr:beszel docker image.
This commit is contained in:
Thomas Camlong committed 2026-06-08 12:54:30 +02:00
1 parent 87d3f6a0a1
commit 6757fbe956
29 files changed
+2526 -18

No files matched your search

@@ -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 { AnchorIntegration } from "../anchor/anchor-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
@@ -125,6 +126,7 @@ export const integrationCreators = {
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -0,0 +1,296 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelAuthResponse,
BeszelContainer,
BeszelContainerStatsRecord,
BeszelSmartDevice,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
CreateAlertInput,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
const escapeFilterValue = (value: string) => value.replace(/'/g, "\\'");
interface BeszelSession {
token: string;
userId: string;
}
export class BeszelIntegration extends Integration {
private readonly sessionStore: SessionStore<BeszelSession>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync();
if (existingSession) {
return existingSession;
}
const response = await fetchWithTrustedCertificatesAsync(
this.url("/api/collections/users/auth-with-password"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
},
);
if (!response.ok) {
throw new ResponseError(response);
}
const data = (await response.json()) as BeszelAuthResponse;
const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session);
return session;
}
private async fetchWithAuthAsync(
url: URL,
options: { method?: string; body?: string } = {},
) {
let session = await this.authenticateAsync();
const doFetch = (token: string) =>
fetchWithTrustedCertificatesAsync(url, {
method: options.method ?? "GET",
headers: {
Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}),
},
body: options.body,
});
let response = await doFetch(session.token);
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await doFetch(session.token);
}
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
public async getSystemsAsync(): Promise<BeszelSystem[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/systems/records", { perPage: "500", sort: "-updated" }),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystem>;
return data.items;
}
public async getSystemDetailsAsync(systemId: string): Promise<BeszelSystemDetails> {
const response = await this.fetchWithAuthAsync(
this.url(`/api/collections/system_details/records/${systemId}` as `/${string}`),
);
return (await response.json()) as BeszelSystemDetails;
}
public async getSystemStatsAsync(
systemId: string,
type = "1m",
perPage = 60,
): Promise<BeszelSystemStatsRecord[]> {
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/system_stats/records", {
filter,
sort: "-created",
perPage: String(perPage),
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemStatsRecord>;
return data.items;
}
public async getContainersAsync(systemId: string): Promise<BeszelContainer[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/containers/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
sort: "-updated",
perPage: "500",
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelContainer>;
return data.items;
}
public async getContainerStatsAsync(
systemId: string,
type = "1m",
perPage = 60,
): Promise<BeszelContainerStatsRecord[]> {
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/container_stats/records", {
filter,
sort: "-created",
perPage: String(perPage),
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelContainerStatsRecord>;
return data.items;
}
public async getSmartDevicesAsync(systemId: string): Promise<BeszelSmartDevice[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/smart_devices/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSmartDevice>;
return data.items;
}
public async getSystemdServicesAsync(systemId: string): Promise<BeszelSystemdService[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/systemd_services/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
sort: "name",
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemdService>;
return data.items;
}
public async getAlertsAsync(systemId?: string): Promise<BeszelAlert[]> {
const params: Record<string, string> = { perPage: "500" };
if (systemId) {
params.filter = `system='${escapeFilterValue(systemId)}'`;
}
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/alerts/records", params),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelAlert>;
return data.items;
}
public async getAlertHistoryAsync(systemId?: string, perPage = 50): Promise<BeszelAlertHistory[]> {
const params: Record<string, string> = {
sort: "-created",
perPage: String(perPage),
};
if (systemId) {
params.filter = `system='${escapeFilterValue(systemId)}'`;
}
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/alerts_history/records", params),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelAlertHistory>;
return data.items;
}
public async createAlertAsync(systemId: string, input: CreateAlertInput): Promise<BeszelAlert> {
const session = await this.authenticateAsync();
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/alerts/records"),
{
method: "POST",
body: JSON.stringify({
system: systemId,
user: session.userId,
name: input.name,
value: input.value,
min: input.min ?? 0,
triggered: false,
}),
},
);
return (await response.json()) as BeszelAlert;
}
public async updateAlertAsync(alertId: string, input: UpdateAlertInput): Promise<BeszelAlert> {
const response = await this.fetchWithAuthAsync(
this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`),
{
method: "PATCH",
body: JSON.stringify(input),
},
);
return (await response.json()) as BeszelAlert;
}
public async deleteAlertAsync(alertId: string): Promise<void> {
await this.fetchWithAuthAsync(
this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`),
{ method: "DELETE" },
);
}
public async pauseSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(
this.url(`/api/collections/systems/records/${systemId}` as `/${string}`),
{
method: "PATCH",
body: JSON.stringify({ status: "paused" }),
},
);
}
public async resumeSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(
this.url(`/api/collections/systems/records/${systemId}` as `/${string}`),
{
method: "PATCH",
body: JSON.stringify({ status: "pending" }),
},
);
}
public async deleteSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(
this.url(`/api/collections/systems/records/${systemId}` as `/${string}`),
{ method: "DELETE" },
);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) {
return TestConnectionError.StatusResult({ status: response.status, url: response.url });
}
const data = (await response.json()) as BeszelAuthResponse;
if (!data.token) {
return TestConnectionError.UnauthorizedResult(401);
}
return { success: true };
}
}
@@ -0,0 +1,244 @@
export interface BeszelAuthResponse {
token: string;
record: {
id: string;
email: string;
username: string;
verified: boolean;
};
}
export type BeszelSystemStatus = "up" | "down" | "paused" | "pending";
export interface BeszelSystemInfo {
h?: string;
cpu: number;
t?: number;
c?: number;
ct?: number;
m?: string;
la?: [number, number, number];
o?: string;
u: number;
mp: number;
dp: number;
bat?: [number, number];
b?: number;
bb?: number;
v: string;
p?: boolean;
g?: number;
dt?: number;
os?: number;
efs?: Record<string, number>;
sv?: [number, number];
}
export interface BeszelSystem {
id: string;
name: string;
host: string;
port: string;
status: BeszelSystemStatus;
info: BeszelSystemInfo;
users?: string[];
created: string;
updated: string;
}
export interface BeszelSystemDetails {
id: string;
system: string;
hostname: string;
kernel: string;
cores: number;
threads: number;
cpu: string;
os: number;
os_name: string;
arch: string;
memory: number;
podman: boolean;
updated: string;
}
export interface BeszelSystemStats {
cpu: number;
cpum?: number;
cpub?: number[];
cpus?: number[];
la?: [number, number, number];
m: number;
mu: number;
mp: number;
mb: number;
mm?: number;
mz?: number;
s: number;
su: number;
d: number;
du: number;
dp: number;
dr: number;
dw: number;
drm?: number;
dwm?: number;
dio?: [number, number];
diom?: [number, number];
ns: number;
nr: number;
b?: [number, number];
nsm?: number;
nrm?: number;
bm?: [number, number];
t?: Record<string, number>;
efs?: Record<string, BeszelExtraFsStats>;
g?: Record<string, BeszelGPUData>;
bat?: [number, number];
ni?: Record<string, [number, number, number, number]>;
}
export interface BeszelExtraFsStats {
d: number;
du: number;
r: number;
w: number;
rm: number;
wm: number;
rb: number;
wb: number;
rbm: number;
wbm: number;
}
export interface BeszelGPUData {
n: string;
mu?: number;
mt?: number;
u: number;
p?: number;
pp?: number;
e?: Record<string, number>;
}
export interface BeszelSystemStatsRecord {
id: string;
system: string;
stats: BeszelSystemStats;
type: string;
created: string;
updated: string;
}
export interface BeszelContainer {
id: string;
system: string;
name: string;
image: string;
status: string;
health: number;
cpu: number;
memory: number;
net: number;
ports?: string;
updated: number;
}
export interface BeszelContainerStats {
n: string;
c: number;
m: number;
ns?: number;
nr?: number;
b?: [number, number];
}
export interface BeszelContainerStatsRecord {
id: string;
system: string;
stats: BeszelContainerStats[];
type: string;
created: string;
updated: string;
}
export interface BeszelSmartDevice {
id: string;
system: string;
name: string;
model: string;
state: string;
capacity: number;
temp: number;
firmware: string;
serial: string;
type: string;
hours: number;
cycles: number;
attributes: BeszelSmartAttribute[];
updated: string;
}
export interface BeszelSmartAttribute {
id?: number;
n: string;
v: number;
w?: number;
t?: number;
rv?: number;
rs?: string;
wf?: string;
}
export interface BeszelSystemdService {
id: string;
system: string;
name: string;
state: number;
sub: number;
cpu: number;
cpuPeak: number;
memory: number;
memPeak: number;
updated: number;
}
export interface BeszelAlert {
id: string;
user: string;
system: string;
name: string;
triggered: boolean;
value: number;
min: number;
}
export interface BeszelAlertHistory {
id: string;
alert: string;
user: string;
system: string;
name: string;
val: number;
created: string;
resolved?: string | null;
}
export interface CreateAlertInput {
name: string;
value: number;
min?: number;
}
export interface UpdateAlertInput {
value?: number;
min?: number;
}
export interface PocketBaseListResponse<T> {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: T[];
}
@@ -0,0 +1,226 @@
// @vitest-environment node
import { beforeAll, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
process.env.ENABLE_DNS_CACHING = "false";
});
import type { BeszelSystem } from "../beszel-types";
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
vi.mock("@homarr/redis", () => ({
createGetSetChannel: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
removeAsync: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
ErrorWithMetadata: class extends Error {},
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration } from "../beszel-integration";
function createBeszelIntegration() {
return new BeszelIntegration({
id: "test-beszel",
name: "Test Beszel",
url: BESZEL_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: BESZEL_EMAIL },
{ kind: "password", value: BESZEL_PASSWORD },
],
});
}
const shouldRun = BESZEL_EMAIL && BESZEL_PASSWORD;
describe.skipIf(!shouldRun)("BeszelIntegration (real instance)", () => {
let integration: BeszelIntegration;
let systemId: string;
beforeAll(() => {
integration = createBeszelIntegration();
});
describe("getSystemsAsync", () => {
test("returns an array of systems", async () => {
const systems = await integration.getSystemsAsync();
expect(systems).toBeInstanceOf(Array);
expect(systems.length).toBeGreaterThan(0);
});
test("each system has required fields", async () => {
const systems = await integration.getSystemsAsync();
const system = systems[0]!;
systemId = system.id;
expect(system.id).toBeDefined();
expect(system.name).toBeDefined();
expect(system.status).toMatch(/^(up|down|paused|pending)$/);
expect(system.info).toBeDefined();
expect(typeof system.info.cpu).toBe("number");
expect(typeof system.info.mp).toBe("number");
expect(typeof system.info.dp).toBe("number");
});
});
describe("getSystemDetailsAsync", () => {
test("returns system details with hardware info", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]!.id;
const details = await integration.getSystemDetailsAsync(systemId);
expect(details.hostname).toBeDefined();
expect(details.cpu).toBeDefined();
expect(typeof details.cores).toBe("number");
expect(details.cores).toBeGreaterThan(0);
expect(typeof details.memory).toBe("number");
expect(details.memory).toBeGreaterThan(0);
expect(details.os_name).toBeDefined();
});
});
describe("getSystemStatsAsync", () => {
test("returns time-series stats records", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]!.id;
const stats = await integration.getSystemStatsAsync(systemId, "1m", 5);
expect(stats).toBeInstanceOf(Array);
if (stats.length === 0) return;
const record = stats[0]!;
expect(record.stats).toBeDefined();
expect(typeof record.stats.cpu).toBe("number");
expect(typeof record.stats.mp).toBe("number");
expect(typeof record.stats.dp).toBe("number");
expect(record.created).toBeDefined();
});
});
describe("getContainersAsync", () => {
test("returns container list", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]!.id;
const containers = await integration.getContainersAsync(systemId);
expect(containers).toBeInstanceOf(Array);
if (containers.length > 0) {
const container = containers[0]!;
expect(container.name).toBeDefined();
expect(container.image).toBeDefined();
expect(container.status).toBeDefined();
expect(typeof container.cpu).toBe("number");
expect(typeof container.memory).toBe("number");
}
});
});
describe("alert CRUD", () => {
let alertId: string;
test("creates an alert", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]!.id;
const alert = await integration.createAlertAsync(systemId, {
name: "CPU",
value: 90,
min: 1,
});
expect(alert.id).toBeDefined();
expect(alert.name).toBe("CPU");
expect(alert.value).toBe(90);
expect(alert.system).toBe(systemId);
alertId = alert.id;
});
test("lists alerts including the created one", async () => {
const alerts = await integration.getAlertsAsync(systemId);
const found = alerts.find((a) => a.id === alertId);
expect(found).toBeDefined();
expect(found!.name).toBe("CPU");
});
test("updates alert threshold", async () => {
const updated = await integration.updateAlertAsync(alertId, { value: 95 });
expect(updated.value).toBe(95);
});
test("deletes alert", async () => {
await integration.deleteAlertAsync(alertId);
const alerts = await integration.getAlertsAsync(systemId);
const found = alerts.find((a) => a.id === alertId);
expect(found).toBeUndefined();
});
});
describe("system actions", () => {
test("pauses and resumes a system", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]!.id;
await integration.pauseSystemAsync(systemId);
const afterPause = await integration.getSystemsAsync();
const paused = afterPause.find((s) => s.id === systemId);
expect(paused?.status).toBe("paused");
await integration.resumeSystemAsync(systemId);
const afterResume = await integration.getSystemsAsync();
const resumed = afterResume.find((s) => s.id === systemId);
expect(resumed?.status).toMatch(/^(up|pending)$/);
});
});
describe("authentication", () => {
test("authenticates with valid credentials", async () => {
const systems = await integration.getSystemsAsync();
expect(systems.length).toBeGreaterThan(0);
});
test("fails with invalid credentials", async () => {
const badIntegration = new BeszelIntegration({
id: "test-bad",
name: "Bad Beszel",
url: BESZEL_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "wrong@example.com" },
{ kind: "password", value: "wrongpassword" },
],
});
await expect(badIntegration.getSystemsAsync()).rejects.toThrow();
});
});
});
+1
View File
@@ -18,3 +18,4 @@ export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./umami/umami-types";
export * from "./beszel/beszel-types";