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";
|
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
|
* 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);
|
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
|
||||||
|
|
||||||
if (hasQueryAccess) return;
|
if (hasQueryAccess) return;
|
||||||
|
|||||||
@@ -32,4 +32,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
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 { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
||||||
import { networkControllerJob } from "./jobs/integrations/network-controller";
|
import { networkControllerJob } from "./jobs/integrations/network-controller";
|
||||||
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
|
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
|
||||||
|
import { systemUsageJob } from "./jobs/integrations/system-usage";
|
||||||
import { timetableJob } from "./jobs/integrations/timetable";
|
import { timetableJob } from "./jobs/integrations/timetable";
|
||||||
import { tracearrJob } from "./jobs/integrations/tracearr";
|
import { tracearrJob } from "./jobs/integrations/tracearr";
|
||||||
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
||||||
@@ -55,6 +56,7 @@ const getJobGroup = () => {
|
|||||||
refreshNotifications: refreshNotificationsJob,
|
refreshNotifications: refreshNotificationsJob,
|
||||||
weather: weatherJob,
|
weather: weatherJob,
|
||||||
timetable: timetableJob,
|
timetable: timetableJob,
|
||||||
|
systemUsage: systemUsageJob,
|
||||||
tracearr: tracearrJob,
|
tracearr: tracearrJob,
|
||||||
umamiActiveVisitors: umamiActiveVisitorsJob,
|
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/adguard-home"
|
||||||
| "/docs/integrations/anchor"
|
| "/docs/integrations/anchor"
|
||||||
| "/docs/integrations/aria2"
|
| "/docs/integrations/aria2"
|
||||||
|
| "/docs/integrations/beszel"
|
||||||
| "/docs/integrations/codeberg"
|
| "/docs/integrations/codeberg"
|
||||||
| "/docs/integrations/coolify"
|
| "/docs/integrations/coolify"
|
||||||
| "/docs/integrations/dash-dot"
|
| "/docs/integrations/dash-dot"
|
||||||
|
|||||||
@@ -370,6 +370,13 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||||
defaultPort: 80,
|
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: {
|
unraid: {
|
||||||
name: "Unraid",
|
name: "Unraid",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
@@ -451,6 +458,7 @@ export const integrationDefs = {
|
|||||||
"networkController",
|
"networkController",
|
||||||
"notifications",
|
"notifications",
|
||||||
"smartHomeServer",
|
"smartHomeServer",
|
||||||
|
"systemUsage",
|
||||||
"ups",
|
"ups",
|
||||||
],
|
],
|
||||||
documentationUrl: null,
|
documentationUrl: null,
|
||||||
@@ -539,6 +547,7 @@ export const integrationCategories = [
|
|||||||
"mediaMonitoring",
|
"mediaMonitoring",
|
||||||
"speedtest",
|
"speedtest",
|
||||||
"analytics",
|
"analytics",
|
||||||
|
"systemUsage",
|
||||||
"ups",
|
"ups",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const widgetKinds = [
|
|||||||
"firewall",
|
"firewall",
|
||||||
"notifications",
|
"notifications",
|
||||||
"systemResources",
|
"systemResources",
|
||||||
|
"systemUsage",
|
||||||
"coolify",
|
"coolify",
|
||||||
"systemDisks",
|
"systemDisks",
|
||||||
"timetable",
|
"timetable",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
|||||||
|
|
||||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||||
|
import { BeszelIntegration } from "../beszel/beszel-integration";
|
||||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||||
@@ -116,6 +117,7 @@ export const integrationCreators = {
|
|||||||
ntfy: NTFYIntegration,
|
ntfy: NTFYIntegration,
|
||||||
mock: MockIntegration,
|
mock: MockIntegration,
|
||||||
truenas: TrueNasIntegration,
|
truenas: TrueNasIntegration,
|
||||||
|
beszel: BeszelIntegration,
|
||||||
unraid: UnraidIntegration,
|
unraid: UnraidIntegration,
|
||||||
coolify: CoolifyIntegration,
|
coolify: CoolifyIntegration,
|
||||||
tracearr: TracearrIntegration,
|
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 { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||||
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-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 { 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 type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
|
||||||
import { CalendarMockService } from "./data/calendar";
|
import { CalendarMockService } from "./data/calendar";
|
||||||
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
|
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
|
||||||
@@ -29,6 +30,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
|
|||||||
import { NotificationsMockService } from "./data/notifications";
|
import { NotificationsMockService } from "./data/notifications";
|
||||||
import { SmartHomeMockService } from "./data/smart-home";
|
import { SmartHomeMockService } from "./data/smart-home";
|
||||||
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
||||||
|
import { SystemUsageMockService } from "./data/system-usage";
|
||||||
import { UpsSummaryMockService } from "./data/ups-summary";
|
import { UpsSummaryMockService } from "./data/ups-summary";
|
||||||
|
|
||||||
export class MockIntegration
|
export class MockIntegration
|
||||||
@@ -46,6 +48,7 @@ export class MockIntegration
|
|||||||
IMediaTranscodingIntegration,
|
IMediaTranscodingIntegration,
|
||||||
NetworkControllerSummaryIntegration,
|
NetworkControllerSummaryIntegration,
|
||||||
ISmartHomeIntegration,
|
ISmartHomeIntegration,
|
||||||
|
ISystemUsageIntegration,
|
||||||
IUpsSummaryIntegration
|
IUpsSummaryIntegration
|
||||||
{
|
{
|
||||||
private static readonly dnsHole = new DnsHoleMockService();
|
private static readonly dnsHole = new DnsHoleMockService();
|
||||||
@@ -61,6 +64,7 @@ export class MockIntegration
|
|||||||
private static readonly networkController = new NetworkControllerSummaryMockService();
|
private static readonly networkController = new NetworkControllerSummaryMockService();
|
||||||
private static readonly notifications = new NotificationsMockService();
|
private static readonly notifications = new NotificationsMockService();
|
||||||
private static readonly smartHome = new SmartHomeMockService();
|
private static readonly smartHome = new SmartHomeMockService();
|
||||||
|
private static readonly systemUsage = new SystemUsageMockService();
|
||||||
private static readonly upsSummary = new UpsSummaryMockService();
|
private static readonly upsSummary = new UpsSummaryMockService();
|
||||||
|
|
||||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
@@ -130,4 +134,8 @@ export class MockIntegration
|
|||||||
|
|
||||||
// UpsSummaryIntegration
|
// UpsSummaryIntegration
|
||||||
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
|
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 "./tracearr/tracearr-types";
|
||||||
export * from "./speedtest-tracker/speedtest-tracker-types";
|
export * from "./speedtest-tracker/speedtest-tracker-types";
|
||||||
export * from "./umami/umami-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",
|
"notes": "Notes",
|
||||||
"mediaMonitoring": "Monitoring",
|
"mediaMonitoring": "Monitoring",
|
||||||
"speedtest": "Speed Test",
|
"speedtest": "Speed Test",
|
||||||
"analytics": "Analytics"
|
"analytics": "Analytics",
|
||||||
|
"systemUsage": "System Usage",
|
||||||
|
"ups": "UPS"
|
||||||
},
|
},
|
||||||
"testConnection": {
|
"testConnection": {
|
||||||
"action": {
|
"action": {
|
||||||
@@ -3037,6 +3039,47 @@
|
|||||||
"internalServerError": "Failed to fetch speedtest data"
|
"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": {
|
"systemResources": {
|
||||||
"name": "System resources",
|
"name": "System resources",
|
||||||
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
||||||
@@ -3856,6 +3899,9 @@
|
|||||||
"timetable": {
|
"timetable": {
|
||||||
"label": "Timetable"
|
"label": "Timetable"
|
||||||
},
|
},
|
||||||
|
"systemUsage": {
|
||||||
|
"label": "System Usage"
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Icons Updater"
|
"label": "Icons Updater"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,3 +10,45 @@ export const IconPowerOff = createCustomIcon({
|
|||||||
type: "outline",
|
type: "outline",
|
||||||
name: "power-off",
|
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 stockPrice from "./stocks";
|
||||||
import * as systemDisks from "./system-disks";
|
import * as systemDisks from "./system-disks";
|
||||||
import * as systemResources from "./system-resources";
|
import * as systemResources from "./system-resources";
|
||||||
|
import * as systemUsage from "./system-usage";
|
||||||
import * as timetable from "./timetable";
|
import * as timetable from "./timetable";
|
||||||
import * as tracearr from "./tracearr";
|
import * as tracearr from "./tracearr";
|
||||||
import * as umami from "./umami";
|
import * as umami from "./umami";
|
||||||
@@ -86,6 +87,7 @@ export const widgetImports = {
|
|||||||
notifications,
|
notifications,
|
||||||
mediaReleases,
|
mediaReleases,
|
||||||
systemResources,
|
systemResources,
|
||||||
|
systemUsage,
|
||||||
coolify,
|
coolify,
|
||||||
systemDisks,
|
systemDisks,
|
||||||
timetable,
|
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