Files
homarr/packages/integrations/src/dashdot/dashdot-integration.ts
T

250 lines
8.2 KiB
TypeScript

import "@homarr/redis";
import dayjs from "dayjs";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
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";
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return {};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/info"));
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return {
success: true,
};
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const [info, cpuLoad, memoryLoad, storageLoad, networkLoad, gpuLoad] = await Promise.all([
this.getInfoAsync(),
this.getCurrentCpuLoadAsync(),
this.getCurrentMemoryLoadAsync(),
this.getCurrentStorageLoadAsync(),
this.getCurrentNetworkLoadAsync(),
this.getCurrentGpuLoadAsync(),
]);
// Read history after the CPU request has persisted its newest sample.
const channel = this.getChannel();
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
return {
cpuUtilization: cpuLoad.sumLoad,
memUsedInBytes: memoryLoad.loadInBytes,
memAvailableInBytes: info.maxAvailableMemoryBytes - memoryLoad.loadInBytes,
network: networkLoad,
fileSystem: info.storage
.filter((_, index) => storageLoad[index] !== -1) // filter out undermoutned drives, they display as -1 in the load API
.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: `${storageLoad[index]!}`,
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})),
cpuModelName: info.cpuModel === "" ? `Unknown Model (${info.cpuBrand})` : `${info.cpuModel} (${info.cpuBrand})`,
cpuTemp: cpuLoad.averageTemperature,
availablePkgUpdates: 0,
rebootRequired: false,
smart: [], // API endpoint does not provide S.M.A.R.T data.
gpu: gpuLoad.map((gpu, index) => ({
gpuId: `gpu-${index}`,
name: info.gpuNames[index] ?? `GPU ${index}`,
memoryUtilization: gpu.memory ?? 0,
processorUtilization: gpu.load ?? 0,
temperature: null,
fanSpeed: null,
})),
uptime: info.uptime,
version: `${info.operatingSystemVersion}`,
loadAverage: {
"1min": Math.round(this.getAverageOfCpu(history[0])),
"5min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 4))),
"15min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 14))),
},
};
}
private async getInfoAsync() {
const infoResponse = await fetchWithTrustedCertificatesAsync(this.url("/info"));
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
return {
maxAvailableMemoryBytes: serverInfo.ram.size,
storage: serverInfo.storage,
cpuBrand: serverInfo.cpu.brand,
cpuModel: serverInfo.cpu.model,
operatingSystemVersion: `${serverInfo.os.distro} ${serverInfo.os.release} (${serverInfo.os.kernel})`,
uptime: serverInfo.os.uptime,
gpuNames: serverInfo.gpu.layout.map((gpu) => gpu.brand),
};
}
private async getCurrentCpuLoadAsync() {
const channel = this.getChannel();
const response = await fetchWithTrustedCertificatesAsync(this.url("/load/cpu"));
const result = await response.text();
// we convert it to text as the response is either valid json or empty if cpu widget is disabled.
if (result.length === 0) {
return {
sumLoad: 0,
averageTemperature: 0,
};
}
const data = await cpuLoadPerCoreApiList.parseAsync(JSON.parse(result));
await channel.pushAsync(data);
return {
sumLoad: this.getAverageOfCpu(data),
averageTemperature: data.reduce((acc, current) => acc + current.temp, 0) / data.length,
};
}
private getAverageOfCpuFlat(cpuLoad: z.infer<typeof cpuLoadPerCoreApiList>[]) {
const averages = cpuLoad.map((load) => this.getAverageOfCpu(load));
return averages.reduce((acc, current) => acc + current, 0) / averages.length;
}
private getAverageOfCpu(cpuLoad?: z.infer<typeof cpuLoadPerCoreApiList>) {
if (!cpuLoad) {
return 0;
}
return cpuLoad.reduce((acc, current) => acc + current.load, 0) / cpuLoad.length;
}
private async getCurrentStorageLoadAsync() {
const storageLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/storage"));
// we convert it to text as the response is either valid json or empty if storage widget is disabled.
const result = await storageLoad.text();
if (result.length === 0) {
return [];
}
return JSON.parse(result) as number[];
}
private async getCurrentMemoryLoadAsync() {
const memoryLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/ram"));
const result = (await memoryLoad.json()) as object;
// somehow the response here is not empty and rather an empty json object if the ram widget is disabled.
if (Object.keys(result).length === 0) {
return {
loadInBytes: 0,
};
}
const data = await memoryLoadApi.parseAsync(result);
return {
loadInBytes: data.load,
};
}
private async getCurrentNetworkLoadAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/load/network"));
const result = await response.text();
// we convert it to text as the response is either valid json or empty if network widget is disabled.
if (result.length === 0) return null;
return await networkLoadApi.parseAsync(JSON.parse(result));
}
private async getCurrentGpuLoadAsync() {
try {
const response = await fetchWithTrustedCertificatesAsync(this.url("/load/gpu"));
const result = await response.json();
const data = await gpuLoadApi.parseAsync(result);
return data.layout;
} catch {
return [];
}
}
private getChannel() {
return createChannelEventHistoryOld<z.infer<typeof cpuLoadPerCoreApiList>>(
`integration:${this.integration.id}:history:cpu`,
100,
);
}
}
const cpuLoadPerCoreApi = z.object({
load: z.number().min(0),
temp: z.number().min(0),
});
const memoryLoadApi = z.object({
load: z.number().min(0),
});
const networkLoadApi = z.object({
up: z.number().min(0),
down: z.number().min(0),
});
const internalServerInfoApi = z.object({
os: z.object({
distro: z.string(),
kernel: z.string(),
release: z.string(),
uptime: z.number().min(0),
}),
cpu: z.object({
brand: z.string(),
model: z.string(),
}),
ram: z.object({
size: z.number().min(0),
}),
storage: z.array(
z.object({
size: z.number().min(0),
disks: z.array(
z.object({
device: z.string(),
brand: z.string(),
type: z.string(),
}),
),
}),
),
gpu: z.object({
layout: z
.array(
z.object({
brand: z.string(),
model: z.string().optional(),
}),
)
.default([]),
}),
});
const cpuLoadPerCoreApiList = z.array(cpuLoadPerCoreApi);
const gpuLoadApi = z.object({
layout: z.array(
z.object({
load: z.number().min(0).optional(),
memory: z.number().min(0).optional(),
}),
),
});