feat(integrations): add truenas (#3745)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
|||||||
|
|
||||||
export const healthMonitoringRouter = createTRPCRouter({
|
export const healthMonitoringRouter = createTRPCRouter({
|
||||||
getSystemHealthStatus: publicProcedure
|
getSystemHealthStatus: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
|
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
@@ -26,7 +26,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
subscribeSystemHealthStatus: publicProcedure
|
subscribeSystemHealthStatus: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
|
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
|
||||||
.subscription(({ ctx }) => {
|
.subscription(({ ctx }) => {
|
||||||
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
|
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
|
|||||||
@@ -283,6 +283,13 @@ export const integrationDefs = {
|
|||||||
category: ["notifications"],
|
category: ["notifications"],
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
||||||
},
|
},
|
||||||
|
truenas: {
|
||||||
|
name: "TrueNAS",
|
||||||
|
secretKinds: [["username", "password"]],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
||||||
|
category: ["healthMonitoring"],
|
||||||
|
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||||
|
},
|
||||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||||
mock: {
|
mock: {
|
||||||
name: "Mock",
|
name: "Mock",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { PlexIntegration } from "../plex/plex-integration";
|
|||||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||||
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||||
import { QuayIntegration } from "../quay/quay-integration";
|
import { QuayIntegration } from "../quay/quay-integration";
|
||||||
|
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||||
import type { Integration, IntegrationInput } from "./integration";
|
import type { Integration, IntegrationInput } from "./integration";
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ export const integrationCreators = {
|
|||||||
quay: QuayIntegration,
|
quay: QuayIntegration,
|
||||||
ntfy: NTFYIntegration,
|
ntfy: NTFYIntegration,
|
||||||
mock: MockIntegration,
|
mock: MockIntegration,
|
||||||
|
truenas: TrueNasIntegration,
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
|
|
||||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
|
|||||||
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
||||||
export { PlexIntegration } from "./plex/plex-integration";
|
export { PlexIntegration } from "./plex/plex-integration";
|
||||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||||
|
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
||||||
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface SystemHealthMonitoring {
|
|||||||
"1min": number;
|
"1min": number;
|
||||||
"5min": number;
|
"5min": number;
|
||||||
"15min": number;
|
"15min": number;
|
||||||
};
|
} | null;
|
||||||
rebootRequired: boolean;
|
rebootRequired: boolean;
|
||||||
availablePkgUpdates: number;
|
availablePkgUpdates: number;
|
||||||
cpuTemp: number | undefined;
|
cpuTemp: number | undefined;
|
||||||
|
|||||||
375
packages/integrations/src/truenas/truenas-integration.ts
Normal file
375
packages/integrations/src/truenas/truenas-integration.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
import { createId } from "@homarr/common";
|
||||||
|
import { RequestError, ResponseError } from "@homarr/common/server";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
|
import { Integration } from "../base/integration";
|
||||||
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
|
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||||
|
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
||||||
|
|
||||||
|
const localLogger = logger.child({ module: "TrueNasIntegration" });
|
||||||
|
|
||||||
|
const NETWORK_MULTIPLIER = 100;
|
||||||
|
|
||||||
|
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
|
private static webSocketMap = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
private wsUrl() {
|
||||||
|
const url = super.url("/websocket");
|
||||||
|
url.protocol = url.protocol.replace("http", "ws");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get webSocket() {
|
||||||
|
return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
const webSocket = await this.connectWebSocketAsync();
|
||||||
|
await this.registerSessionAsync(webSocket);
|
||||||
|
await this.authenticateWebSocketAsync(webSocket);
|
||||||
|
|
||||||
|
// Remove current socket connection so we can authenticate with updated credentials
|
||||||
|
TrueNasIntegration.webSocketMap.delete(this.integration.id);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrueNAS API uses WebSocket. This function connects to the socket
|
||||||
|
* and resolves the promise if the connection was successful.
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html
|
||||||
|
*/
|
||||||
|
private async connectWebSocketAsync(): Promise<WebSocket> {
|
||||||
|
localLogger.debug("Connecting to websocket server", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
const webSocket = new WebSocket(this.wsUrl());
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
webSocket.onopen = () => {
|
||||||
|
localLogger.debug("Connected to websocket server", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
resolve(webSocket);
|
||||||
|
};
|
||||||
|
|
||||||
|
webSocket.onerror = () => {
|
||||||
|
reject(new Error("Failed to connect"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before authentication, a session must be obtained from the server using the "connect" event.
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
|
||||||
|
*/
|
||||||
|
private async registerSessionAsync(webSocket: WebSocket): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const subscribe = (event: MessageEvent<string>) => {
|
||||||
|
const data = JSON.parse(event.data) as { msg: string };
|
||||||
|
if (data.msg === "connected") {
|
||||||
|
webSocket.removeEventListener("message", subscribe);
|
||||||
|
resolve();
|
||||||
|
} else if (data.msg === "failed") {
|
||||||
|
webSocket.removeEventListener("message", subscribe);
|
||||||
|
reject(new Error("Unable to establish connection"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
webSocket.addEventListener("message", subscribe);
|
||||||
|
webSocket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
msg: "connect",
|
||||||
|
version: "1", // this must be number, not string
|
||||||
|
support: ["1"], // this must be number, not string
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a session was obtained, the session can be authenticated.
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
|
||||||
|
*/
|
||||||
|
private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise<void> {
|
||||||
|
localLogger.debug("Authenticating with username and password", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
const response = await this.requestAsync(
|
||||||
|
"auth.login",
|
||||||
|
[this.getSecretValue("username"), this.getSecretValue("password")],
|
||||||
|
webSocket,
|
||||||
|
);
|
||||||
|
const result = await z.boolean().parseAsync(response);
|
||||||
|
if (!result) throw new ResponseError({ status: 401 });
|
||||||
|
localLogger.debug("Authenticated successfully with username and password", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves data using the reporting method
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
|
||||||
|
*/
|
||||||
|
private async getReportingAsync(): Promise<ReportingItem[]> {
|
||||||
|
localLogger.debug("Retrieving reporting data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("reporting.get_data", [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "cpu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cputemp",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
aggregate: true,
|
||||||
|
start: dayjs().add(-5, "minutes").unix(),
|
||||||
|
end: dayjs().unix(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await z.array(reportingItemSchema).parseAsync(response);
|
||||||
|
|
||||||
|
localLogger.debug("Retrieved reporting data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
count: result.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of all available network interfaces
|
||||||
|
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
|
||||||
|
*/
|
||||||
|
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
|
||||||
|
localLogger.debug("Retrieving available network-interfaces", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("interface.query", [
|
||||||
|
[], // no filters
|
||||||
|
{},
|
||||||
|
]);
|
||||||
|
const result = await networkInterfaceSchema.parseAsync(response);
|
||||||
|
|
||||||
|
localLogger.debug("Retrieved available network-interfaces", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
count: result.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves reporting network data of the last 5 minutes
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
|
||||||
|
*/
|
||||||
|
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
|
||||||
|
const networkInterfaces = await this.getNetworkInterfacesAsync();
|
||||||
|
|
||||||
|
localLogger.debug("Retrieving reporting network data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("reporting.netdata_get_data", [
|
||||||
|
networkInterfaces.map((networkInterface) => ({
|
||||||
|
name: "interface",
|
||||||
|
identifier: networkInterface.id,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
start: dayjs().add(-5, "minutes").unix(),
|
||||||
|
end: dayjs().unix(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await reportingNetDataSchema.parseAsync(response);
|
||||||
|
|
||||||
|
localLogger.debug("Retrieved reporting-network-data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
count: result.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves information about the system
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
|
||||||
|
*/
|
||||||
|
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
|
||||||
|
localLogger.debug("Retrieving system-information", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("system.info");
|
||||||
|
const result = await systemInfoSchema.parseAsync(response);
|
||||||
|
|
||||||
|
localLogger.debug("Retrieved system-information", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||||
|
const systemInformation = await this.getSystemInformationAsync();
|
||||||
|
const reporting = await this.getReportingAsync();
|
||||||
|
|
||||||
|
const cpuData = this.extractLatestReportingData(reporting, "cpu");
|
||||||
|
const cpuTempData = this.extractLatestReportingData(reporting, "cputemp");
|
||||||
|
const memoryData = this.extractLatestReportingData(reporting, "memory");
|
||||||
|
|
||||||
|
const netdata = await this.getReportingNetdataAsync();
|
||||||
|
|
||||||
|
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
||||||
|
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
||||||
|
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
|
||||||
|
memAvailableInBytes: systemInformation.physmem,
|
||||||
|
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||||
|
fileSystem: [],
|
||||||
|
availablePkgUpdates: 0,
|
||||||
|
network: {
|
||||||
|
up: upload * NETWORK_MULTIPLIER,
|
||||||
|
down: download * NETWORK_MULTIPLIER,
|
||||||
|
},
|
||||||
|
loadAverage: null,
|
||||||
|
smart: [],
|
||||||
|
uptime: systemInformation.uptime_seconds,
|
||||||
|
version: systemInformation.version,
|
||||||
|
cpuModelName: systemInformation.model,
|
||||||
|
rebootRequired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request through websocket and return response
|
||||||
|
* Times out after 5 seconds when no response was received.
|
||||||
|
* @param method json-rpc method to call
|
||||||
|
* @param params array of parameters
|
||||||
|
* @param webSocketOverride override of webSocket, helpful for not storing the connection
|
||||||
|
* @returns result of json-rpc call
|
||||||
|
*/
|
||||||
|
private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
|
||||||
|
let webSocket = webSocketOverride ?? this.webSocket;
|
||||||
|
if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
localLogger.debug("Connecting to websocket", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
// We can only land here with static webSocket
|
||||||
|
webSocket = await this.connectWebSocketAsync();
|
||||||
|
await this.registerSessionAsync(webSocket);
|
||||||
|
|
||||||
|
TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
|
||||||
|
await this.authenticateWebSocketAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const id = createId();
|
||||||
|
const handler = (event: MessageEvent<string>) => {
|
||||||
|
const data = JSON.parse(event.data) as Record<string, unknown>;
|
||||||
|
if (data.msg !== "result") return;
|
||||||
|
if (data.id !== id) return;
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
webSocket.removeEventListener("message", handler);
|
||||||
|
localLogger.debug("Received method response", {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
resolve(data.result);
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
webSocket.removeEventListener("message", handler);
|
||||||
|
reject(
|
||||||
|
new RequestError(
|
||||||
|
{
|
||||||
|
type: "timeout",
|
||||||
|
reason: "aborted",
|
||||||
|
code: "ECONNABORTED",
|
||||||
|
},
|
||||||
|
{ cause: new Error("Canceled request after 5 seconds") },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
webSocket.addEventListener("message", handler);
|
||||||
|
|
||||||
|
localLogger.debug("Sending method request", {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
webSocket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id,
|
||||||
|
msg: "method",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index = 1 | 2) => {
|
||||||
|
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const dataObject = data.find((item) => item.identifier === key)!;
|
||||||
|
// TODO: check why the below sorting is done, because right now it compares number[] with number[]?
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportingItemSchema = z.object({
|
||||||
|
name: z.enum(["cpu", "memory", "cputemp"]),
|
||||||
|
identifier: z.enum(["cpu", "memory", "cputemp"]),
|
||||||
|
aggregations: z.object({
|
||||||
|
min: z.record(z.string(), z.unknown()),
|
||||||
|
mean: z.record(z.string(), z.unknown()),
|
||||||
|
max: z.record(z.string(), z.unknown()),
|
||||||
|
}),
|
||||||
|
start: z.number().min(0),
|
||||||
|
end: z.number().min(0),
|
||||||
|
legend: z.array(z.string()),
|
||||||
|
data: z.array(z.array(z.number())),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ReportingItem = z.infer<typeof reportingItemSchema>;
|
||||||
|
|
||||||
|
const reportingNetDataSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
identifier: z.string(),
|
||||||
|
data: z.array(z.array(z.number())),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemInfoSchema = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
hostname: z.string(),
|
||||||
|
physmem: z.number().min(0), // pysical memory
|
||||||
|
model: z.string(), // cpu model
|
||||||
|
uptime_seconds: z.number().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const networkInterfaceSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -151,21 +151,26 @@ export const SystemHealthMonitoring = ({
|
|||||||
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
|
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
|
||||||
{formatUptime(healthInfo.uptime, t)}
|
{formatUptime(healthInfo.uptime, t)}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
|
{healthInfo.loadAverage && (
|
||||||
{t("widget.healthMonitoring.popover.loadAverage")}
|
<>
|
||||||
</List.Item>
|
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
|
||||||
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
|
{t("widget.healthMonitoring.popover.loadAverage")}
|
||||||
<List.Item className="health-monitoring-information-load-average-1min">
|
</List.Item>
|
||||||
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
|
||||||
</List.Item>
|
<List.Item className="health-monitoring-information-load-average-1min">
|
||||||
<List.Item className="health-monitoring-information-load-average-5min">
|
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
||||||
{t("widget.healthMonitoring.popover.minutes", { count: "5" })} {healthInfo.loadAverage["5min"]}%
|
</List.Item>
|
||||||
</List.Item>
|
<List.Item className="health-monitoring-information-load-average-5min">
|
||||||
<List.Item className="health-monitoring-information-load-average-15min">
|
{t("widget.healthMonitoring.popover.minutes", { count: "5" })}{" "}
|
||||||
{t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "}
|
{healthInfo.loadAverage["5min"]}%
|
||||||
{healthInfo.loadAverage["15min"]}%
|
</List.Item>
|
||||||
</List.Item>
|
<List.Item className="health-monitoring-information-load-average-15min">
|
||||||
</List>
|
{t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "}
|
||||||
|
{healthInfo.loadAverage["15min"]}%
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</List>
|
</List>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { optionsBuilder } from "../options";
|
|||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
|
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
|
||||||
icon: IconGraphFilled,
|
icon: IconGraphFilled,
|
||||||
supportedIntegrations: ["dashDot", "openmediavault"],
|
supportedIntegrations: ["dashDot", "openmediavault", "truenas"],
|
||||||
createOptions() {
|
createOptions() {
|
||||||
return optionsBuilder.from(() => ({}));
|
return optionsBuilder.from(() => ({}));
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user