diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts new file mode 100644 index 000000000..bd3d3bf27 --- /dev/null +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -0,0 +1,52 @@ +import { observable } from "@trpc/server/observable"; + +import type { HealthMonitoring } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const healthMonitoringRouter = createTRPCRouter({ + getHealthStatus: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault")) + .query(async ({ ctx }) => { + return await Promise.all( + ctx.integrations.map(async (integration) => { + const channel = createItemAndIntegrationChannel("healthMonitoring", integration.id); + const data = await channel.getAsync(); + if (!data) { + return null; + } + + return { + integrationId: integration.id, + integrationName: integration.name, + healthInfo: data.data, + }; + }), + ); + }), + + subscribeHealthStatus: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault")) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; healthInfo: HealthMonitoring }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integration of ctx.integrations) { + const channel = createItemAndIntegrationChannel("healthMonitoring", integration.id); + const unsubscribe = channel.subscribe((healthInfo) => { + emit.next({ + integrationId: integration.id, + healthInfo, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index b8b221536..7f030a4bb 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -3,6 +3,7 @@ import { appRouter } from "./app"; import { calendarRouter } from "./calendar"; import { dnsHoleRouter } from "./dns-hole"; import { downloadsRouter } from "./downloads"; +import { healthMonitoringRouter } from "./health-monitoring"; import { indexerManagerRouter } from "./indexer-manager"; import { mediaRequestsRouter } from "./media-requests"; import { mediaServerRouter } from "./media-server"; @@ -23,4 +24,5 @@ export const widgetRouter = createTRPCRouter({ mediaRequests: mediaRequestsRouter, rssFeed: rssFeedRouter, indexerManager: indexerManagerRouter, + healthMonitoring: healthMonitoringRouter, }); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index d391c3239..7e2d357e0 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -2,6 +2,7 @@ import { analyticsJob } from "./jobs/analytics"; import { iconsUpdaterJob } from "./jobs/icons-updater"; import { dnsHoleJob } from "./jobs/integrations/dns-hole"; import { downloadsJob } from "./jobs/integrations/downloads"; +import { healthMonitoringJob } from "./jobs/integrations/health-monitoring"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; @@ -24,6 +25,7 @@ export const jobGroup = createCronJobGroup({ mediaRequests: mediaRequestsJob, rssFeeds: rssFeedsJob, indexerManager: indexerManagerJob, + healthMonitoring: healthMonitoringJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts new file mode 100644 index 000000000..1f187d354 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -0,0 +1,22 @@ +import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; + +import { createCronJob } from "../../lib"; + +export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(async () => { + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["healthMonitoring"], + }); + + for (const itemForIntegration of itemsForIntegration) { + for (const integration of itemForIntegration.integrations) { + const openmediavault = integrationCreatorFromSecrets(integration.integration); + const healthInfo = await openmediavault.getSystemInfoAsync(); + const channel = createItemAndIntegrationChannel("healthMonitoring", integration.integrationId); + await channel.publishAndUpdateLastStateAsync(healthInfo); + } + } +}); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 94736e857..8603f148a 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -119,6 +119,12 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png", category: ["smartHomeServer"], }, + openmediavault: { + name: "OpenMediaVault", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png", + category: ["healthMonitoring"], + }, } as const satisfies Record; export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; @@ -168,4 +174,5 @@ export type IntegrationCategory = | "usenet" | "torrent" | "smartHomeServer" - | "indexerManager"; + | "indexerManager" + | "healthMonitoring"; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index fba9af698..637f20e3f 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -16,5 +16,6 @@ export const widgetKinds = [ "mediaRequests-requestStats", "rssFeed", "indexerManager", + "healthMonitoring", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 2a1c46969..beaf6d1a3 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -14,6 +14,7 @@ import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; +import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; @@ -60,4 +61,5 @@ export const integrationCreators = { jellyseerr: JellyseerrIntegration, overseerr: OverseerrIntegration, prowlarr: ProwlarrIntegration, + openmediavault: OpenMediaVaultIntegration, } satisfies Partial Integration>>; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 0b5cd8a35..0c650a0e3 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -6,23 +6,25 @@ export { DownloadClientIntegration } from "./interfaces/downloads/download-clien export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration"; export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; +export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration"; +export { OverseerrIntegration } from "./overseerr/overseerr-integration"; +export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; +export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; -export { OverseerrIntegration } from "./overseerr/overseerr-integration"; -export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; -export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; // 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 { 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"; +export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; +export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; +export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; +export type { IntegrationInput } from "./base/integration"; // Schemas export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; diff --git a/packages/integrations/src/interfaces/health-monitoring/healt-monitoring.ts b/packages/integrations/src/interfaces/health-monitoring/healt-monitoring.ts new file mode 100644 index 000000000..6f3dc95a5 --- /dev/null +++ b/packages/integrations/src/interfaces/health-monitoring/healt-monitoring.ts @@ -0,0 +1,27 @@ +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; + fileSystem: { + deviceName: string; + used: string; + available: string; + percentage: number; + }[]; + smart: { + deviceName: string; + temperature: number; + overallStatus: string; + }[]; +} diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts new file mode 100644 index 000000000..fc73a2e31 --- /dev/null +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -0,0 +1,155 @@ +import { Integration } from "../base/integration"; +import { IntegrationTestConnectionError } from "../base/test-connection-error"; +import type { HealthMonitoring } from "../types"; +import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-types"; + +export class OpenMediaVaultIntegration extends Integration { + static extractSessionIdFromCookies(headers: Headers): string { + const cookies = headers.get("set-cookie") ?? ""; + const sessionId = cookies + .split(";") + .find((cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID")); + + if (sessionId) { + return sessionId; + } else { + throw new Error("Session ID not found in cookies"); + } + } + + static extractLoginTokenFromCookies(headers: Headers): string { + const cookies = headers.get("set-cookie") ?? ""; + const loginToken = cookies + .split(";") + .find((cookie) => cookie.includes("X-OPENMEDIAVAULT-LOGIN") || cookie.includes("OPENMEDIAVAULT-LOGIN")); + + if (loginToken) { + return loginToken; + } else { + throw new Error("Login token not found in cookies"); + } + } + + public async getSystemInfoAsync(): Promise { + if (!this.headers) { + await this.authenticateAndConstructSessionInHeaderAsync(); + } + + const systemResponses = await this.makeOpenMediaVaultRPCCallAsync("system", "getInformation", {}, this.headers); + const fileSystemResponse = await this.makeOpenMediaVaultRPCCallAsync( + "filesystemmgmt", + "enumerateMountedFilesystems", + { includeroot: true }, + this.headers, + ); + const smartResponse = await this.makeOpenMediaVaultRPCCallAsync("smart", "enumerateDevices", {}, this.headers); + const cpuTempResponse = await this.makeOpenMediaVaultRPCCallAsync("cputemp", "get", {}, this.headers); + + const systemResult = systemInformationSchema.safeParse(await systemResponses.json()); + const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json()); + const smartResult = smartSchema.safeParse(await smartResponse.json()); + const cpuTempResult = cpuTempSchema.safeParse(await cpuTempResponse.json()); + + if (!systemResult.success) { + throw new Error("Invalid system information response"); + } + if (!fileSystemResult.success) { + throw new Error("Invalid file system response"); + } + if (!smartResult.success) { + throw new Error("Invalid SMART information response"); + } + if (!cpuTempResult.success) { + throw new Error("Invalid CPU temperature response"); + } + + const fileSystem = fileSystemResult.data.response.map((fileSystem) => ({ + deviceName: fileSystem.devicename, + used: fileSystem.used, + available: fileSystem.available, + percentage: fileSystem.percentage, + })); + + const smart = smartResult.data.response.map((smart) => ({ + deviceName: smart.devicename, + temperature: smart.temperature, + overallStatus: smart.overallstatus, + })); + + return { + version: systemResult.data.response.version, + cpuModelName: systemResult.data.response.cpuModelName, + cpuUtilization: systemResult.data.response.cpuUtilization, + memUsed: systemResult.data.response.memUsed, + memAvailable: systemResult.data.response.memAvailable, + uptime: systemResult.data.response.uptime, + loadAverage: { + "1min": systemResult.data.response.loadAverage["1min"], + "5min": systemResult.data.response.loadAverage["5min"], + "15min": systemResult.data.response.loadAverage["15min"], + }, + rebootRequired: systemResult.data.response.rebootRequired, + availablePkgUpdates: systemResult.data.response.availablePkgUpdates, + cpuTemp: cpuTempResult.data.response.cputemp, + fileSystem, + smart, + }; + } + + public async testConnectionAsync(): Promise { + const response = await this.makeOpenMediaVaultRPCCallAsync("session", "login", { + username: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }); + + if (!response.ok) { + throw new IntegrationTestConnectionError("invalidCredentials"); + } + const result = (await response.json()) as unknown; + if (typeof result !== "object" || result === null || !("response" in result)) { + throw new IntegrationTestConnectionError("invalidJson"); + } + } + + private async makeOpenMediaVaultRPCCallAsync( + serviceName: string, + method: string, + params: Record, + headers: Record = {}, + ): Promise { + return await fetch(`${this.integration.url}/rpc.php`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify({ + service: serviceName, + method, + params, + }), + }); + } + + private headers: Record | undefined = undefined; + + private async authenticateAndConstructSessionInHeaderAsync() { + const authResponse = await this.makeOpenMediaVaultRPCCallAsync("session", "login", { + username: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }); + const authResult = (await authResponse.json()) as Response; + const response = (authResult as { response?: { sessionid?: string } }).response; + let sessionId; + const headers: Record = {}; + if (response?.sessionid) { + sessionId = response.sessionid; + headers["X-OPENMEDIAVAULT-SESSIONID"] = sessionId; + } else { + sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(authResponse.headers); + const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(authResponse.headers); + headers.Cookie = `${loginToken};${sessionId}`; + } + this.headers = headers; + } +} diff --git a/packages/integrations/src/openmediavault/openmediavault-types.ts b/packages/integrations/src/openmediavault/openmediavault-types.ts new file mode 100644 index 000000000..dcd1660df --- /dev/null +++ b/packages/integrations/src/openmediavault/openmediavault-types.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +// Schema for system information +export const systemInformationSchema = z.object({ + response: z.object({ + version: z.string(), + cpuModelName: z.string(), + cpuUtilization: z.number(), + memUsed: z.string(), + memAvailable: z.string(), + uptime: z.number(), + loadAverage: z.object({ + "1min": z.number(), + "5min": z.number(), + "15min": z.number(), + }), + rebootRequired: z.boolean(), + availablePkgUpdates: z.number(), + }), +}); + +// Schema for file systems +export const fileSystemSchema = z.object({ + response: z.array( + z.object({ + devicename: z.string(), + used: z.string(), + available: z.string(), + percentage: z.number(), + }), + ), +}); + +// Schema for SMART information +export const smartSchema = z.object({ + response: z.array( + z.object({ + devicename: z.string(), + temperature: z.union([z.string(), z.number()]).transform((val) => { + // Convert string to number if necessary + const temp = typeof val === "string" ? parseFloat(val) : val; + if (isNaN(temp)) { + throw new Error("Invalid temperature value"); + } + return temp; + }), + overallstatus: z.string(), + }), + ), +}); + +// Schema for CPU temperature +export const cpuTempSchema = z.object({ + response: z.object({ + cputemp: z.number(), + }), +}); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 0af72f1d7..2bdcc6988 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -1,5 +1,6 @@ export * from "./calendar-types"; export * from "./interfaces/dns-hole-summary/dns-hole-summary-types"; +export * from "./interfaces/health-monitoring/healt-monitoring"; export * from "./interfaces/indexer-manager/indexer"; export * from "./interfaces/media-requests/media-request"; export * from "./pi-hole/pi-hole-types"; diff --git a/packages/old-import/src/widgets/definitions/index.ts b/packages/old-import/src/widgets/definitions/index.ts index 0ec256693..e74c5c98c 100644 --- a/packages/old-import/src/widgets/definitions/index.ts +++ b/packages/old-import/src/widgets/definitions/index.ts @@ -66,6 +66,7 @@ export const widgetKindMapping = { "mediaRequests-requestList": "media-requests-list", "mediaRequests-requestStats": "media-requests-stats", indexerManager: "indexer-manager", + healthMonitoring: "health-monitoring", } satisfies Record; // Use null for widgets that did not exist in oldmarr // TODO: revert assignment so that only old widgets are needed in the object, diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index afc89393f..cb95a7629 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -103,6 +103,12 @@ const optionMapping: OptionMapping = { indexerManager: { openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab, }, + healthMonitoring: { + cpu: (oldOptions) => oldOptions.cpu, + memory: (oldOptions) => oldOptions.memory, + fahrenheit: (oldOptions) => oldOptions.fahrenheit, + fileSystem: (oldOptions) => oldOptions.fileSystem, + }, app: null, }; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index e1248959b..6d6e88d0b 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1069,6 +1069,41 @@ export default { internalServerError: "Failed to fetch indexers status", }, }, + healthMonitoring: { + name: "System Health Monitoring", + description: "Displays information showing the health and status of your system(s).", + option: { + fahrenheit: { + label: "CPU Temp in Fahrenheit", + }, + cpu: { + label: "Show CPU Info", + }, + memory: { + label: "Show Memory Info", + }, + fileSystem: { + label: "Show Filesystem Info", + }, + }, + popover: { + information: "Information", + processor: "Processor:", + memory: "Memory:", + version: "Version:", + uptime: "Uptime: {days} days, {hours} hours", + loadAverage: "Load average:", + minute: "1 minute:", + minutes: "{count} minutes:", + used: "Used", + diskAvailable: "Available", + memAvailable: "Available:", + }, + memory: {}, + error: { + internalServerError: "Failed to fetch health status", + }, + }, common: { location: { query: "City / Postal code", @@ -1842,6 +1877,9 @@ export default { indexerManager: { label: "Indexer Manager", }, + healthMonitoring: { + label: "Health Monitoring", + }, dnsHole: { label: "DNS Hole Data", }, diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx new file mode 100644 index 000000000..5bea4996a --- /dev/null +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -0,0 +1,359 @@ +"use client"; + +import { + Avatar, + Box, + Card, + Center, + Divider, + Flex, + Group, + Indicator, + List, + Modal, + Progress, + RingProgress, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { useDisclosure, useElementSize, useListState } from "@mantine/hooks"; +import { + IconBrain, + IconClock, + IconCpu, + IconCpu2, + IconFileReport, + IconInfoCircle, + IconServer, + IconTemperature, + IconVersions, +} from "@tabler/icons-react"; + +import type { TranslationFunction } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationSelectedError } from "../errors"; + +export default function HealthMonitoringWidget({ + options, + integrationIds, + serverData, +}: WidgetComponentProps<"healthMonitoring">) { + const t = useI18n(); + const [healthData] = useListState(serverData?.initialData ?? []); + const [opened, { open, close }] = useDisclosure(false); + + if (integrationIds.length === 0) { + throw new NoIntegrationSelectedError(); + } + return ( + + {healthData.map(({ integrationId, integrationName, healthInfo }) => { + const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed); + const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); + const { ref, width } = useElementSize(); + const ringSize = width * 0.95; + const ringThickness = width / 10; + const progressSize = width * 0.2; + + return ( + + + + + 0 ? "blue" : "gray"} + position="top-end" + size="4cqmin" + label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined} + disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0} + > + + + + + + + + + } + > + {t("widget.healthMonitoring.popover.processor")} {healthInfo.cpuModelName} + + } + > + {t("widget.healthMonitoring.popover.memory")} {memoryUsage.memTotal.GB}GiB -{" "} + {t("widget.healthMonitoring.popover.memAvailable")} {memoryUsage.memFree.GB}GiB ( + {memoryUsage.memFree.percent}%) + + } + > + {t("widget.healthMonitoring.popover.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 && ( + + + {`${healthInfo.cpuUtilization.toFixed(2)}%`} + + + } + sections={[ + { + value: Number(healthInfo.cpuUtilization.toFixed(2)), + color: progressColor(Number(healthInfo.cpuUtilization.toFixed(2))), + }, + ]} + /> + + )} + {healthInfo.cpuTemp && options.cpu && ( + + + + {options.fahrenheit + ? `${(healthInfo.cpuTemp * 1.8 + 32).toFixed(1)}°F` + : `${healthInfo.cpuTemp}°C`} + + + + } + sections={[ + { + value: healthInfo.cpuTemp, + color: progressColor(healthInfo.cpuTemp), + }, + ]} + /> + + )} + {options.memory && ( + + + + {memoryUsage.memUsed.GB}GiB + + + + } + sections={[ + { + value: Number(memoryUsage.memUsed.percent), + color: progressColor(Number(memoryUsage.memUsed.percent)), + tooltip: `${memoryUsage.memUsed.percent}%`, + }, + ]} + /> + + )} + + + {options.fileSystem && + disksData.map((disk) => { + return ( + + + + + + {disk.deviceName} + + + + + + {options.fahrenheit + ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F` + : `${disk.temperature}°C`} + + + + + + {disk.overallStatus} + + + + + + + + {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.diskAvailable")} + + + + + + ); + })} + + ); + })} + + ); +} + +export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => { + const days = Math.floor(uptimeInSeconds / (60 * 60 * 24)); + const remainingHours = Math.floor((uptimeInSeconds % (60 * 60 * 24)) / 3600); + return t("widget.healthMonitoring.popover.uptime", { days, hours: remainingHours }); +}; + +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; + 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 ?? "", + }; + }); +}; + +export const formatMemoryUsage = (memFree: string, memUsed: string) => { + const memFreeBytes = Number(memFree); + const memUsedBytes = Number(memUsed); + const totalMemory = memFreeBytes + memUsedBytes; + const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2); + const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2); + const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100); + const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100); + const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2); + + return { + memFree: { percent: memFreePercent, GB: memFreeGB }, + memUsed: { percent: memUsedPercent, GB: memUsedGB }, + memTotal: { GB: memTotalGB }, + }; +}; diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts new file mode 100644 index 000000000..3e7f9c548 --- /dev/null +++ b/packages/widgets/src/health-monitoring/index.ts @@ -0,0 +1,31 @@ +import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", { + icon: IconHeartRateMonitor, + options: optionsBuilder.from((factory) => ({ + fahrenheit: factory.switch({ + defaultValue: false, + }), + cpu: factory.switch({ + defaultValue: true, + }), + memory: factory.switch({ + defaultValue: true, + }), + fileSystem: factory.switch({ + defaultValue: true, + }), + })), + supportedIntegrations: ["openmediavault"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.healthMonitoring.error.internalServerError"), + }, + }, +}) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/health-monitoring/serverData.ts b/packages/widgets/src/health-monitoring/serverData.ts new file mode 100644 index 000000000..3e68f587a --- /dev/null +++ b/packages/widgets/src/health-monitoring/serverData.ts @@ -0,0 +1,27 @@ +"use server"; + +import { api } from "@homarr/api/server"; + +import type { WidgetProps } from "../definition"; + +export default async function getServerDataAsync({ integrationIds }: WidgetProps<"healthMonitoring">) { + if (integrationIds.length === 0) { + return { + initialData: [], + }; + } + + try { + const currentHealthInfo = await api.widget.healthMonitoring.getHealthStatus({ + integrationIds, + }); + + return { + initialData: currentHealthInfo.filter((health) => health !== null), + }; + } catch { + return { + initialData: [], + }; + } +} diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 842f201ba..645ba1df5 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -12,6 +12,7 @@ import type { WidgetComponentProps } from "./definition"; import * as dnsHoleControls from "./dns-hole/controls"; import * as dnsHoleSummary from "./dns-hole/summary"; import * as downloads from "./downloads"; +import * as healthMonitoring from "./health-monitoring"; import * as iframe from "./iframe"; import type { WidgetImportRecord } from "./import"; import * as indexerManager from "./indexer-manager"; @@ -51,6 +52,7 @@ export const widgetImports = { "mediaRequests-requestStats": mediaRequestsStats, rssFeed, indexerManager, + healthMonitoring, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports;