feat: add dashdot integration (#1541)

This commit is contained in:
Manuel
2024-12-07 18:08:50 +01:00
committed by GitHub
parent 6a79d01d79
commit 84e500279d
9 changed files with 235 additions and 8 deletions

View File

@@ -14,6 +14,11 @@
"gridstack",
"homarr",
"jellyfin",
"llen",
"lpop",
"lpush",
"lrange",
"ltrim",
"mantine",
"manuel-rw",
"Meierschlumpf",

View File

@@ -49,6 +49,7 @@
"pnpm": {
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
}
},
"allowNonAppliedPatches": true
}
}

View File

@@ -1,5 +1,6 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { HealthMonitoring } from "@homarr/integrations";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
@@ -8,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -26,7 +27,7 @@ export const healthMonitoringRouter = createTRPCRouter({
}),
subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];

View File

@@ -144,6 +144,13 @@ export const integrationDefs = {
category: ["healthMonitoring"],
supportsSearch: false,
},
dashDot: {
name: "Dash.",
secretKinds: [[]],
category: ["healthMonitoring"],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
supportsSearch: false,
},
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;

View File

@@ -4,6 +4,7 @@ import type { Integration as DbIntegration } from "@homarr/db/schema/sqlite";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
@@ -68,4 +69,5 @@ export const integrationCreators = {
openmediavault: OpenMediaVaultIntegration,
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;

View File

@@ -0,0 +1,148 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
import { z } from "@homarr/validation";
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
import { Integration } from "../base/integration";
import type { HealthMonitoring } from "../types";
export class DashDotIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const response = await fetch(this.url("/info"));
await response.json();
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync();
const storageLoad = await this.getCurrentStorageLoadAsync();
const channel = this.getChannel();
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
return {
cpuUtilization: cpuLoad.sumLoad,
memUsed: `${memoryLoad.loadInBytes}`,
memAvailable: `${info.maxAvailableMemoryBytes - memoryLoad.loadInBytes}`,
fileSystem: info.storage.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: humanFileSize(storageLoad[index]!),
available: `${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: [],
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 fetch(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,
};
}
private async getCurrentCpuLoadAsync() {
const channel = this.getChannel();
const cpu = await fetch(this.url("/load/cpu"));
const data = await cpuLoadPerCoreApiList.parseAsync(await cpu.json());
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 fetch(this.url("/load/storage"));
return (await storageLoad.json()) as number[];
}
private async getCurrentMemoryLoadAsync() {
const memoryLoad = await fetch(this.url("/load/ram"));
const data = await memoryLoadApi.parseAsync(await memoryLoad.json());
return {
loadInBytes: data.load,
};
}
private getChannel() {
return createChannelEventHistory<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 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(),
}),
),
}),
),
});
const cpuLoadPerCoreApiList = z.array(cpuLoadPerCoreApi);

View File

@@ -187,6 +187,67 @@ export const createItemChannel = <TData>(itemId: string) => {
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
};
export const createChannelEventHistory = <TData>(channelName: string, maxElements = 15) => {
const popElementsOverMaxAsync = async () => {
const length = await getSetClient.llen(channelName);
if (length <= maxElements) {
return;
}
await getSetClient.ltrim(channelName, length - maxElements, length);
};
return {
subscribe: (callback: (data: TData) => void) => {
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
callback(superjson.parse(message));
});
},
publishAndPushAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
},
pushAsync: async (data: TData) => {
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
},
clearAsync: async () => {
await getSetClient.del(channelName);
},
getLastAsync: async () => {
const length = await getSetClient.llen(channelName);
const data = await getSetClient.lrange(channelName, length - 1, length);
if (data.length !== 1) return null;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!);
},
getSliceAsync: async (startIndex: number, endIndex: number) => {
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
},
getSliceUntilTimeAsync: async (time: Date) => {
const length = await getSetClient.llen(channelName);
const items: TData[] = [];
const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1);
for (let i = 0; i < length - 1; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const deserializedItem = superjson.parse<{ data: TData; timestamp: Date }>(itemsInCollection[i]!);
if (deserializedItem.timestamp < time) {
continue;
}
items.push(deserializedItem.data);
}
return items;
},
getLengthAsync: async () => {
return await getSetClient.llen(channelName);
},
name: channelName,
};
};
export const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
return {
subscribe: (callback: (data: TData) => void) => {

View File

@@ -169,15 +169,15 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
</List.Item>
<List m="0.5cqmin" withPadding center spacing="0.5cqmin" icon={<IconCpu size="1cqmin" />}>
<List.Item className="health-monitoring-information-load-average-1min">
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
</List.Item>
<List.Item className="health-monitoring-information-load-average-5min">
{t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "}
{healthInfo.loadAverage["5min"]}
{healthInfo.loadAverage["5min"]}%
</List.Item>
<List.Item className="health-monitoring-information-load-average-15min">
{t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "}
{healthInfo.loadAverage["15min"]}
{healthInfo.loadAverage["15min"]}%
</List.Item>
</List>
</List>
@@ -363,7 +363,7 @@ const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: nu
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp}°C`}
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
</Text>
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
</Center>

View File

@@ -1,5 +1,7 @@
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -19,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
defaultValue: true,
}),
})),
supportedIntegrations: ["openmediavault"],
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,