feat: OMV integration & health monitoring widget (#1142)
This commit is contained in:
52
packages/api/src/router/widgets/health-monitoring.ts
Normal file
52
packages/api/src/router/widgets/health-monitoring.ts
Normal 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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { appRouter } from "./app";
|
|||||||
import { calendarRouter } from "./calendar";
|
import { calendarRouter } from "./calendar";
|
||||||
import { dnsHoleRouter } from "./dns-hole";
|
import { dnsHoleRouter } from "./dns-hole";
|
||||||
import { downloadsRouter } from "./downloads";
|
import { downloadsRouter } from "./downloads";
|
||||||
|
import { healthMonitoringRouter } from "./health-monitoring";
|
||||||
import { indexerManagerRouter } from "./indexer-manager";
|
import { indexerManagerRouter } from "./indexer-manager";
|
||||||
import { mediaRequestsRouter } from "./media-requests";
|
import { mediaRequestsRouter } from "./media-requests";
|
||||||
import { mediaServerRouter } from "./media-server";
|
import { mediaServerRouter } from "./media-server";
|
||||||
@@ -23,4 +24,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
mediaRequests: mediaRequestsRouter,
|
mediaRequests: mediaRequestsRouter,
|
||||||
rssFeed: rssFeedRouter,
|
rssFeed: rssFeedRouter,
|
||||||
indexerManager: indexerManagerRouter,
|
indexerManager: indexerManagerRouter,
|
||||||
|
healthMonitoring: healthMonitoringRouter,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { analyticsJob } from "./jobs/analytics";
|
|||||||
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||||
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
|
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
|
||||||
import { downloadsJob } from "./jobs/integrations/downloads";
|
import { downloadsJob } from "./jobs/integrations/downloads";
|
||||||
|
import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
|
||||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||||
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
||||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||||
@@ -24,6 +25,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
mediaRequests: mediaRequestsJob,
|
mediaRequests: mediaRequestsJob,
|
||||||
rssFeeds: rssFeedsJob,
|
rssFeeds: rssFeedsJob,
|
||||||
indexerManager: indexerManagerJob,
|
indexerManager: indexerManagerJob,
|
||||||
|
healthMonitoring: healthMonitoringJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -119,6 +119,12 @@ export const integrationDefs = {
|
|||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
|
||||||
category: ["smartHomeServer"],
|
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>;
|
} as const satisfies Record<string, integrationDefinition>;
|
||||||
|
|
||||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||||
@@ -168,4 +174,5 @@ export type IntegrationCategory =
|
|||||||
| "usenet"
|
| "usenet"
|
||||||
| "torrent"
|
| "torrent"
|
||||||
| "smartHomeServer"
|
| "smartHomeServer"
|
||||||
| "indexerManager";
|
| "indexerManager"
|
||||||
|
| "healthMonitoring";
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ export const widgetKinds = [
|
|||||||
"mediaRequests-requestStats",
|
"mediaRequests-requestStats",
|
||||||
"rssFeed",
|
"rssFeed",
|
||||||
"indexerManager",
|
"indexerManager",
|
||||||
|
"healthMonitoring",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
|||||||
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
||||||
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||||
@@ -60,4 +61,5 @@ export const integrationCreators = {
|
|||||||
jellyseerr: JellyseerrIntegration,
|
jellyseerr: JellyseerrIntegration,
|
||||||
overseerr: OverseerrIntegration,
|
overseerr: OverseerrIntegration,
|
||||||
prowlarr: ProwlarrIntegration,
|
prowlarr: ProwlarrIntegration,
|
||||||
|
openmediavault: OpenMediaVaultIntegration,
|
||||||
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;
|
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;
|
||||||
|
|||||||
@@ -6,23 +6,25 @@ export { DownloadClientIntegration } from "./interfaces/downloads/download-clien
|
|||||||
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
|
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
|
||||||
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-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 { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
|
||||||
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
|
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
|
||||||
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
|
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
|
||||||
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
|
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
|
||||||
export { TransmissionIntegration } from "./download-client/transmission/transmission-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
|
// Types
|
||||||
export type { IntegrationInput } from "./base/integration";
|
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
|
||||||
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 { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||||
export type { MediaRequestList, MediaRequestStats } 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 { 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
|
// Schemas
|
||||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./calendar-types";
|
export * from "./calendar-types";
|
||||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-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/indexer-manager/indexer";
|
||||||
export * from "./interfaces/media-requests/media-request";
|
export * from "./interfaces/media-requests/media-request";
|
||||||
export * from "./pi-hole/pi-hole-types";
|
export * from "./pi-hole/pi-hole-types";
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const widgetKindMapping = {
|
|||||||
"mediaRequests-requestList": "media-requests-list",
|
"mediaRequests-requestList": "media-requests-list",
|
||||||
"mediaRequests-requestStats": "media-requests-stats",
|
"mediaRequests-requestStats": "media-requests-stats",
|
||||||
indexerManager: "indexer-manager",
|
indexerManager: "indexer-manager",
|
||||||
|
healthMonitoring: "health-monitoring",
|
||||||
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
||||||
// Use null for widgets that did not exist in oldmarr
|
// Use null for widgets that did not exist in oldmarr
|
||||||
// TODO: revert assignment so that only old widgets are needed in the object,
|
// TODO: revert assignment so that only old widgets are needed in the object,
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ const optionMapping: OptionMapping = {
|
|||||||
indexerManager: {
|
indexerManager: {
|
||||||
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
|
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
|
||||||
},
|
},
|
||||||
|
healthMonitoring: {
|
||||||
|
cpu: (oldOptions) => oldOptions.cpu,
|
||||||
|
memory: (oldOptions) => oldOptions.memory,
|
||||||
|
fahrenheit: (oldOptions) => oldOptions.fahrenheit,
|
||||||
|
fileSystem: (oldOptions) => oldOptions.fileSystem,
|
||||||
|
},
|
||||||
app: null,
|
app: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1069,6 +1069,41 @@ export default {
|
|||||||
internalServerError: "Failed to fetch indexers status",
|
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: {
|
common: {
|
||||||
location: {
|
location: {
|
||||||
query: "City / Postal code",
|
query: "City / Postal code",
|
||||||
@@ -1842,6 +1877,9 @@ export default {
|
|||||||
indexerManager: {
|
indexerManager: {
|
||||||
label: "Indexer Manager",
|
label: "Indexer Manager",
|
||||||
},
|
},
|
||||||
|
healthMonitoring: {
|
||||||
|
label: "Health Monitoring",
|
||||||
|
},
|
||||||
dnsHole: {
|
dnsHole: {
|
||||||
label: "DNS Hole Data",
|
label: "DNS Hole Data",
|
||||||
},
|
},
|
||||||
|
|||||||
359
packages/widgets/src/health-monitoring/component.tsx
Normal file
359
packages/widgets/src/health-monitoring/component.tsx
Normal 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 },
|
||||||
|
};
|
||||||
|
};
|
||||||
31
packages/widgets/src/health-monitoring/index.ts
Normal file
31
packages/widgets/src/health-monitoring/index.ts
Normal 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"));
|
||||||
27
packages/widgets/src/health-monitoring/serverData.ts
Normal file
27
packages/widgets/src/health-monitoring/serverData.ts
Normal 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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type { WidgetComponentProps } from "./definition";
|
|||||||
import * as dnsHoleControls from "./dns-hole/controls";
|
import * as dnsHoleControls from "./dns-hole/controls";
|
||||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||||
import * as downloads from "./downloads";
|
import * as downloads from "./downloads";
|
||||||
|
import * as healthMonitoring from "./health-monitoring";
|
||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
import * as indexerManager from "./indexer-manager";
|
import * as indexerManager from "./indexer-manager";
|
||||||
@@ -51,6 +52,7 @@ export const widgetImports = {
|
|||||||
"mediaRequests-requestStats": mediaRequestsStats,
|
"mediaRequests-requestStats": mediaRequestsStats,
|
||||||
rssFeed,
|
rssFeed,
|
||||||
indexerManager,
|
indexerManager,
|
||||||
|
healthMonitoring,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
Reference in New Issue
Block a user