Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae294b9d88 |
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
Binary file not shown.
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",
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
Reference in new issue
Block a user