Compare commits

...
Author SHA1 Message Date
Thomas Camlong ae294b9d88 feat(integrations): add beszel system usage integration 2026-06-08 12:43:45 +02:00
28 changed files with 955 additions and 2 deletions

No files matched your search

+13 -1
View File
@@ -13,7 +13,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import { publicProcedure } from "../trpc";
export type IntegrationAction = "query" | "interact";
export type IntegrationAction = "query" | "interact" | "use";
/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
@@ -167,6 +167,18 @@ const throwIfActionIsNotAllowedAsync = async (
});
}
if (action === "use") {
const haveAllUseAccess = integrations
.map((integration) => constructIntegrationPermissions(integration, session))
.every(({ hasUseAccess }) => hasUseAccess);
if (haveAllUseAccess) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to use at least one of the specified integrations",
});
}
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
if (hasQueryAccess) return;
+1
View File
@@ -32,4 +32,5 @@ export const widgetRouter = createTRPCRouter({
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
systemUsage: lazy(() => import("./system-usage").then((mod) => mod.systemUsageRouter)),
});
@@ -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();
};
});
}),
});
+2
View File
@@ -18,6 +18,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { systemUsageJob } from "./jobs/integrations/system-usage";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
@@ -55,6 +56,7 @@ const getJobGroup = () => {
refreshNotifications: refreshNotificationsJob,
weather: weatherJob,
timetable: timetableJob,
systemUsage: systemUsageJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
});
@@ -0,0 +1,20 @@
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) =>
options.systemId
? {
systemId: options.systemId.value,
}
: [],
},
}),
);
@@ -156,6 +156,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/adguard-home"
| "/docs/integrations/anchor"
| "/docs/integrations/aria2"
| "/docs/integrations/beszel"
| "/docs/integrations/codeberg"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
+9
View File
@@ -370,6 +370,13 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
defaultPort: 80,
},
beszel: {
name: "Beszel",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/beszel.svg",
category: ["systemUsage"],
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
},
unraid: {
name: "Unraid",
secretKinds: [["apiKey"]],
@@ -451,6 +458,7 @@ export const integrationDefs = {
"networkController",
"notifications",
"smartHomeServer",
"systemUsage",
"ups",
],
documentationUrl: null,
@@ -539,6 +547,7 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"systemUsage",
"ups",
] as const;
+1
View File
@@ -30,6 +30,7 @@ export const widgetKinds = [
"firewall",
"notifications",
"systemResources",
"systemUsage",
"coolify",
"systemDisks",
"timetable",
@@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
@@ -116,6 +117,7 @@ export const integrationCreators = {
ntfy: NTFYIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
beszel: BeszelIntegration,
unraid: UnraidIntegration,
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
@@ -0,0 +1,143 @@
import type { fetch as undiciFetch } from "undici";
import z from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ResponseError } from "@homarr/common/server";
import { HandleIntegrationErrors } from "../base/errors/decorator";
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 { AgentConnectionType, System, SystemLoadStatus } from "../interfaces/system-usage/system-usage-types";
@HandleIntegrationErrors([])
export class BeszelIntegration extends Integration implements ISystemUsageIntegration {
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
await this.authenticateAsync(fetchAsync);
return { success: true as const };
}
public async getSystemsAsync() {
const token = await this.authenticateAsync();
const response = await this.fetchApi("/api/collections/systems/records?perPage=500", token);
const data = z.object({ items: z.array(systemSchema) }).parse(response);
return data.items.map((system) => ({
id: system.id,
name: system.name,
}));
}
public async getSystemDetailsAsync(id: string): Promise<System> {
const token = await this.authenticateAsync();
const response = await this.fetchApi(`/api/collections/systems/records/${id}`, token);
const system = systemSchema.parse(response);
return {
id: system.id,
name: system.name,
status: system.status,
agent: {
connectionType: system.info.ct !== undefined ? (connectionTypeByCode[system.info.ct] ?? null) : 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 async fetchApi(
path: string,
token: string,
customFetch: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
) {
const response = await customFetch(this.url(path).toString(), {
headers: { Authorization: token },
});
if (!response.ok) {
throw new ResponseError(response);
}
return await response.json();
}
private async authenticateAsync(customFetch: typeof undiciFetch = fetchWithTrustedCertificatesAsync) {
const response = await customFetch(this.url("/api/collections/users/auth-with-password").toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) {
// PocketBase returns 400 for failed auth — remap to 401 for proper authorization error classification
const status = response.status === 400 ? 401 : response.status;
throw new ResponseError({ status, url: response.url });
}
const data = authResponseSchema.parse(await response.json());
return data.token;
}
}
const connectionTypeByCode: Record<number, AgentConnectionType> = {
1: "ssh",
2: "webSocket",
};
const authResponseSchema = z.object({
token: z.string(),
});
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(),
mp: z.number(),
dp: z.number(),
g: z.number().optional(),
la: z.tuple([z.number(), z.number(), z.number()]),
bb: z.number(),
dt: z.number().optional(),
v: z.string(),
ct: z.number().optional(),
t: z.number().optional(),
}),
name: z.string(),
status: z.enum(systemStatus),
});
type BeszelSystem = z.infer<typeof systemSchema>;
@@ -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>;
}
@@ -0,0 +1,31 @@
export type SystemLoadStatus = "good" | "warning" | "critical" | "unknown";
export type SystemStatus = "up" | "down" | "paused" | "pending";
export type AgentConnectionType = "ssh" | "webSocket";
export interface System {
id: string;
name: string;
status: SystemStatus;
usage: SystemUsage;
agent: {
connectionType: AgentConnectionType | null;
version: string;
};
}
export interface SystemUsage {
cpuPercentage: number;
memoryPercentage: number;
diskPercentage: number;
gpuPercentage: number | null;
load: {
status: SystemLoadStatus;
averages: {
one: number;
five: number;
fifteen: number;
};
};
networkBytes: number;
temperature: number | null;
}
@@ -0,0 +1,38 @@
import type { ISystemUsageIntegration } from "../../interfaces/system-usage/system-usage-integration";
import type { System } from "../../types";
export class SystemUsageMockService implements ISystemUsageIntegration {
async getSystemsAsync(): Promise<Pick<System, "id" | "name">[]> {
return await Promise.resolve([
{ id: "1", name: "Mock System 1" },
{ id: "2", name: "Mock System 2" },
]);
}
async getSystemDetailsAsync(id: System["id"]): Promise<System> {
return await Promise.resolve({
id,
name: `Mock System ${id}`,
status: "up",
agent: {
connectionType: "webSocket",
version: "1.0.0",
},
usage: {
cpuPercentage: 25,
memoryPercentage: 50,
diskPercentage: 75,
gpuPercentage: null,
load: {
status: "good",
averages: {
one: 0.5,
five: 0.7,
fifteen: 0.9,
},
},
networkBytes: Math.floor(2 ** 24.5),
temperature: 55,
},
});
}
}
@@ -15,6 +15,7 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
import type { ISystemUsageIntegration } from "../interfaces/system-usage/system-usage-integration";
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import { CalendarMockService } from "./data/calendar";
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
@@ -29,6 +30,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
import { NotificationsMockService } from "./data/notifications";
import { SmartHomeMockService } from "./data/smart-home";
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
import { SystemUsageMockService } from "./data/system-usage";
import { UpsSummaryMockService } from "./data/ups-summary";
export class MockIntegration
@@ -46,6 +48,7 @@ export class MockIntegration
IMediaTranscodingIntegration,
NetworkControllerSummaryIntegration,
ISmartHomeIntegration,
ISystemUsageIntegration,
IUpsSummaryIntegration
{
private static readonly dnsHole = new DnsHoleMockService();
@@ -61,6 +64,7 @@ export class MockIntegration
private static readonly networkController = new NetworkControllerSummaryMockService();
private static readonly notifications = new NotificationsMockService();
private static readonly smartHome = new SmartHomeMockService();
private static readonly systemUsage = new SystemUsageMockService();
private static readonly upsSummary = new UpsSummaryMockService();
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
@@ -130,4 +134,8 @@ export class MockIntegration
// UpsSummaryIntegration
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
// SystemUsageIntegration
getSystemsAsync = MockIntegration.systemUsage.getSystemsAsync.bind(MockIntegration.systemUsage);
getSystemDetailsAsync = MockIntegration.systemUsage.getSystemDetailsAsync.bind(MockIntegration.systemUsage);
}
+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 "./interfaces/system-usage/system-usage-types";
+176
View File
@@ -0,0 +1,176 @@
import { rmdir } from "fs/promises";
import { join } from "path";
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { BeszelIntegration } from "../src/beszel/beszel-integration";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/db")>();
return {
...actual,
db: createDb(),
};
});
const email = "e2e@homarr.dev";
const password = "12341234";
const publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID5PFut0emFoQQ53LlLGfFYA/U1Noe6WPqYN/97LJTvR";
const token = "4af8b512-afdc-4e42-a478-d0a3bfbbb4a8";
const IMAGE_NAME = "henrygd/beszel:latest";
const AGENT_IMAGE_NAME = "henrygd/beszel-agent:latest";
const systemId = "nj259tge7e4h7xy";
describe("Beszel integration", () => {
let socketPathId: string;
let beszelContainer: StartedTestContainer;
let beszelAgentContainer: StartedTestContainer;
beforeAll(async () => {
socketPathId = createId();
beszelContainer = await createBeszelContainer(socketPathId).start();
beszelAgentContainer = await createBeszelAgentContainer(socketPathId, beszelContainer.getMappedPort(8090)).start();
await new Promise((resolve) => setTimeout(resolve, 5_000)); // Wait for Beszel to be ready and have some system_data
}, 20_000);
afterAll(async () => {
await beszelContainer.stop();
await beszelAgentContainer.stop();
await rmdir(createSocketPath(socketPathId), { recursive: true });
}, 5_000);
test("Test connection is successful", async () => {
// Arrange
const integration = createIntegration(beszelContainer.getMappedPort(8090));
// Act
const result = await integration.testConnectionAsync();
// Assert
expect(result.success).toBe(true);
});
test("Test connection fails with invalid credentials", async () => {
// Arrange
const integration = createIntegration(beszelContainer.getMappedPort(8090), {
password: "wrongpassword",
});
// Act
const result = await integration.testConnectionAsync();
// Assert
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.type).toBe("authorization");
});
test("Can get systems", async () => {
// Arrange
const integration = createIntegration(beszelContainer.getMappedPort(8090));
// Act
const systems = await integration.getSystemsAsync();
// Assert
expect(systems.length).toBe(1);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(systems[0]!.name).toBe("localhost");
});
test("Can get system details", async () => {
// Arrange
const integration = createIntegration(beszelContainer.getMappedPort(8090));
// Act
const system = await integration.getSystemDetailsAsync(systemId);
// Assert
expect(system.id).toBe(systemId);
expect(system.name).toBe("localhost");
expect(system.agent.connectionType).toBe("webSocket");
expect(system.agent.version).toBeDefined();
expect(system.status).toBe("up");
expect(system.usage.cpuPercentage).toBeGreaterThanOrEqual(0);
expect(system.usage.memoryPercentage).toBeGreaterThanOrEqual(0);
expect(system.usage.diskPercentage).toBeGreaterThanOrEqual(0);
expect(system.usage.load.averages.one).toBeGreaterThanOrEqual(0);
expect(system.usage.networkBytes).toBeGreaterThanOrEqual(0);
});
});
const createIntegration = (port: number, overrides: { hostname?: string; password?: string } = {}) =>
new BeszelIntegration({
id: createId(),
name: "Beszel E2E",
url: `http://${overrides.hostname ?? "localhost"}:${port}`,
externalUrl: null,
decryptedSecrets: [
{
kind: "username",
value: email,
},
{
kind: "password",
value: overrides.password ?? password,
},
],
});
const files = ["data.db", "data.db-shm", "data.db-wal", "id_ed25519"];
const createBeszelContainer = (id: string) => {
return new GenericContainer(IMAGE_NAME)
.withCopyFilesToContainer(
files.map((file) => ({
source: join(__dirname, `/volumes/beszel/${file}`),
target: `/beszel_data/${file}`,
})),
)
.withExposedPorts(8090)
.withBindMounts([
{
source: createSocketPath(id),
target: "/beszel_socket",
mode: "rw",
},
])
.withWaitStrategy(Wait.forHttp("/", 8090));
};
const createBeszelAgentContainer = (id: string, port: number) => {
return new GenericContainer(AGENT_IMAGE_NAME)
.withBindMounts([
{
source: "/var/run/docker.sock",
target: "/var/run/docker.sock",
mode: "ro",
},
{
source: createSocketPath(id),
target: "/beszel_socket",
mode: "rw",
},
])
.withEnvironment({
LISTEN: "/beszel_socket/beszel.sock",
HUB_URL: `http://host.docker.internal:${port}`,
TOKEN: token,
KEY: publicKey,
})
.withExtraHosts([
{
// This enabled the usage of host.docker.internal as hostname in the container
host: "host.docker.internal",
ipAddress: "host-gateway",
},
]);
};
const createSocketPath = (id: string) => join(__dirname, `/volumes/beszel/socket-${id}`);
Binary file not shown.
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACA+TxbrdHphaEEOdy5SxnxWAP1NTaHulj6mDf/eyyU70QAA
AIgMmaMFDJmjBQAAAAtzc2gtZWQyNTUxOQAAACA+TxbrdHphaEEOdy5SxnxWAP1N
TaHulj6mDf/eyyU70QAAAEBgdIVB4OrfE2cUkVn7lpqtyq+XRq2rxzq01U1DPHB+
fj5PFut0emFoQQ53LlLGfFYA/U1Noe6WPqYN/97LJTvRAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { System } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const systemUsageRequestHandler = createCachedIntegrationRequestHandler<
System,
IntegrationKindByCategory<"systemUsage">,
{ systemId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSystemDetailsAsync(input.systemId);
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "systemUsage",
});
+47 -1
View File
@@ -811,7 +811,9 @@
"notes": "Notes",
"mediaMonitoring": "Monitoring",
"speedtest": "Speed Test",
"analytics": "Analytics"
"analytics": "Analytics",
"systemUsage": "System Usage",
"ups": "UPS"
},
"testConnection": {
"action": {
@@ -3037,6 +3039,47 @@
"internalServerError": "Failed to fetch speedtest data"
}
},
"systemUsage": {
"name": "System usage",
"description": "CPU, Memory, Disk and other hardware usage of your system",
"option": {
"systemId": {
"label": "Choose system"
},
"visibleItems": {
"label": "Visible items"
}
},
"item": {
"cpu": {
"label": "CPU"
},
"memory": {
"label": "Memory"
},
"disk": {
"label": "Disk"
},
"gpu": {
"label": "GPU"
},
"load": {
"label": "Load Avg"
},
"network": {
"label": "Net"
},
"temperature": {
"label": "Temp"
},
"agent": {
"label": "Agent"
}
},
"error": {
"noSystem": "No system selected"
}
},
"systemResources": {
"name": "System resources",
"description": "CPU, Memory, Disk and other hardware usage of your system",
@@ -3856,6 +3899,9 @@
"timetable": {
"label": "Timetable"
},
"systemUsage": {
"label": "System Usage"
},
"iconsUpdater": {
"label": "Icons Updater"
},
+42
View File
@@ -10,3 +10,45 @@ export const IconPowerOff = createCustomIcon({
type: "outline",
name: "power-off",
});
export const IconWebsocket = createCustomIcon({
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 193" fill="currentColor">
<path xmlns="http://www.w3.org/2000/svg" d="M192 145h32V68l-36-35-22 22 26 27zm32 16H113l-26-27 11-11 22 22h45l-44-45 11-11 44 44V88l-21-22 11-11-55-55H0l32 32h65l24 23-34 34-24-23V48H32v31l55 55-23 22 36 36h156z"/>
</svg>`,
type: "filled",
name: "websocket",
});
export const IconMemoryStick = createCustomIcon({
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-memory-stick-icon lucide-memory-stick">
<path d="M6 19v-3"/>
<path d="M10 19v-3"/>
<path d="M14 19v-3"/>
<path d="M18 19v-3"/>
<path d="M8 11V9"/>
<path d="M16 11V9"/>
<path d="M12 11V9"/>
<path d="M2 15h20"/>
<path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"/>
</svg>`,
type: "outline",
name: "memory-stick",
});
export const IconHardDrive = createCustomIcon({
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hard-drive-icon lucide-hard-drive">
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
<line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
</svg>`,
type: "outline",
name: "hard-drive",
});
export const IconGpu = createCustomIcon({
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" strokeWidth="2">
<path xmlns="http://www.w3.org/2000/svg" d="M4 21V4.1a1.5 1.5 0 0 0-1.1-1L2 3m2 2h13c2.4 0 3.5 0 4.3.7s.7 2 .7 4.3v4.5c0 2.4 0 3.5-.7 4.3-.8.7-2 .7-4.3.7h-4.9a1.8 1.8 0 0 1-1.6-1c-.3-.6-1-1-1.6-1H4" />
<path xmlns="http://www.w3.org/2000/svg" d="M19 11.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0m-11.5-3h2m-2 3h2m-2 3h2" />
</svg>`,
type: "outline",
name: "gpu",
});
+2
View File
@@ -44,6 +44,7 @@ import * as speedtestTracker from "./speedtest-tracker";
import * as stockPrice from "./stocks";
import * as systemDisks from "./system-disks";
import * as systemResources from "./system-resources";
import * as systemUsage from "./system-usage";
import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
@@ -86,6 +87,7 @@ export const widgetImports = {
notifications,
mediaReleases,
systemResources,
systemUsage,
coolify,
systemDisks,
timetable,
@@ -0,0 +1,228 @@
"use client";
import type { MantineColor } from "@mantine/core";
import { Divider, Group, Stack, Text } from "@mantine/core";
import {
IconCpu,
IconHourglass,
IconNetwork,
IconSquareChevronRight,
IconThermometer,
IconWifi,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, objectEntries } from "@homarr/common";
import type { System, SystemLoadStatus, SystemStatus } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { IconGpu, IconHardDrive, IconMemoryStick, IconWebsocket } from "@homarr/ui/icons";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors";
import { NoSystemSelectedError } from "./errors/no-system-selected-error";
import { Dot, Item, ProgressValue } from "./item";
export default function SystemUsage({ integrationIds, options, isEditMode }: WidgetComponentProps<"systemUsage">) {
if (isEditMode) {
return <SystemUsagePreview visibleItems={options.visibleItems} />;
}
const integrationId = integrationIds.at(0);
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
if (!options.systemId) {
throw new NoSystemSelectedError();
}
return (
<SystemUsageContent
systemId={options.systemId.value}
integrationId={integrationId}
visibleItems={options.visibleItems}
/>
);
}
interface SystemUsagePreviewProps {
visibleItems: (keyof typeof items)[];
}
const progressItems = new Set<keyof typeof items>(["cpu", "memory", "disk", "gpu"]);
const SystemUsagePreview = ({ visibleItems }: SystemUsagePreviewProps) => {
const t = useI18n();
return (
<Stack gap="sm" p="sm">
<Group gap="sm" align="center">
<Dot color="green" />
<Text fw="bold" size="sm">
System Name
</Text>
</Group>
<Divider />
{objectEntries(items).map(([key, item]) =>
("hidden" in item && item.hidden({ usage: { gpuPercentage: null, temperature: null } } as System)) ||
!visibleItems.includes(key) ? null : (
<Item key={key} icon={item.icon} label={t(`widget.systemUsage.item.${key}.label`)}>
{progressItems.has(key) ? <ProgressValue value={50} /> : <Text size="xs">—</Text>}
</Item>
),
)}
</Stack>
);
};
interface SystemUsageContentProps {
systemId: string;
integrationId: string;
visibleItems: (keyof typeof items)[];
}
const SystemUsageContent = ({ systemId, integrationId, visibleItems }: SystemUsageContentProps) => {
const [systemDetails] = clientApi.widget.systemUsage.get.useSuspenseQuery(
{ systemId, integrationId },
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.systemUsage.subscribe.useSubscription(
{ systemId, integrationId },
{
onData(data) {
utils.widget.systemUsage.get.setData({ systemId, integrationId }, data.system);
},
},
);
const t = useI18n();
return (
<Stack gap="sm" p="sm">
<Group gap="sm" align="center">
<Dot color={systemStatusColors[systemDetails.status]} />
<Text fw="bold" size="sm">
{systemDetails.name}
</Text>
</Group>
<Divider />
{objectEntries(items).map(([key, item]) =>
("hidden" in item && item.hidden(systemDetails)) || !visibleItems.includes(key) ? null : (
<Item key={key} icon={item.icon} label={t(`widget.systemUsage.item.${key}.label`)}>
<item.component system={systemDetails} />
</Item>
),
)}
</Stack>
);
};
const systemStatusColors = {
up: "green",
down: "red",
paused: "gray",
pending: "yellow",
} satisfies Record<SystemStatus, MantineColor>;
const loadStatusColors = {
good: "green",
warning: "yellow",
critical: "red",
unknown: "gray",
} satisfies Record<SystemLoadStatus, MantineColor>;
const items = {
cpu: {
icon: IconCpu,
component({ system }) {
return <ProgressValue value={system.usage.cpuPercentage} />;
},
},
memory: {
icon: IconMemoryStick,
component({ system }) {
return <ProgressValue value={system.usage.memoryPercentage} />;
},
},
disk: {
icon: IconHardDrive,
component({ system }) {
return <ProgressValue value={system.usage.diskPercentage} />;
},
},
gpu: {
icon: IconGpu,
component({ system }) {
return <ProgressValue value={system.usage.gpuPercentage ?? 0} />;
},
hidden(system) {
return system.usage.gpuPercentage === null;
},
},
load: {
icon: IconHourglass,
component({ system }) {
return (
<Group gap="xs" align="center" wrap="nowrap">
<Dot color={loadStatusColors[system.usage.load.status]} />
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{system.usage.load.averages.one}
</Text>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{system.usage.load.averages.five}
</Text>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{system.usage.load.averages.fifteen}
</Text>
</Group>
);
},
},
network: {
icon: IconNetwork,
component({ system }) {
return (
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{humanFileSize(system.usage.networkBytes, "/s")}
</Text>
);
},
},
temperature: {
icon: IconThermometer,
component({ system }) {
return (
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{system.usage.temperature ?? "N/A"} °C
</Text>
);
},
hidden(system) {
return system.usage.temperature === null;
},
},
agent: {
icon: IconWifi,
component({ system }) {
return (
<Group gap="xs" align="center" wrap="nowrap">
{system.agent.connectionType === "ssh" ? <IconSquareChevronRight size={14} stroke={1.5} /> : null}
{system.agent.connectionType === "webSocket" ? <IconWebsocket size={14} stroke={1.5} /> : null}
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{system.agent.version}
</Text>
</Group>
);
},
},
} satisfies Record<
string,
{ icon: TablerIcon; component: (props: { system: System }) => React.ReactNode; hidden?: (system: System) => boolean }
>;
@@ -0,0 +1,19 @@
import { IconServerOff } from "@tabler/icons-react";
import type { TranslationFunction } from "@homarr/translation";
import { ErrorBoundaryError } from "../../errors";
export class NoSystemSelectedError extends ErrorBoundaryError {
constructor() {
super("No system selected");
}
public getErrorBoundaryData() {
return {
icon: IconServerOff,
message: (t: TranslationFunction) => t("widget.systemUsage.error.noSystem"),
showLogsLink: false,
};
}
}
@@ -0,0 +1,43 @@
import { IconServer } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("systemUsage", {
icon: IconServer,
supportedIntegrations: getIntegrationKindsByCategory("systemUsage"),
integrationsRequired: false,
createOptions() {
return optionsBuilder.from((factory) => ({
systemId: factory.dynamicSelect({
useOptions(_query, integrationIds) {
const { data: systems, isPending } = clientApi.widget.systemUsage.listSystems.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{
enabled: integrationIds.length > 0,
},
);
return {
isPending,
options:
systems?.map((system) => ({
label: system.name,
value: system.id,
})) ?? [],
};
},
}),
visibleItems: factory.multiSelect({
options: (["cpu", "memory", "disk", "gpu", "load", "network", "temperature", "agent"] as const).map((key) => ({
value: key,
label: (t) => t(`widget.systemUsage.item.${key}.label`),
})),
defaultValue: ["cpu", "memory", "disk", "gpu", "load", "network", "temperature", "agent"],
}),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,46 @@
import type { MantineColor } from "@mantine/core";
import { Box, Flex, Group, Progress, Text } from "@mantine/core";
import type { TablerIcon } from "@homarr/ui";
export const ProgressValue = ({ value }: { value: number }) => (
<Group gap="xs" align="center" w="100%" wrap="nowrap">
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{value}%
</Text>
<Progress value={value} color={progressColor(value)} w="100%" />
</Group>
);
const progressColor = (value: number): MantineColor => {
if (value < 50) return "green";
if (value < 75) return "yellow";
return "red";
};
interface DotProps {
color: MantineColor;
}
export const Dot = ({ color }: DotProps) => <Box style={{ borderRadius: "100%" }} bg={color} h={8} w={8}></Box>;
interface ItemProps {
icon: TablerIcon;
label: string;
children: React.ReactNode;
}
export const Item = (props: ItemProps) => (
<Group justify="space-between" align="center" wrap="nowrap" w="100%">
<Group gap="xs" wrap="nowrap">
<props.icon size={16} stroke={1.5} />
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{props.label}:
</Text>
</Group>
<Flex c="white" justify="end" maw="50%" w="100%">
{props.children}
</Flex>
</Group>
);