From 84e500279d5cc2db696365da46cf405c7a9fd06f Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sat, 7 Dec 2024 18:08:50 +0100 Subject: [PATCH] feat: add dashdot integration (#1541) --- .vscode/settings.json | 5 + package.json | 3 +- .../src/router/widgets/health-monitoring.ts | 5 +- packages/definitions/src/integration.ts | 7 + packages/integrations/src/base/creator.ts | 2 + .../src/dashdot/dashdot-integration.ts | 148 ++++++++++++++++++ packages/redis/src/lib/channel.ts | 61 ++++++++ .../src/health-monitoring/component.tsx | 8 +- .../widgets/src/health-monitoring/index.ts | 4 +- 9 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 packages/integrations/src/dashdot/dashdot-integration.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 0cf8dc65f..2e67df382 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,11 @@ "gridstack", "homarr", "jellyfin", + "llen", + "lpop", + "lpush", + "lrange", + "ltrim", "mantine", "manuel-rw", "Meierschlumpf", diff --git a/package.json b/package.json index e68d1e811..172c86c67 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "pnpm": { "patchedDependencies": { "pretty-print-error": "patches/pretty-print-error.patch" - } + }, + "allowNonAppliedPatches": true } } diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 8c7ee16dc..2169efafd 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -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)[] = []; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f9f6cc3e7..8c9645be9 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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; export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 4c6e2aa6d..54643e4db 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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 Integration>; diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts new file mode 100644 index 000000000..271e17409 --- /dev/null +++ b/packages/integrations/src/dashdot/dashdot-integration.ts @@ -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 { + const response = await fetch(this.url("/info")); + await response.json(); + } + + public async getSystemInfoAsync(): Promise { + 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[]) { + const averages = cpuLoad.map((load) => this.getAverageOfCpu(load)); + return averages.reduce((acc, current) => acc + current, 0) / averages.length; + } + + private getAverageOfCpu(cpuLoad?: z.infer) { + 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>( + `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); diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index cdf84f492..68664a2d7 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -187,6 +187,67 @@ export const createItemChannel = (itemId: string) => { return createChannelWithLatestAndEvents(`item:${itemId}`); }; +export const createChannelEventHistory = (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 = (channelName: string) => { return { subscribe: (callback: (data: TData) => void) => { diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index c9c7aa946..fdde45dfa 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -169,15 +169,15 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg }> - {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]} + {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% {t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "} - {healthInfo.loadAverage["5min"]} + {healthInfo.loadAverage["5min"]}% {t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "} - {healthInfo.loadAverage["15min"]} + {healthInfo.loadAverage["15min"]}% @@ -363,7 +363,7 @@ const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: nu label={
- {fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp}°C`} + {fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index 5ad3c06cc..970f827f4 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -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,