feat: OMV integration & health monitoring widget (#1142)

This commit is contained in:
Yossi Hillali
2024-09-30 14:05:13 +03:00
committed by GitHub
parent 6ce466d38e
commit 0f56dc1ecd
19 changed files with 802 additions and 8 deletions

View File

@@ -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>("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>("healthMonitoring", integration.id);
const unsubscribe = channel.subscribe((healthInfo) => {
emit.next({
integrationId: integration.id,
healthInfo,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -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,
});

View File

@@ -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];

View File

@@ -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);
}
}
});

View File

@@ -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<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
@@ -168,4 +174,5 @@ export type IntegrationCategory =
| "usenet"
| "torrent"
| "smartHomeServer"
| "indexerManager";
| "indexerManager"
| "healthMonitoring";

View File

@@ -16,5 +16,6 @@ export const widgetKinds = [
"mediaRequests-requestStats",
"rssFeed",
"indexerManager",
"healthMonitoring",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -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<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;

View File

@@ -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";

View File

@@ -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;
}[];
}

View File

@@ -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<HealthMonitoring> {
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<void> {
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<string, unknown>,
headers: Record<string, string> = {},
): Promise<Response> {
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<string, string> | 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<string, string> = {};
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;
}
}

View File

@@ -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(),
}),
});

View File

@@ -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";

View File

@@ -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<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
// Use null for widgets that did not exist in oldmarr
// TODO: revert assignment so that only old widgets are needed in the object,

View File

@@ -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,
};

View File

@@ -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",
},

View File

@@ -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 (
<Box h="100%" className="health-monitoring">
{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 (
<Box
key={integrationId}
h="100%"
className={`health-monitoring-information health-monitoring-${integrationName}`}
>
<Card className="health-monitoring-information-card" m="2.5cqmin" p="2.5cqmin" withBorder>
<Flex
className="health-monitoring-information-card-elements"
h="100%"
w="100%"
justify="space-between"
align="center"
key={integrationId}
>
<Box className="health-monitoring-information-card-section">
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
processing
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
position="top-end"
size="4cqmin"
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
<Avatar className="health-monitoring-information-icon-avatar" size="10cqmin" radius="sm">
<IconInfoCircle className="health-monitoring-information-icon" size="8cqmin" onClick={open} />
</Avatar>
</Indicator>
<Modal
opened={opened}
onClose={close}
size="auto"
title={t("widget.healthMonitoring.popover.information")}
centered
>
<Stack gap="10px" className="health-monitoring-modal-stack">
<Divider />
<List className="health-monitoring-information-list" center spacing="0.5cqmin">
<List.Item
className="health-monitoring-information-processor"
icon={<IconCpu2 size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.processor")} {healthInfo.cpuModelName}
</List.Item>
<List.Item
className="health-monitoring-information-memory"
icon={<IconBrain size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.memory")} {memoryUsage.memTotal.GB}GiB -{" "}
{t("widget.healthMonitoring.popover.memAvailable")} {memoryUsage.memFree.GB}GiB (
{memoryUsage.memFree.percent}%)
</List.Item>
<List.Item
className="health-monitoring-information-version"
icon={<IconVersions size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.version")} {healthInfo.version}
</List.Item>
<List.Item
className="health-monitoring-information-uptime"
icon={<IconClock size="1.5cqmin" />}
>
{formatUptime(healthInfo.uptime, t)}
</List.Item>
<List.Item
className="health-monitoring-information-load-average"
icon={<IconCpu size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.loadAverage")}
</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"]}
</List.Item>
<List.Item className="health-monitoring-information-load-average-5min">
{t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "}
{healthInfo.loadAverage["5min"]}
</List.Item>
<List.Item className="health-monitoring-information-load-average-15min">
{t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "}
{healthInfo.loadAverage["15min"]}
</List.Item>
</List>
</List>
</Stack>
</Modal>
</Box>
{options.cpu && (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
<RingProgress
className="health-monitoring-cpu-utilization"
roundCaps
size={ringSize}
thickness={ringThickness}
label={
<Center style={{ flexDirection: "column" }}>
<Text
className="health-monitoring-cpu-utilization-value"
size="3cqmin"
>{`${healthInfo.cpuUtilization.toFixed(2)}%`}</Text>
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: Number(healthInfo.cpuUtilization.toFixed(2)),
color: progressColor(Number(healthInfo.cpuUtilization.toFixed(2))),
},
]}
/>
</Box>
)}
{healthInfo.cpuTemp && options.cpu && (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
<RingProgress
ref={ref}
className="health-monitoring-cpu-temp"
roundCaps
size={ringSize}
thickness={ringThickness}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
{options.fahrenheit
? `${(healthInfo.cpuTemp * 1.8 + 32).toFixed(1)}°F`
: `${healthInfo.cpuTemp}°C`}
</Text>
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: healthInfo.cpuTemp,
color: progressColor(healthInfo.cpuTemp),
},
]}
/>
</Box>
)}
{options.memory && (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
<RingProgress
className="health-monitoring-memory-use"
roundCaps
size={ringSize}
thickness={ringThickness}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-memory-value" size="3cqmin">
{memoryUsage.memUsed.GB}GiB
</Text>
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: Number(memoryUsage.memUsed.percent),
color: progressColor(Number(memoryUsage.memUsed.percent)),
tooltip: `${memoryUsage.memUsed.percent}%`,
},
]}
/>
</Box>
)}
</Flex>
</Card>
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
className="health-monitoring-disk-card"
key={disk.deviceName}
m="2.5cqmin"
p="2.5cqmin"
withBorder
>
<Flex className="health-monitoring-disk-status" justify="space-between" align="center" m="1.5cqmin">
<Group gap="1cqmin">
<IconServer className="health-monitoring-disk-icon" size="5cqmin" />
<Text className="dihealth-monitoring-disk-name" size="4cqmin">
{disk.deviceName}
</Text>
</Group>
<Group gap="1cqmin">
<IconTemperature className="health-monitoring-disk-temperature-icon" size="5cqmin" />
<Text className="health-monitoring-disk-temperature-value" size="4cqmin">
{options.fahrenheit
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
: `${disk.temperature}°C`}
</Text>
</Group>
<Group gap="1cqmin">
<IconFileReport className="health-monitoring-disk-status-icon" size="5cqmin" />
<Text className="health-monitoring-disk-status-value" size="4cqmin">
{disk.overallStatus}
</Text>
</Group>
</Flex>
<Progress.Root className="health-monitoring-disk-use" size={progressSize}>
<Tooltip label={disk.used}>
<Progress.Section
value={disk.percentage}
color={progressColor(disk.percentage)}
className="health-monitoring-disk-use-percentage"
>
<Progress.Label className="health-monitoring-disk-use-value">
{t("widget.healthMonitoring.popover.used")}
</Progress.Label>
</Progress.Section>
</Tooltip>
<Tooltip
label={
Number(disk.available) / 1024 ** 4 >= 1
? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB`
: `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB`
}
>
<Progress.Section
className="health-monitoring-disk-available-percentage"
value={100 - disk.percentage}
color="default"
>
<Progress.Label className="health-monitoring-disk-available-value">
{t("widget.healthMonitoring.popover.diskAvailable")}
</Progress.Label>
</Progress.Section>
</Tooltip>
</Progress.Root>
</Card>
);
})}
</Box>
);
})}
</Box>
);
}
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 },
};
};

View File

@@ -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"));

View File

@@ -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: [],
};
}
}

View File

@@ -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;