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
View File
@@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "mantine-datatable/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
+27
View File
@@ -0,0 +1,27 @@
services:
beszel:
image: henrygd/beszel:latest
container_name: beszel-dev
restart: unless-stopped
environment:
APP_URL: http://localhost:8090
ports:
- 8090:8090
volumes:
- ./beszel_data:/beszel_data
- ./beszel_socket:/beszel_socket
beszel-agent:
image: henrygd/beszel-agent:latest
container_name: beszel-agent-dev
restart: unless-stopped
network_mode: host
volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- ./beszel_socket:/beszel_socket
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
LISTEN: /beszel_socket/beszel.sock
HUB_URL: http://localhost:8090
TOKEN: "${BESZEL_AGENT_TOKEN}"
KEY: "${BESZEL_AGENT_KEY}"
+19 -17
View File
@@ -1604,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
items: items.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
),
items: items
.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
)
.filter((item) => item !== null),
};
};
@@ -1635,7 +1637,7 @@ const parseItem = (item: unknown) => {
const result = outputItemSchema.safeParse(item);
if (!result.success) {
throw new Error(result.error.message);
return null;
}
return result.data;
};
+137
View File
@@ -0,0 +1,137 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
beszelSystemsRequestHandler,
type BeszelAlertsData,
type BeszelStatsData,
type BeszelSystemRow,
} from "@homarr/request-handler/beszel";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSystems: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
return innerHandler.subscribe((systems) => {
emit.next({
integrationId: integration.id,
systems,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((fn) => fn());
};
});
}),
getAlerts: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel"))
.input(
z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
const [alertsResult, systemsResult] = await Promise.all([
alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
]);
const systemNameMap: Record<string, string> = {};
for (const system of systemsResult.data) {
systemNameMap[system.id] = system.name;
}
return {
integrationId: integration.id,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
updatedAt: alertsResult.timestamp,
};
}),
);
return results;
}),
subscribeAlerts: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel"))
.input(
z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().min(1).max(100).default(10),
}),
)
.subscription(({ ctx, input }) => {
return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
return innerHandler.subscribe((data) => {
emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
});
});
return () => {
unsubscribes.forEach((fn) => fn());
};
});
}),
getSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel"))
.input(
z.object({
systemId: z.string(),
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
includeDocker: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) => {
const integration = ctx.integrations[0];
if (!integration) return null;
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
});
+1
View File
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
@@ -10,6 +10,9 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind === "beszel") {
return null as never;
}
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
+8
View File
@@ -433,6 +433,14 @@ export const integrationDefs = {
category: ["ups"],
documentationUrl: null,
},
beszel: {
name: "Beszel",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
category: ["healthMonitoring"],
documentationUrl: null,
defaultPort: 8090,
},
// 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",
+4
View File
@@ -39,5 +39,9 @@ export const widgetKinds = [
"speedtestTracker",
"umami",
"ups",
"beszelSystemTable",
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -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";
+134
View File
@@ -0,0 +1,134 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelContainerStatsRecord,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemStatsRecord,
} from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export interface BeszelSystemRow {
id: string;
name: string;
status: "up" | "down" | "paused" | "pending";
cpu: number;
memory: number;
disk: number;
gpu: number;
loadAvg: [number, number, number] | null;
netBytes: number;
temp: number | null;
battery: [number, number] | null;
services: number;
uptime: number;
agentVersion: string;
hostname: string;
cpuModel: string;
cores: number;
memoryTotal: number;
osName: string;
}
function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
const info = system.info;
return {
id: system.id,
name: system.name,
status: system.status,
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
netBytes: info.bb ?? (info.b ?? 0) * 1_000_000,
temp: info.dt ?? null,
battery: info.bat ?? null,
services: info.sv?.[0] ?? 0,
uptime: info.u,
agentVersion: info.v,
hostname: details?.hostname ?? info.h ?? system.name,
cpuModel: details?.cpu ?? info.m ?? "Unknown",
cores: details?.cores ?? info.ct ?? info.c ?? 1,
memoryTotal: details?.memory ?? 0,
osName: details?.os_name ?? "",
};
}
export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
BeszelSystemRow[],
"beszel",
Record<string, never>
>({
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
systems.map(async (system) => {
const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
return mapToSystemRow(system, details);
}),
);
return enriched;
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "beszelSystems",
});
export interface BeszelAlertsData {
alerts: BeszelAlert[];
history: BeszelAlertHistory[];
}
export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
BeszelAlertsData,
"beszel",
{ includeHistory: boolean; maxHistoryItems: number }
>({
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
const alerts = await instance.getAlertsAsync();
const history = input.includeHistory
? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems)
: [];
return { alerts, history };
},
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "120m", perPage: 84 },
"30d": { type: "480m", perPage: 90 },
};
export interface BeszelStatsData {
systemStats: BeszelSystemStatsRecord[];
containerStats: BeszelContainerStatsRecord[];
}
export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
BeszelStatsData,
"beszel",
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
const config = timePeriodConfig[input.timePeriod] ?? timePeriodConfig["1h"]!;
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
: [];
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "beszelStats",
});
@@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
Record<string, never>
>({
async requestAsync(integration, _input) {
+211
View File
@@ -3101,6 +3101,217 @@
"status": {
"unhealthy": "Unhealthy"
}
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
"option": {
"statusFilter": {
"label": "Status filter"
},
"sortBy": {
"label": "Sort by"
},
"sortDirection": {
"label": "Sort direction"
},
"showCpu": {
"label": "Show CPU"
},
"showMemory": {
"label": "Show memory"
},
"showDisk": {
"label": "Show disk"
},
"showGpu": {
"label": "Show GPU"
},
"showLoadAvg": {
"label": "Show load average"
},
"showNet": {
"label": "Show network"
},
"showTemp": {
"label": "Show temperature"
},
"showBattery": {
"label": "Show battery"
},
"showServices": {
"label": "Show services"
},
"showUptime": {
"label": "Show uptime"
},
"showAgent": {
"label": "Show agent version"
}
},
"noRecords": "No systems found",
"column": {
"system": "System",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"gpu": "GPU",
"loadAvg": "Load Avg",
"net": "Net",
"temp": "Temp",
"battery": "Bat",
"services": "Services",
"uptime": "Uptime",
"agent": "Agent"
}
},
"beszelSystemGrid": {
"name": "Beszel Systems (Grid)",
"description": "Card grid view of all Beszel-monitored systems with real-time metrics",
"option": {
"statusFilter": {
"label": "Status filter"
},
"showCpu": {
"label": "Show CPU"
},
"showMemory": {
"label": "Show memory"
},
"showDisk": {
"label": "Show disk"
},
"showGpu": {
"label": "Show GPU"
},
"showLoadAvg": {
"label": "Show load average"
},
"showNet": {
"label": "Show network"
},
"showTemp": {
"label": "Show temperature"
},
"showBattery": {
"label": "Show battery"
},
"showServices": {
"label": "Show services"
},
"showUptime": {
"label": "Show uptime"
},
"showAgent": {
"label": "Show agent version"
}
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"gpu": "GPU",
"loadAvg": "Load Avg",
"net": "Net",
"temp": "Temp",
"battery": "Bat",
"services": "Services",
"uptime": "Uptime",
"agent": "Agent"
}
},
"beszelAlerts": {
"name": "Beszel Alerts",
"description": "View Beszel alert configurations and history",
"option": {
"showHistory": {
"label": "Show alert history"
},
"maxHistoryItems": {
"label": "Max history items"
}
},
"empty": "No alerts configured",
"history": "History",
"status": {
"triggered": "Triggered",
"ok": "OK"
}
},
"beszelSystemStats": {
"name": "Beszel System Stats",
"description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics",
"option": {
"timePeriod": {
"label": "Time period"
},
"showCpu": {
"label": "Show CPU chart"
},
"showMemory": {
"label": "Show memory chart"
},
"showDisk": {
"label": "Show disk chart"
},
"showDiskIO": {
"label": "Show disk I/O chart"
},
"showNetwork": {
"label": "Show network chart"
},
"showDockerCpu": {
"label": "Show Docker CPU chart"
},
"showDockerMemory": {
"label": "Show Docker memory chart"
},
"showDockerNetwork": {
"label": "Show Docker network chart"
}
},
"selectSystem": "Select system",
"chart": {
"cpu": {
"title": "CPU Usage",
"subtitle": "Average system-wide CPU utilization",
"series": "CPU"
},
"memory": {
"title": "Memory Usage",
"subtitle": "Precise utilization at the recorded time",
"series": "Used"
},
"disk": {
"title": "Disk Usage",
"subtitle": "Usage of root partition",
"series": "Used"
},
"diskIO": {
"title": "Disk I/O",
"subtitle": "Throughput of root filesystem",
"read": "Read",
"write": "Write"
},
"network": {
"title": "Bandwidth",
"subtitle": "Network traffic of public interfaces",
"sent": "Sent",
"recv": "Received"
},
"dockerCpu": {
"title": "Docker CPU Usage",
"subtitle": "Average CPU utilization of containers"
},
"dockerMemory": {
"title": "Docker Memory Usage",
"subtitle": "Memory usage of docker containers"
},
"dockerNetwork": {
"title": "Docker Network I/O",
"subtitle": "Network traffic of docker containers"
}
}
}
},
"widgetPreview": {
@@ -0,0 +1,127 @@
"use client";
import { useMemo } from "react";
import { Badge, Card, Group, Indicator, ScrollArea, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconBell, IconCheck } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(relativeTime);
const alertColorMap: Record<string, string> = {
triggered: "red",
ok: "green",
};
export default function BeszelAlertsWidget({
options,
integrationIds,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({
integrationIds,
includeHistory: options.showHistory,
maxHistoryItems: options.maxHistoryItems,
});
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeAlerts.useSubscription(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
{
onData(data) {
utils.widget.beszel.getAlerts.setData(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
(prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId
? { ...r, alerts: data.alerts.alerts, history: data.alerts.history, updatedAt: data.timestamp }
: r,
);
},
);
},
},
);
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
for (const r of results) {
if (r.systemNameMap) {
Object.assign(map, r.systemNameMap);
}
}
return map;
}, [results]);
const alerts = useMemo(
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
[results],
);
const history = useMemo(() => {
const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` })));
return all.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()).slice(0, options.maxHistoryItems);
}, [results, options.maxHistoryItems]);
return (
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
{alerts.length === 0 && (
<Group gap="xs" justify="center" py="md">
<IconBell size={20} opacity={0.5} />
<Text size="sm" c="dimmed">{t("empty")}</Text>
</Group>
)}
{alerts.map((alert) => {
const state = alert.triggered ? "triggered" : "ok";
const systemName = systemNameMap[alert.system] ?? alert.system;
return (
<Card key={alert._key} padding="xs" radius="sm" withBorder>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Indicator color={alertColorMap[state]} size={8} processing={alert.triggered}>
{alert.triggered ? <IconAlertTriangle size={16} /> : <IconCheck size={16} />}
</Indicator>
<Stack gap={0}>
<Text size="xs" fw={500}>{alert.name} &gt; {alert.value}</Text>
<Text size="xs" c="dimmed">{systemName}</Text>
</Stack>
</Group>
<Badge size="xs" variant="light" color={alertColorMap[state]}>
{t(`status.${state}`)}
</Badge>
</Group>
</Card>
);
})}
{options.showHistory && history.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" mt="xs">{t("history")}</Text>
{history.map((entry) => {
const systemName = systemNameMap[entry.system] ?? entry.system;
return (
<Card key={entry._key} padding="xs" radius="sm" withBorder>
<Group justify="space-between" wrap="nowrap">
<Stack gap={0}>
<Text size="xs" fw={500}>{entry.name}</Text>
<Text size="xs" c="dimmed">{systemName}</Text>
</Stack>
<Text size="xs" c="dimmed">{dayjs(entry.created).fromNow()}</Text>
</Group>
</Card>
);
})}
</>
)}
</Stack>
</ScrollArea>
);
}
@@ -0,0 +1,17 @@
import { IconBell } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
supportedIntegrations: ["beszel"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showHistory: factory.switch({ defaultValue: true }),
maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,205 @@
"use client";
import { useMemo } from "react";
import { Badge, Card, Group, Progress, ScrollArea, SimpleGrid, Text, Stack } from "@mantine/core";
import {
Activity,
Battery,
BatteryCharging,
Cpu,
HardDrive,
MemoryStick,
Monitor,
Network,
Server,
Thermometer,
Wifi,
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor, loadAvgColor } from "../beszel/_shared/colors";
import { formatBytes, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
const ICON_SIZE = 14;
interface MetricRowProps {
icon: React.ReactNode;
label: string;
value: string;
progress?: { value: number; color: string };
}
const MetricRow = ({ icon, label, value, progress }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" py={2}>
{icon}
<Text size="xs" c="dimmed" miw={55}>
{label}
</Text>
{progress ? (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size="xs" fw={500} miw={38} ta="right">
{value}
</Text>
<Progress value={progress.value} color={progress.color} size="sm" style={{ flex: 1 }} />
</Group>
) : (
<Text size="xs" fw={500}>
{value}
</Text>
)}
</Group>
);
const SystemCard = ({ system, options, t }: { system: BeszelSystemRow & { _key: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">> }) => (
<Card padding={6} radius="sm" withBorder>
<Group gap="xs" mb={4}>
<Badge
size="xs"
variant="dot"
color={statusColorMap[system.status]}
styles={{ root: { textTransform: "none" } }}
>
{system.name}
</Badge>
</Group>
<Stack gap={0}>
{options.showCpu && (
<MetricRow
icon={<Cpu size={ICON_SIZE} />}
label={t("metric.cpu")}
value={formatPercent(system.cpu)}
progress={{ value: system.cpu, color: thresholdColor(system.cpu) }}
/>
)}
{options.showMemory && (
<MetricRow
icon={<MemoryStick size={ICON_SIZE} />}
label={t("metric.memory")}
value={formatPercent(system.memory)}
progress={{ value: system.memory, color: thresholdColor(system.memory) }}
/>
)}
{options.showDisk && (
<MetricRow
icon={<HardDrive size={ICON_SIZE} />}
label={t("metric.disk")}
value={formatPercent(system.disk)}
progress={{ value: system.disk, color: thresholdColor(system.disk) }}
/>
)}
{options.showGpu && system.gpu > 0 && (
<MetricRow
icon={<Monitor size={ICON_SIZE} />}
label={t("metric.gpu")}
value={formatPercent(system.gpu)}
progress={{ value: system.gpu, color: thresholdColor(system.gpu) }}
/>
)}
{options.showLoadAvg && system.loadAvg && (
<MetricRow
icon={<Activity size={ICON_SIZE} />}
label={t("metric.loadAvg")}
value={formatLoadAvg(system.loadAvg)}
/>
)}
{options.showNet && (
<MetricRow
icon={<Network size={ICON_SIZE} />}
label={t("metric.net")}
value={formatBytes(system.netBytes)}
/>
)}
{options.showTemp && system.temp !== null && (
<MetricRow
icon={<Thermometer size={ICON_SIZE} />}
label={t("metric.temp")}
value={formatTemp(system.temp, false)}
/>
)}
{options.showBattery && system.battery && (
<MetricRow
icon={system.battery[1] > 0 ? <BatteryCharging size={ICON_SIZE} /> : <Battery size={ICON_SIZE} />}
label={t("metric.battery")}
value={`${system.battery[0]}%`}
/>
)}
{options.showServices && (
<MetricRow
icon={<Server size={ICON_SIZE} />}
label={t("metric.services")}
value={String(system.services)}
/>
)}
{options.showUptime && (
<MetricRow
icon={<Activity size={ICON_SIZE} />}
label={t("metric.uptime")}
value={formatUptime(system.uptime)}
/>
)}
{options.showAgent && (
<MetricRow
icon={<Wifi size={ICON_SIZE} />}
label={t("metric.agent")}
value={system.agentVersion}
/>
)}
</Stack>
</Card>
);
export default function BeszelSystemGridWidget({
options,
integrationIds,
width,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeSystems.useSubscription(
{ integrationIds },
{
onData(data) {
utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r,
);
});
},
},
);
const allSystems = useMemo(
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
[results],
);
const filteredSystems = useMemo(() => {
if (options.statusFilter === "all") return allSystems;
return allSystems.filter((s) => s.status === options.statusFilter);
}, [allSystems, options.statusFilter]);
const getColCount = (w: number) => {
if (w > 1200) return 4;
if (w > 800) return 3;
if (w > 500) return 2;
return 1;
};
return (
<ScrollArea h="100%">
<SimpleGrid cols={getColCount(width)} spacing="xs" p={0}>
{filteredSystems.map((system) => (
<SystemCard key={system._key} system={system} options={options} t={t} />
))}
</SimpleGrid>
</ScrollArea>
);
}
@@ -0,0 +1,35 @@
import { IconLayoutGrid } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const statusOptions = [
{ value: "all", label: "All Systems" },
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
{ value: "paused", label: "Paused" },
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
supportedIntegrations: ["beszel"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
}),
);
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,361 @@
"use client";
import { useMemo, useState } from "react";
import { Group, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { AreaChart } from "@mantine/charts";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
};
const formatBytesPerSec = (bytes: number) => `${formatBytes(bytes)}/s`;
const formatPercent = (v: number) => `${v.toFixed(1)}%`;
const formatGB = (v: number) => `${v.toFixed(2)} GB`;
const formatMB = (v: number) => `${v.toFixed(1)} MB`;
const formatMBs = (v: number) => `${v.toFixed(2)} MB/s`;
const compactTooltipProps = {
wrapperStyle: { zIndex: 1000, pointerEvents: "none" as const },
contentStyle: { padding: "4px 8px", fontSize: 11, lineHeight: 1.3 },
labelStyle: { fontSize: 11, marginBottom: 2 },
itemStyle: { padding: 0, fontSize: 11 },
};
interface ChartPanelProps {
title: string;
subtitle?: string;
children: React.ReactNode;
}
const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
<Stack gap={4}>
<Group gap="xs">
<Text size="sm" fw={600}>{title}</Text>
{subtitle && <Text size="xs" c="dimmed">{subtitle}</Text>}
</Group>
{children}
</Stack>
);
export default function BeszelSystemStatsWidget({
options,
integrationIds,
width,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
[systemsResult],
);
const [selectedSystem, setSelectedSystem] = useState<string | null>(systems[0]?.value ?? null);
const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({
integrationIds,
systemId: selectedSystem ?? systems[0]?.value ?? "",
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork,
});
const chartHeight = 180;
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const cpuData = useMemo(() => {
if (!statsResult?.systemStats) return [];
return [...statsResult.systemStats].reverse().map((r) => ({
time: formatTime(r.created),
[t("chart.cpu.series")]: r.stats.cpu,
}));
}, [statsResult, t]);
const memoryData = useMemo(() => {
if (!statsResult?.systemStats) return [];
return [...statsResult.systemStats].reverse().map((r) => ({
time: formatTime(r.created),
[t("chart.memory.series")]: r.stats.mu / (1024 * 1024 * 1024),
}));
}, [statsResult, t]);
const diskData = useMemo(() => {
if (!statsResult?.systemStats) return [];
return [...statsResult.systemStats].reverse().map((r) => ({
time: formatTime(r.created),
[t("chart.disk.series")]: r.stats.du / (1024 * 1024 * 1024),
}));
}, [statsResult, t]);
const diskIOData = useMemo(() => {
if (!statsResult?.systemStats) return [];
return [...statsResult.systemStats].reverse().map((r) => ({
time: formatTime(r.created),
[t("chart.diskIO.read")]: r.stats.dr / (1024 * 1024),
[t("chart.diskIO.write")]: r.stats.dw / (1024 * 1024),
}));
}, [statsResult, t]);
const networkData = useMemo(() => {
if (!statsResult?.systemStats) return [];
return [...statsResult.systemStats].reverse().map((r) => ({
time: formatTime(r.created),
[t("chart.network.sent")]: r.stats.ns,
[t("chart.network.recv")]: r.stats.nr,
}));
}, [statsResult, t]);
const containerNames = useMemo(() => {
if (!statsResult?.containerStats?.length) return [];
const names = new Set<string>();
for (const record of statsResult.containerStats) {
for (const c of record.stats) {
names.add(c.n);
}
}
return [...names].slice(0, 15);
}, [statsResult]);
const dockerCpuData = useMemo(() => {
if (!statsResult?.containerStats?.length) return [];
return [...statsResult.containerStats].reverse().map((record) => {
const point: Record<string, unknown> = { time: formatTime(record.created) };
for (const name of containerNames) {
const container = record.stats.find((c) => c.n === name);
point[name] = container?.c ?? 0;
}
return point;
});
}, [statsResult, containerNames]);
const dockerMemData = useMemo(() => {
if (!statsResult?.containerStats?.length) return [];
return [...statsResult.containerStats].reverse().map((record) => {
const point: Record<string, unknown> = { time: formatTime(record.created) };
for (const name of containerNames) {
const container = record.stats.find((c) => c.n === name);
point[name] = (container?.m ?? 0) / (1024 * 1024);
}
return point;
});
}, [statsResult, containerNames]);
const dockerNetData = useMemo(() => {
if (!statsResult?.containerStats?.length) return [];
return [...statsResult.containerStats].reverse().map((record) => {
const point: Record<string, unknown> = { time: formatTime(record.created) };
for (const name of containerNames) {
const container = record.stats.find((c) => c.n === name);
const netTotal = (container?.ns ?? 0) + (container?.nr ?? 0);
point[name] = netTotal / 1024;
}
return point;
});
}, [statsResult, containerNames]);
const containerColors = [
"blue.6", "teal.6", "orange.6", "grape.6", "cyan.6",
"pink.6", "lime.6", "violet.6", "indigo.6", "yellow.6",
"red.6", "green.6", "blue.4", "teal.4", "orange.4",
];
const containerSeries = containerNames.map((name, i) => ({
name,
color: containerColors[i % containerColors.length]!,
}));
const cols = width > 600 ? 2 : 1;
return (
<ScrollArea h="100%">
<Stack gap="md" p="sm">
<Select
size="xs"
data={systems}
value={selectedSystem}
onChange={setSelectedSystem}
placeholder={t("selectSystem")}
/>
{statsResult && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
<AreaChart
h={chartHeight}
data={cpuData}
dataKey="time"
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
curveType="monotone"
withDots={false}
fillOpacity={0.4}
yAxisProps={{ domain: [0, 100], tickFormatter: (v: number) => `${v}%` }}
valueFormatter={formatPercent}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
/>
</ChartPanel>
)}
{options.showMemory && memoryData.length > 0 && (
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
<AreaChart
h={chartHeight}
data={memoryData}
dataKey="time"
series={[{ name: t("chart.memory.series"), color: "teal.6" }]}
curveType="monotone"
withDots={false}
fillOpacity={0.4}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} GB` }}
valueFormatter={formatGB}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
/>
</ChartPanel>
)}
{options.showDisk && diskData.length > 0 && (
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
<AreaChart
h={chartHeight}
data={diskData}
dataKey="time"
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
curveType="monotone"
withDots={false}
fillOpacity={0.4}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} GB` }}
valueFormatter={formatGB}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
/>
</ChartPanel>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
<AreaChart
h={chartHeight}
data={diskIOData}
dataKey="time"
series={[
{ name: t("chart.diskIO.read"), color: "orange.6" },
{ name: t("chart.diskIO.write"), color: "blue.6" },
]}
curveType="monotone"
withDots={false}
fillOpacity={0.3}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(1)} MB/s` }}
valueFormatter={formatMBs}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
withLegend
/>
</ChartPanel>
)}
{options.showNetwork && networkData.length > 0 && (
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
<AreaChart
h={chartHeight}
data={networkData}
dataKey="time"
series={[
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
]}
curveType="monotone"
withDots={false}
fillOpacity={0.3}
yAxisProps={{ tickFormatter: (v: number) => formatBytesPerSec(v) }}
valueFormatter={(v: number) => formatBytesPerSec(v)}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
withLegend
/>
</ChartPanel>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
<AreaChart
h={chartHeight}
data={dockerCpuData}
dataKey="time"
type="stacked"
series={containerSeries}
curveType="monotone"
withDots={false}
fillOpacity={0.6}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(2)}%` }}
valueFormatter={formatPercent}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
/>
</ChartPanel>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
<AreaChart
h={chartHeight}
data={dockerMemData}
dataKey="time"
type="stacked"
series={containerSeries}
curveType="monotone"
withDots={false}
fillOpacity={0.6}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} MB` }}
valueFormatter={formatMB}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
/>
</ChartPanel>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
<AreaChart
h={chartHeight}
data={dockerNetData}
dataKey="time"
type="stacked"
series={containerSeries}
curveType="monotone"
withDots={false}
fillOpacity={0.6}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} KB/s` }}
valueFormatter={(v: number) => `${v.toFixed(1)} KB/s`}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
/>
</ChartPanel>
)}
</>
)}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
);
}
@@ -0,0 +1,31 @@
import { IconChartAreaLine } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const timePeriodOptions = [
{ value: "1h", label: "1 Hour" },
{ value: "12h", label: "12 Hours" },
{ value: "24h", label: "24 Hours" },
{ value: "1w", label: "1 Week" },
{ value: "30d", label: "30 Days" },
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
supportedIntegrations: ["beszel"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showDiskIO: factory.switch({ defaultValue: true }),
showNetwork: factory.switch({ defaultValue: true }),
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,280 @@
"use client";
import "./styles.css";
import { useMemo, useState } from "react";
import { Group, Indicator, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
Activity,
Battery,
Cpu,
HardDrive,
MemoryStick,
Monitor,
Network,
Server,
Thermometer,
Wifi,
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor, loadAvgColor } from "../beszel/_shared/colors";
import { formatBytes, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
interface SizeConfig {
iconSize: number;
fontSize: "xs" | "10px";
progressSize: "xs" | "sm";
cellPadding: number;
valueMiw: number;
}
const getSizeConfig = (width: number): SizeConfig => {
if (width < 400) {
return { iconSize: 10, fontSize: "10px", progressSize: "xs", cellPadding: 2, valueMiw: 30 };
}
return { iconSize: 14, fontSize: "xs", progressSize: "sm", cellPadding: 4, valueMiw: 38 };
};
export default function BeszelSystemTableWidget({
options,
integrationIds,
width,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const utils = clientApi.useUtils();
const size = getSizeConfig(width);
clientApi.widget.beszel.subscribeSystems.useSubscription(
{ integrationIds },
{
onData(data) {
utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r,
);
});
},
},
);
const allSystems = useMemo(
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
[results],
);
const filteredSystems = useMemo(() => {
if (options.statusFilter === "all") return allSystems;
return allSystems.filter((s) => s.status === options.statusFilter);
}, [allSystems, options.statusFilter]);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc",
});
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...filteredSystems].sort((a, b) => {
const aVal = a[accessor] ?? 0;
const bVal = b[accessor] ?? 0;
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
return 0;
});
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
</Group>
);
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
accessor: "name",
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.system")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={4} wrap="nowrap">
<Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
</Text>
</Group>
),
},
options.showCpu && {
accessor: "cpu",
title: (
<Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} />
<Text inherit>{t("column.cpu")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
},
options.showMemory && {
accessor: "memory",
title: (
<Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} />
<Text inherit>{t("column.memory")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
},
options.showDisk && {
accessor: "disk",
title: (
<Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} />
<Text inherit>{t("column.disk")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.disk} />,
},
options.showGpu && {
accessor: "gpu",
title: (
<Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} />
<Text inherit>{t("column.gpu")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
},
options.showLoadAvg && {
accessor: "loadAvg",
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.loadAvg")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={4} wrap="nowrap">
<Indicator
color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"}
size={7}
/>
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
</Group>
),
},
options.showNet && {
accessor: "netBytes",
title: (
<Group gap={4} wrap="nowrap">
<Network size={size.iconSize} />
<Text inherit>{t("column.net")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatBytes(record.netBytes)}</Text>,
},
options.showTemp && {
accessor: "temp",
title: (
<Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} />
<Text inherit>{t("column.temp")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
accessor: "battery",
title: (
<Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} />
<Text inherit>{t("column.battery")}</Text>
</Group>
),
render: (record) => (
<Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>
),
},
options.showServices && {
accessor: "services",
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.services")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
accessor: "uptime",
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.uptime")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
accessor: "agentVersion",
title: (
<Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} />
<Text inherit>{t("column.agent")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.agentVersion}</Text>,
},
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]);
return (
<DataTable
withTableBorder={false}
borderRadius={0}
highlightOnHover
fz={size.fontSize}
records={sortedSystems}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
height="100%"
className="beszel-table"
/>
);
}
@@ -0,0 +1,56 @@
import { IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const statusOptions = [
{ value: "all", label: "All Systems" },
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
{ value: "paused", label: "Paused" },
];
const sortOptions = [
{ value: "name", label: "System" },
{ value: "cpu", label: "CPU" },
{ value: "memory", label: "Memory" },
{ value: "disk", label: "Disk" },
{ value: "gpu", label: "GPU" },
{ value: "loadAvg", label: "Load Avg" },
{ value: "netBytes", label: "Net" },
{ value: "temp", label: "Temp" },
{ value: "services", label: "Services" },
{ value: "uptime", label: "Uptime" },
{ value: "agentVersion", label: "Agent" },
];
const sortDirectionOptions = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
supportedIntegrations: ["beszel"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
sortDirection: factory.select({ defaultValue: "asc", options: sortDirectionOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
}),
);
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,18 @@
.beszel-table th {
white-space: nowrap;
}
.beszel-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.beszel-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
@@ -0,0 +1,21 @@
import type { BeszelSystemStatus } from "./types";
export const thresholdColor = (value: number): string => {
if (value >= 80) return "red";
if (value >= 50) return "yellow";
return "green";
};
export const loadAvgColor = (la1: number, cores: number): string => {
const ratio = la1 / cores;
if (ratio >= 1.5) return "red";
if (ratio >= 0.8) return "yellow";
return "green";
};
export const statusColorMap: Record<BeszelSystemStatus, string> = {
up: "green",
down: "red",
paused: "gray",
pending: "yellow",
};
@@ -0,0 +1,29 @@
export const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(value < 10 ? 2 : 1)} ${units[i]}`;
};
export const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days > 0) return `${days} day${days > 1 ? "s" : ""}`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`;
const minutes = Math.floor((seconds % 3600) / 60);
return `${minutes} min`;
};
export const formatTemp = (celsius: number | null, fahrenheit: boolean): string => {
if (celsius === null) return "—";
if (fahrenheit) return `${((celsius * 9) / 5 + 32).toFixed(1)} °F`;
return `${celsius.toFixed(2)} °C`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const formatLoadAvg = (la: [number, number, number] | null): string => {
if (!la) return "—";
return la.map((v) => v.toFixed(2)).join(" ");
};
@@ -0,0 +1,23 @@
export type BeszelSystemStatus = "up" | "down" | "paused" | "pending";
export interface BeszelSystemRow {
id: string;
name: string;
status: BeszelSystemStatus;
cpu: number;
memory: number;
disk: number;
gpu: number;
loadAvg: [number, number, number] | null;
netBytes: number;
temp: number | null;
battery: [number, number] | null;
services: number;
uptime: number;
agentVersion: string;
hostname: string;
cpuModel: string;
cores: number;
memoryTotal: number;
osName: string;
}
+8
View File
@@ -48,6 +48,10 @@ import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
import * as beszelSystemTable from "./beszel-system-table";
import * as beszelSystemGrid from "./beszel-system-grid";
import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
@@ -95,6 +99,10 @@ export const widgetImports = {
speedtestTracker,
umami,
ups,
beszelSystemTable,
beszelSystemGrid,
beszelAlerts,
beszelSystemStats,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;