diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index a51cfed2ea..93cf7de3eb 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,42 +1,51 @@ import { observable } from "@trpc/server/observable"; +import type { Modify } from "@homarr/common/types"; +import type { Integration } from "@homarr/db/schema/sqlite"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { HealthMonitoring } from "@homarr/integrations"; -import type { ProxmoxClusterInfo } from "@homarr/integrations/types"; -import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; +import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; -import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { z } from "zod"; export const healthMonitoringRouter = createTRPCRouter({ - getSystemHealthStatus: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot")) - .query(async ({ ctx }) => { + getHealthStatus: publicProcedure + .input(z.object({ pointCount: z.number().optional(), maxElements: z.number().optional() })) + .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "proxmox")) + .query(async ({ input: { pointCount = 1, maxElements = 32 }, ctx }) => { return await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = systemInfoRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + ctx.integrations.map(async (integrationWithSecrets) => { + const innerHandler = systemInfoRequestHandler.handler(integrationWithSecrets, { maxElements, pointCount }); + const healthInfo = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; return { - integrationId: integration.id, - integrationName: integration.name, - healthInfo: data, - updatedAt: timestamp, + integration, + healthInfo, }; }), ); }), - subscribeSystemHealthStatus: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot")) - .subscription(({ ctx }) => { - return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => { + + subscribeHealthStatus: publicProcedure + .input(z.object({ maxElements: z.number().optional() })) + .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "proxmox")) + .subscription(({ ctx, input: { maxElements = 32 } }) => { + return observable<{ + integration: Modify }>; + healthInfo: { data: HealthMonitoring; timestamp: Date }; + }>((emit) => { const unsubscribes: (() => void)[] = []; - for (const integration of ctx.integrations) { - const innerHandler = systemInfoRequestHandler.handler(integration, {}); - const unsubscribe = innerHandler.subscribe((healthInfo) => { + for (const integrationWithSecrets of ctx.integrations) { + const innerHandler = systemInfoRequestHandler.handler(integrationWithSecrets, { maxElements, pointCount: 1 }); + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const unsubscribe = innerHandler.subscribe((data) => { emit.next({ - integrationId: integration.id, - healthInfo, - timestamp: new Date(), + integration, + healthInfo: { data, timestamp: new Date() }, }); }); unsubscribes.push(unsubscribe); @@ -48,26 +57,4 @@ export const healthMonitoringRouter = createTRPCRouter({ }; }); }), - getClusterHealthStatus: publicProcedure - .unstable_concat(createOneIntegrationMiddleware("query", "proxmox")) - .query(async ({ ctx }) => { - const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return data; - }), - subscribeClusterHealthStatus: publicProcedure - .unstable_concat(createOneIntegrationMiddleware("query", "proxmox")) - .subscription(({ ctx }) => { - return observable((emit) => { - const unsubscribes: (() => void)[] = []; - const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); - const unsubscribe = innerHandler.subscribe((healthInfo) => { - emit.next(healthInfo); - }); - unsubscribes.push(unsubscribe); - return () => { - unsubscribe(); - }; - }); - }), }); diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index 6e87a57fb0..5e11831a9c 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -1,23 +1,14 @@ import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; +import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; import { createCronJob } from "../../lib"; export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback( - createRequestIntegrationJobHandler( - (integration, itemOptions: Record) => { - const { kind } = integration; - if (kind !== "proxmox") { - return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); - } - return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions); + createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, { + widgetKinds: ["healthMonitoring"], + getInput: { + healthMonitoring: (options) => ({ maxElements: Number(options.pointDensity), pointCount: 1 }), }, - { - widgetKinds: ["healthMonitoring"], - getInput: { - healthMonitoring: () => ({}), - }, - }, - ), + }), ); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 9c0b78b212..3c6f503975 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -220,7 +220,8 @@ export type IntegrationCategory = | "miscellaneous" | "smartHomeServer" | "indexerManager" - | "healthMonitoring" | "search" | "mediaTranscoding" - | "networkController"; + | "networkController" + | "healthMonitoring" + | "tempNone"; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index f9aec19a0e..bfd138e5c3 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -12,6 +12,7 @@ import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorre import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { EmbyIntegration } from "../emby/emby-integration"; +import { DashDotIntegration } from "../health-monitoring/dashdot/dashdot-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts deleted file mode 100644 index 9df6720a94..0000000000 --- a/packages/integrations/src/dashdot/dashdot-integration.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { humanFileSize } from "@homarr/common"; - -import "@homarr/redis"; - -import dayjs from "dayjs"; -import { z } from "zod"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; - -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 fetchWithTrustedCertificatesAsync(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 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, - }; - } - - private async getCurrentCpuLoadAsync() { - const channel = this.getChannel(); - const cpu = await fetchWithTrustedCertificatesAsync(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 fetchWithTrustedCertificatesAsync(this.url("/load/storage")); - return (await storageLoad.json()) as number[]; - } - - private async getCurrentMemoryLoadAsync() { - const memoryLoad = await fetchWithTrustedCertificatesAsync(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/integrations/src/health-monitoring/dashdot/dashdot-integration.ts b/packages/integrations/src/health-monitoring/dashdot/dashdot-integration.ts new file mode 100644 index 0000000000..5452a58ee2 --- /dev/null +++ b/packages/integrations/src/health-monitoring/dashdot/dashdot-integration.ts @@ -0,0 +1,80 @@ +import type { z, ZodType } from "zod"; + +import type { HealthMonitoring } from "../../interfaces/health-monitoring/healt-monitoring-data"; +import { HealthMonitoringIntegration } from "../../interfaces/health-monitoring/health-monitoring-interface"; +import { + configSchema, + cpuLoadSchema, + memoryLoadSchema, + networkLoadSchema, + serverInfoSchema, + storageLoadSchema, +} from "./dashdot-schema"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; + +export class DashDotIntegration extends HealthMonitoringIntegration { + public async testConnectionAsync(): Promise { + const response = await fetchWithTrustedCertificatesAsync(this.url("/info")); + await response.json(); + } + + public async getSystemInfoAsync(): Promise { + const { config } = await this.dashDotApiCallAsync("/config", configSchema); + const info = await this.dashDotApiCallAsync("/info", serverInfoSchema); + const cpuLoad = await this.dashDotApiCallAsync("/load/cpu", cpuLoadSchema); + const memoryLoad = await this.dashDotApiCallAsync("/load/ram", memoryLoadSchema); + const storageLoad = await this.dashDotApiCallAsync("/load/storage", storageLoadSchema); + const networkLoad = await this.dashDotApiCallAsync("/load/network", networkLoadSchema); + return { + system: { + name: config.override.os ?? info.os.distro, + type: "single", + version: info.os.release, + uptime: info.os.uptime * 1000, + }, + cpu: cpuLoad.map((cpu) => { + return { + id: `${cpu.core}`, + name: `${cpu.core}`, + temp: cpu.temp ? (config.use_imperial ? (cpu.temp - 32) / 1.8 : cpu.temp) : undefined, + maxValue: 100, + }; + }), + memory: [ + { + id: "unique", + name: `${config.override.ram_brand ?? info.ram.layout[0].brand} - ${config.override.ram_type ?? info.ram.layout[0].type}`, + maxValue: config.override.ram_size ?? info.ram.size, + }, + ], + storage: info.storage.map((storage, index) => { + return { + name: storage.disks.map((disk) => config.override.storage_brands?.[disk.brand] ?? disk.brand).join(", "), + used: storageLoad[index] ?? -1, + size: storage.size, + }; + }), + network: [ + { + id: "unique", + name: config.use_network_interface, + maxValue: ((config.override.network_interface_speed ?? info.network.interfaceSpeed) * 1000 ** 3) / 8, + }, + ], + history: [ + { + timestamp: Date.now(), + cpu: cpuLoad.map(({ core, load }) => ({ id: `${core}`, value: load })), + memory: [{ id: "unique", value: memoryLoad.load }], + networkUp: [{ id: "unique", value: Math.round(networkLoad.up) }], + networkDown: [{ id: "unique", value: Math.round(networkLoad.down) }], + }, + ], + }; + } + + private async dashDotApiCallAsync(path: `/${string}`, schema: T): Promise> { + const response = await fetchWithTrustedCertificatesAsync(this.url(path)); + return await schema.parseAsync(await response.json()) as Promise>; + } +} diff --git a/packages/integrations/src/health-monitoring/dashdot/dashdot-schema.ts b/packages/integrations/src/health-monitoring/dashdot/dashdot-schema.ts new file mode 100644 index 0000000000..e9216fbd23 --- /dev/null +++ b/packages/integrations/src/health-monitoring/dashdot/dashdot-schema.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +export const cpuLoadSchema = z.array( + z.object({ + load: z.number().min(0), + temp: z.number().min(0).optional(), + core: z.number(), + }), +); + +export const memoryLoadSchema = z.object({ + load: z.number().min(0), +}); + +export const storageLoadSchema = z.array(z.number().min(0)); + +export const networkLoadSchema = z.object({ + up: z.number().min(0), + down: z.number().min(0), +}); + +export const serverInfoSchema = 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), + layout: z.array(z.object({ brand: z.string(), type: z.string() })).nonempty(), + }), + storage: z.array( + z.object({ + size: z.number().min(0), + disks: z.array( + z.object({ + device: z.string(), + brand: z.string(), + type: z.string(), + }), + ), + }), + ), + network: z.object({ + interfaceSpeed: z + .number() + .min(0) + .transform((speed) => speed / 1000), + type: z.enum(["Wireless", "Bridge", "Bond", "TAP", "Wired"]), + }), +}); + +export const configSchema = z.object({ + config: z.object({ + use_network_interface: z.string().default("UNKNOWN"), + network_speed_as_bytes: z.boolean(), + fs_virtual_mounts: z.array(z.string()), + use_imperial: z.boolean(), + override: z + .object({ + os: z.string(), + cpu_brand: z.string(), + cpu_model: z.string(), + cpu_frequency: z.number().min(0), + ram_brand: z.string(), + ram_size: z.number().min(0), + ram_type: z.string(), + ram_frequency: z.number().min(0), + storage_brands: z.record(z.string(), z.string()), + storage_sizes: z.record(z.string(), z.number().min(0)), + storage_types: z.record(z.string(), z.string()), + network_interface_speed: z + .number() + .min(0) + .transform((speed) => speed / 1000), + }) + .partial(), + }), +}); diff --git a/packages/integrations/src/health-monitoring/openmediavault/openmediavault.ts b/packages/integrations/src/health-monitoring/openmediavault/openmediavault.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index d0999bee1d..cc9487ee5c 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -21,13 +21,15 @@ export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration"; export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration"; export { NextcloudIntegration } from "./nextcloud/nextcloud.integration"; +export { DashDotIntegration } from "./health-monitoring/dashdot/dashdot-integration"; +export { HealthMonitoringIntegration } from "./interfaces/health-monitoring/health-monitoring-interface"; // Types export type { IntegrationInput } from "./base/integration"; export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; -export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring"; +export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring-data"; export { MediaRequestStatus } from "./interfaces/media-requests/media-request"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request"; export type { StreamSession } from "./interfaces/media-server/session"; diff --git a/packages/integrations/src/interfaces/health-monitoring/healt-monitoring-data.ts b/packages/integrations/src/interfaces/health-monitoring/healt-monitoring-data.ts new file mode 100644 index 0000000000..9d9ef0b5cc --- /dev/null +++ b/packages/integrations/src/interfaces/health-monitoring/healt-monitoring-data.ts @@ -0,0 +1,52 @@ +import type { AtLeastOneOf } from "@homarr/common/types"; + +// Array in this interface are for multiple of the component, while following internal arrays are for historical data +export interface HealthMonitoring { + system: SystemInfos; //General system information + cpu: (CpuExtraStats & StatsBase)[]; //Per core or Per node model + memory: StatsBase[]; //Per system/node model + network: StatsBase[]; //Per network interface, to be revisited if needed + storage: StorageStats[]; //Per disk, not influenced by cluster + history: AtLeastOneOf; +} + +interface StatsBase { + id: string; + name: string; + maxValue: number; +} + +interface HistoricalData { + timestamp: number; + cpu: HistoryElement[]; + memory: HistoryElement[]; + networkUp: HistoryElement[]; + networkDown: HistoryElement[]; +} + +interface HistoryElement { + id: string; + value: number; +} + +interface SystemInfos { + name: string; + type: "single" | "cluster"; + version: string; + uptime: number; + rebootRequired?: boolean; + updateAvailable?: boolean; +} + +interface CpuExtraStats { + status?: unknown; + temp?: number; +} + +interface StorageStats { + name: string; + used: number; + size: number; + temp?: number; + smartStatus?: string; +} diff --git a/packages/integrations/src/interfaces/health-monitoring/healt-monitoring.ts b/packages/integrations/src/interfaces/health-monitoring/healt-monitoring.ts deleted file mode 100644 index 90b2b80131..0000000000 --- a/packages/integrations/src/interfaces/health-monitoring/healt-monitoring.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface HealthMonitoring { - version: string; - cpuModelName: string; - cpuUtilization: number; - memUsed: string; - memAvailable: string; - uptime: number; - loadAverage: { - "1min": number; - "5min": number; - "15min": number; - }; - rebootRequired: boolean; - availablePkgUpdates: number; - cpuTemp: number | undefined; - fileSystem: { - deviceName: string; - used: string; - available: string; - percentage: number; - }[]; - smart: { - deviceName: string; - temperature: number | null; - overallStatus: string; - }[]; -} diff --git a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-interface.ts b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-interface.ts new file mode 100644 index 0000000000..53fe673c0c --- /dev/null +++ b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-interface.ts @@ -0,0 +1,6 @@ +import { Integration } from "../../base/integration"; +import type { HealthMonitoring } from "./healt-monitoring-data"; + +export abstract class HealthMonitoringIntegration extends Integration { + public abstract getSystemInfoAsync(): Promise; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 2262164c4b..58ec1d2e90 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -1,7 +1,7 @@ export * from "./calendar-types"; export * from "./interfaces/dns-hole-summary/dns-hole-summary-types"; export * from "./interfaces/network-controller-summary/network-controller-summary-types"; -export * from "./interfaces/health-monitoring/healt-monitoring"; +export * from "./interfaces/health-monitoring/healt-monitoring-data"; export * from "./interfaces/indexer-manager/indexer"; export * from "./interfaces/media-requests/media-request"; export * from "./base/searchable-integration"; diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index c61393e517..e0c56cb898 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -7,6 +7,7 @@ export { createIntegrationOptionsChannel, createWidgetOptionsChannel, createChannelWithLatestAndEvents, + createIntegrationHistoryChannel, handshakeAsync, createSubPubChannel, createGetSetChannel, diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 686fd4f500..bd6ead7a57 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -227,59 +227,41 @@ 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); - }; +export const createIntegrationHistoryChannel = (integrationId: string, queryKey: string, maxElements = 32) => { + const channelName = `integration:${integrationId}:history:${queryKey}`; + return createChannelEventHistory(channelName, maxElements); +}; +export const createChannelEventHistory = (channelName: string, maxElements = 32) => { 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) => { + pushAsync: async (data: TData, options = { publish: false }) => { + if (options.publish) await publisher.publish(channelName, superjson.stringify(data)); await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); - await popElementsOverMaxAsync(); + await getSetClient.ltrim(channelName, 0, maxElements); }, 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) => { + /** + * Returns a slice of the available data in the channel. + * If any of the indexes are out of range (or -range), returned data will be clamped. + * @param startIndex Start index of the slice, negative values are counted from the end, defaults at beginning of range. + * @param endIndex End index of the slice, negative values are counted from the end, defaults at end of range. + */ + getSliceAsync: async (startIndex = 0, endIndex = -1) => { 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; + const itemsInCollection = await getSetClient.lrange(channelName, 0, -1); + return itemsInCollection + .map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)) + .filter((item) => item.timestamp < time); }, getLengthAsync: async () => { return await getSetClient.llen(channelName); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 48d95cd20b..e2c72c6d1d 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -2,32 +2,30 @@ import dayjs from "dayjs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types"; +import type { HealthMonitoring } from "@homarr/integrations/types"; +import { createIntegrationHistoryChannel } from "@homarr/redis"; import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< HealthMonitoring, - Exclude, "proxmox">, - Record + IntegrationKindByCategory<"healthMonitoring">, + { pointCount: number; maxElements: number } >({ - async requestAsync(integration, _input) { + async requestAsync(integration, { pointCount, maxElements }) { const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getSystemInfoAsync(); + const data = await integrationInstance.getSystemInfoAsync(); + const historyHandler = createIntegrationHistoryChannel( + integration.id, + "healthMonitoring", + maxElements, + ); + await historyHandler.pushAsync(data.history[0]); + if (pointCount === 1) return data; + const dbHistory = (await historyHandler.getSliceAsync(-pointCount, -1)).map(({ data }) => data); + const history = (dbHistory.length > 0 ? dbHistory : data.history) as HealthMonitoring["history"]; + return { ...data, history } as HealthMonitoring; }, cacheDuration: dayjs.duration(5, "seconds"), queryKey: "systemInfo", }); - -export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler< - ProxmoxClusterInfo, - "proxmox", - Record ->({ - async requestAsync(integration, _input) { - const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getClusterInfoAsync(); - }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "clusterInfo", -}); diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index d1ee3dd286..a9cb050bcf 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -1,53 +1,362 @@ "use client"; -import { ScrollArea, Tabs } from "@mantine/core"; +import { AreaChart, getFilteredChartTooltipPayload } from "@mantine/charts"; +import type { DefaultMantineColor } from "@mantine/core"; +import { Box, Group, Progress, Stack, Tabs, Text } from "@mantine/core"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import { clientApi } from "@homarr/api/client"; +import { humanFileSize } from "@homarr/common"; +import type { HealthMonitoring } from "@homarr/integrations"; +import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -import { ClusterHealthMonitoring } from "./cluster/cluster-health"; -import { SystemHealthMonitoring } from "./system-health"; + +import "@mantine/charts/styles.css"; + +import type { AtLeastOneOf } from "@homarr/common/types"; + +import { NoIntegrationSelectedError } from "../errors/no-integration-selected"; dayjs.extend(duration); export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) { - const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds); + const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery( + { + integrationIds: props.integrationIds, + maxElements: Number(props.options.pointDensity), + pointCount: Number(props.options.pointDensity), + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + + const utils = clientApi.useUtils(); + + clientApi.widget.healthMonitoring.subscribeHealthStatus.useSubscription( + { + integrationIds: props.integrationIds, + maxElements: Number(props.options.pointDensity), + }, + { + onData(data) { + utils.widget.healthMonitoring.getHealthStatus.setData( + { + integrationIds: props.integrationIds, + maxElements: Number(props.options.pointDensity), + pointCount: Number(props.options.pointDensity), + }, + (prevData) => { + if (!prevData) { + return undefined; + } + const newData = prevData.map((item) => + item.integration.id === data.integration.id + ? { + integration: item.integration, + healthInfo: { + timestamp: data.healthInfo.timestamp, + data: { + ...data.healthInfo.data, + history: [...item.healthInfo.data.history, ...data.healthInfo.data.history].slice( + -Number(props.options.pointDensity), + ) as AtLeastOneOf, + }, + }, + } + : item, + ); + return newData; + }, + ); + }, + }, + ); + const t = useI18n(); - const proxmoxIntegrationId = integrations.find((integration) => integration.kind === "proxmox")?.id; + if (props.integrationIds.length === 0) { + throw new NoIntegrationSelectedError(); + } + + return ( + + 1 ? "flex" : "none"}> + {healthData.map(({ integration }) => ( + + {integration.name} + + ))} + + + {healthData.map(({ integration, healthInfo: { data } }) => { + return ( + + {props.options.systemInfo && ( + + + {data.system.name} + {formatUptime(data.system.uptime, t)} + + + )} + {props.options.cpu && ( + + )} + {props.options.memory && data.system.type === "single" && ( + + )} + {props.options.network && } + {props.options.fileSystem && ( + + )} + + ); + })} + + ); +} - if (!proxmoxIntegrationId) { - return ; +type HistoryKeys = keyof Omit; + +const historyFormatTable = { + cpu: { key: "cpu", color: "blue", format: "percent", negative: false }, + memory: { key: "memory", color: "orange", format: "humanFileSize", negative: false }, + networkUp: { key: "network", color: "green", format: "humanFileSize", negative: false }, + networkDown: { key: "network", color: "red", format: "humanFileSize", negative: true }, +} satisfies Record< + HistoryKeys, + { + key: keyof Pick; + color: DefaultMantineColor; + format: "percent" | "humanFileSize"; + negative: boolean; } +>; - const otherIntegrationIds = integrations - .filter((integration) => integration.kind !== "proxmox") - .map((integration) => integration.id); - if (otherIntegrationIds.length === 0) { - return ; +const dataToGraph = (data: HealthMonitoring, series: AtLeastOneOf) => { + const referenceArray = data.history[0][series[0]]; + // Check that all selected series have the same length + if (series.some((name) => data.history[0][name].length !== data.history[0][series[0]].length)) { + return null; } + return referenceArray.map(({ id }) => ({ + id, + negative: series.some((key) => historyFormatTable[key].negative), + graph: data.history.map((item) => ({ + date: item.timestamp, + name: data[historyFormatTable[series[0]].key].find((element) => element.id === id)?.name ?? "", + ...series.reduce( + (acc, key) => { + const value = item[key].find((element) => element.id === id)?.value ?? null; + const scale = data[historyFormatTable[key].key].find((element) => element.id === id)?.maxValue ?? 100; + acc[`original.${key}`] = value; + acc[`max.${key}`] = scale; + acc[`format.${key}`] = historyFormatTable[key].format; + acc[key] = value ? value / (scale / (historyFormatTable[key].negative ? -100 : 100)) : null; + return acc; + }, + {} as Partial>, + ), + })), + })); +}; + +interface GridAreaChartProps { + data: HealthMonitoring; + series: AtLeastOneOf; + columns?: number; +} + +const GridAreaChart = ({ data, series, columns = 1 }: GridAreaChartProps) => { + const formattedData = dataToGraph(data, series); + const formattedSeries = series.map((name) => ({ + name, + color: historyFormatTable[name].color, + })); + if (!formattedData || formattedData.length === 0) return null; return ( - - - - - {t("widget.healthMonitoring.tab.system")} - - - {t("widget.healthMonitoring.tab.cluster")} - - - - - - - - - - + + {formattedData.map(({ id, negative, graph }) => { + return ( + [] | undefined} />, + }} + /> + ) + })} + + ); +}; + +interface CustomTooltipProps { + payload: Record[] | undefined; +} + +const CustomTooltip = ({ payload }: CustomTooltipProps) => { + if (!payload) return; + // TODO: check why this is needed? + // const filteredData = getFilteredChartTooltipPayload(payload); + const filteredData = payload; + return ( + + {((filteredData[0]?.payload ?? { name: "" }) as { name: string }).name} + {filteredData.map(({ dataKey, payload: subPayload }) => { + const values = subPayload as Record & Record<`format.${string}`, string>; + const displayValue = + historyFormatTable[dataKey as keyof typeof historyFormatTable].format === "humanFileSize" + ? `${humanFileSize(values[`original.${dataKey}`] ?? 0)}/${humanFileSize(values[`max.${dataKey}`] ?? 0)}` + : `${values[dataKey as string]?.toFixed()}%`; + return ( + + {`${dataKey}: ${displayValue}`} + + ); + })} + ); +}; + +interface StorageEntriesProps { + columns: number; + fahrenheit: boolean; + storages: HealthMonitoring["storage"]; } + +const StorageEntries = ({ columns, fahrenheit, storages }: StorageEntriesProps) => { + return ( + + {storages.map((storage) => { + const percentage = (storage.used / storage.size) * 100; + return ( + + + {storage.name} + {storage.temp && ( + + {temperatureFormatter(storage.temp, fahrenheit)} + + )} + {`${humanFileSize(storage.used)}/${humanFileSize(storage.size)}`} + + + + ); + })} + + ); +}; + +const temperatureFormatter = (temperature: number, fahrenheit: boolean) => + fahrenheit ? `${temperature * (9 / 5) + 32}°F` : `${temperature}°C`; + +export const progressColor = (percentage: number) => { + if (percentage < 40) return "green"; + else if (percentage < 60) return "yellow"; + else if (percentage < 90) return "orange"; + else return "red"; +}; + +export const formatUptime = (time: number, t: TranslationFunction) => { + const timeDiff = dayjs.duration(time, "milliseconds"); + return t("widget.healthMonitoring.popover.uptime", { + months: `${Math.floor(timeDiff.as("months"))}`, + days: `${Math.floor(timeDiff.as("days"))}`, + //replace with timeDiff.hours() and timeDiff.minutes() once it's fixed + hours: `${Math.floor(timeDiff.as("hours") % 24)}`, + minutes: `${Math.floor(timeDiff.as("minutes") % 60)}`, + }); +}; + +interface FileSystem { + deviceName: string; + used: string; + available: string; + percentage: number; +} + +interface SmartData { + deviceName: string; + temperature: number; + overallStatus: string; +} + +export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => { + return fileSystems + .map((fileSystem) => { + const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, ""); + const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName); + + return { + deviceName: smartDisk?.deviceName ?? fileSystem.deviceName, + used: fileSystem.used, + available: fileSystem.available, + percentage: fileSystem.percentage, + temperature: smartDisk?.temperature ?? 0, + overallStatus: smartDisk?.overallStatus ?? "", + }; + }) + .sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName)); +}; diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index b3bc28980e..35b3d98329 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -1,6 +1,7 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { z } from "zod"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -12,30 +13,35 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon fahrenheit: factory.switch({ defaultValue: false, }), + systemInfo: factory.switch({ + defaultValue: true, + }), cpu: factory.switch({ defaultValue: true, }), + cpuDetailed: factory.switch({ + defaultValue: true, + }), + cpuColumns: factory.number({ + defaultValue: 2, + step: 1, + validate: z.number().min(1).max(4), + }), memory: factory.switch({ defaultValue: true, }), + network: factory.switch({ + defaultValue: true, + }), fileSystem: factory.switch({ defaultValue: true, }), - defaultTab: factory.select({ - defaultValue: "system", - options: [ - { value: "system", label: "System" }, - { value: "cluster", label: "Cluster" }, - ] as const, - }), - sectionIndicatorRequirement: factory.select({ - defaultValue: "all", - options: [ - { value: "all", label: "All active" }, - { value: "any", label: "Any active" }, - ] as const, - }), - })); + pointDensity: factory.number({ + defaultValue: 12, + step: 1, + validate: z.number().min(1).max(60), + }), + })) }, supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"), errors: { diff --git a/packages/widgets/src/health-monitoring/system-health.module.css b/packages/widgets/src/health-monitoring/system-health.module.css deleted file mode 100644 index 1ab18a6562..0000000000 --- a/packages/widgets/src/health-monitoring/system-health.module.css +++ /dev/null @@ -1,7 +0,0 @@ -[data-mantine-color-scheme="light"] .card { - background-color: var(--mantine-color-gray-1); -} - -[data-mantine-color-scheme="dark"] .card { - background-color: var(--mantine-color-dark-7); -} diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx deleted file mode 100644 index 6e4aa2ed45..0000000000 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ /dev/null @@ -1,322 +0,0 @@ -"use client"; - -import { - ActionIcon, - Box, - Card, - Divider, - Flex, - Group, - Indicator, - List, - Modal, - Progress, - Stack, - Text, - Tooltip, -} from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; -import { - IconBrain, - IconClock, - IconCpu, - IconCpu2, - IconFileReport, - IconInfoCircle, - IconServer, - IconTemperature, - IconVersions, -} from "@tabler/icons-react"; -import combineClasses from "clsx"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; - -import { clientApi } from "@homarr/api/client"; -import { useRequiredBoard } from "@homarr/boards/context"; -import type { TranslationFunction } from "@homarr/translation"; -import { useI18n } from "@homarr/translation/client"; - -import type { WidgetComponentProps } from "../definition"; -import { CpuRing } from "./rings/cpu-ring"; -import { CpuTempRing } from "./rings/cpu-temp-ring"; -import { formatMemoryUsage, MemoryRing } from "./rings/memory-ring"; -import classes from "./system-health.module.css"; - -dayjs.extend(duration); - -export const SystemHealthMonitoring = ({ - options, - integrationIds, - width, -}: WidgetComponentProps<"healthMonitoring">) => { - const t = useI18n(); - const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery( - { - integrationIds, - }, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - const [opened, { open, close }] = useDisclosure(false); - const utils = clientApi.useUtils(); - const board = useRequiredBoard(); - - clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription( - { integrationIds }, - { - onData(data) { - utils.widget.healthMonitoring.getSystemHealthStatus.setData({ integrationIds }, (prevData) => { - if (!prevData) { - return undefined; - } - return prevData.map((item) => - item.integrationId === data.integrationId - ? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp } - : item, - ); - }); - }, - }, - ); - - const isTiny = width < 256; - - return ( - - {healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => { - const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); - const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed); - return ( - - - 0 ? "blue" : "gray"} - position="top-end" - size={16} - label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined} - disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0} - > - - - - - - - - - }> - {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })} - - }> - {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })} - - }> - {t("widget.healthMonitoring.popover.memoryAvailable", { - memoryAvailable: memoryUsage.memFree.GB, - percent: String(memoryUsage.memFree.percent), - })} - - }> - {t("widget.healthMonitoring.popover.version", { - version: healthInfo.version, - })} - - }> - {formatUptime(healthInfo.uptime, t)} - - }> - {t("widget.healthMonitoring.popover.loadAverage")} - - }> - - {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: "5" })} {healthInfo.loadAverage["5min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "} - {healthInfo.loadAverage["15min"]}% - - - - - - - - {options.cpu && } - {options.cpu && ( - - )} - {options.memory && ( - - )} - - { - - {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })} - - } - {options.fileSystem && - disksData.map((disk) => { - return ( - - - - - - - {disk.deviceName} - - - - - - {options.fahrenheit - ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F` - : `${disk.temperature}°C`} - - - - - - {disk.overallStatus ? disk.overallStatus : "N/A"} - - - - - - - - {t("widget.healthMonitoring.popover.used")} - - - - - = 1 - ? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB` - : `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB` - } - > - - - {t("widget.healthMonitoring.popover.available")} - - - - - - - ); - })} - - ); - })} - - ); -}; - -export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => { - const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds"); - const months = uptimeDuration.months(); - const days = uptimeDuration.days(); - const hours = uptimeDuration.hours(); - const minutes = uptimeDuration.minutes(); - - return t("widget.healthMonitoring.popover.uptime", { - months: String(months), - days: String(days), - hours: String(hours), - minutes: String(minutes), - }); -}; - -export const progressColor = (percentage: number) => { - if (percentage < 40) return "green"; - else if (percentage < 60) return "yellow"; - else if (percentage < 90) return "orange"; - else return "red"; -}; - -interface FileSystem { - deviceName: string; - used: string; - available: string; - percentage: number; -} - -interface SmartData { - deviceName: string; - temperature: number | null; - overallStatus: string; -} - -export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => { - return fileSystems - .map((fileSystem) => { - const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, ""); - const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName); - - return { - deviceName: smartDisk?.deviceName ?? fileSystem.deviceName, - used: fileSystem.used, - available: fileSystem.available, - percentage: fileSystem.percentage, - temperature: smartDisk?.temperature ?? 0, - overallStatus: smartDisk?.overallStatus ?? "", - }; - }) - .sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName)); -};