feat: Initial commit for health monitoring widget with graphs (Work in Progress)

This commit is contained in:
SeDemal authored and Manuel committed 2025-04-10 21:51:00 +02:00
1 parent 94263c445b
commit 3b20c96c35
20 files changed
+665 -670

No files matched your search

@@ -1,42 +1,51 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { HealthMonitoring } from "@homarr/integrations";
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { z } from "zod";
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.query(async ({ ctx }) => {
getHealthStatus: publicProcedure
.input(z.object({ pointCount: z.number().optional(), maxElements: z.number().optional() }))
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "proxmox"))
.query(async ({ input: { pointCount = 1, maxElements = 32 }, ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
ctx.integrations.map(async (integrationWithSecrets) => {
const innerHandler = systemInfoRequestHandler.handler(integrationWithSecrets, { maxElements, pointCount });
const healthInfo = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
integration,
healthInfo,
};
}),
);
}),
subscribeSystemHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
subscribeHealthStatus: publicProcedure
.input(z.object({ maxElements: z.number().optional() }))
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "proxmox"))
.subscription(({ ctx, input: { maxElements = 32 } }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"healthMonitoring"> }>;
healthInfo: { data: HealthMonitoring; timestamp: Date };
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
for (const integrationWithSecrets of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integrationWithSecrets, { maxElements, pointCount: 1 });
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const unsubscribe = innerHandler.subscribe((data) => {
emit.next({
integrationId: integration.id,
healthInfo,
timestamp: new Date(),
integration,
healthInfo: { data, timestamp: new Date() },
});
});
unsubscribes.push(unsubscribe);
@@ -48,26 +57,4 @@ export const healthMonitoringRouter = createTRPCRouter({
};
});
}),
getClusterHealthStatus: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "proxmox"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "proxmox"))
.subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next(healthInfo);
});
unsubscribes.push(unsubscribe);
return () => {
unsubscribe();
};
});
}),
});
@@ -1,23 +1,14 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind !== "proxmox") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: (options) => ({ maxElements: Number(options.pointDensity), pointCount: 1 }),
},
{
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: () => ({}),
},
},
),
}),
);
+3 -2
View File
@@ -220,7 +220,8 @@ export type IntegrationCategory =
| "miscellaneous"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring"
| "search"
| "mediaTranscoding"
| "networkController";
| "networkController"
| "healthMonitoring"
| "tempNone";
@@ -12,6 +12,7 @@ import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorre
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { DashDotIntegration } from "../health-monitoring/dashdot/dashdot-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
@@ -1,149 +0,0 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
import { Integration } from "../base/integration";
import type { HealthMonitoring } from "../types";
export class DashDotIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/info"));
await response.json();
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync();
const storageLoad = await this.getCurrentStorageLoadAsync();
const channel = this.getChannel();
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
return {
cpuUtilization: cpuLoad.sumLoad,
memUsed: `${memoryLoad.loadInBytes}`,
memAvailable: `${info.maxAvailableMemoryBytes - memoryLoad.loadInBytes}`,
fileSystem: info.storage.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: humanFileSize(storageLoad[index]!),
available: `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})),
cpuModelName: info.cpuModel === "" ? `Unknown Model (${info.cpuBrand})` : `${info.cpuModel} (${info.cpuBrand})`,
cpuTemp: cpuLoad.averageTemperature,
availablePkgUpdates: 0,
rebootRequired: false,
smart: [],
uptime: info.uptime,
version: `${info.operatingSystemVersion}`,
loadAverage: {
"1min": Math.round(this.getAverageOfCpu(history[0])),
"5min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 4))),
"15min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 14))),
},
};
}
private async getInfoAsync() {
const infoResponse = await fetchWithTrustedCertificatesAsync(this.url("/info"));
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
return {
maxAvailableMemoryBytes: serverInfo.ram.size,
storage: serverInfo.storage,
cpuBrand: serverInfo.cpu.brand,
cpuModel: serverInfo.cpu.model,
operatingSystemVersion: `${serverInfo.os.distro} ${serverInfo.os.release} (${serverInfo.os.kernel})`,
uptime: serverInfo.os.uptime,
};
}
private async getCurrentCpuLoadAsync() {
const channel = this.getChannel();
const cpu = await fetchWithTrustedCertificatesAsync(this.url("/load/cpu"));
const data = await cpuLoadPerCoreApiList.parseAsync(await cpu.json());
await channel.pushAsync(data);
return {
sumLoad: this.getAverageOfCpu(data),
averageTemperature: data.reduce((acc, current) => acc + current.temp, 0) / data.length,
};
}
private getAverageOfCpuFlat(cpuLoad: z.infer<typeof cpuLoadPerCoreApiList>[]) {
const averages = cpuLoad.map((load) => this.getAverageOfCpu(load));
return averages.reduce((acc, current) => acc + current, 0) / averages.length;
}
private getAverageOfCpu(cpuLoad?: z.infer<typeof cpuLoadPerCoreApiList>) {
if (!cpuLoad) {
return 0;
}
return cpuLoad.reduce((acc, current) => acc + current.load, 0) / cpuLoad.length;
}
private async getCurrentStorageLoadAsync() {
const storageLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/storage"));
return (await storageLoad.json()) as number[];
}
private async getCurrentMemoryLoadAsync() {
const memoryLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/ram"));
const data = await memoryLoadApi.parseAsync(await memoryLoad.json());
return {
loadInBytes: data.load,
};
}
private getChannel() {
return createChannelEventHistory<z.infer<typeof cpuLoadPerCoreApiList>>(
`integration:${this.integration.id}:history:cpu`,
100,
);
}
}
const cpuLoadPerCoreApi = z.object({
load: z.number().min(0),
temp: z.number().min(0),
});
const memoryLoadApi = z.object({
load: z.number().min(0),
});
const internalServerInfoApi = z.object({
os: z.object({
distro: z.string(),
kernel: z.string(),
release: z.string(),
uptime: z.number().min(0),
}),
cpu: z.object({
brand: z.string(),
model: z.string(),
}),
ram: z.object({
size: z.number().min(0),
}),
storage: z.array(
z.object({
size: z.number().min(0),
disks: z.array(
z.object({
device: z.string(),
brand: z.string(),
type: z.string(),
}),
),
}),
),
});
const cpuLoadPerCoreApiList = z.array(cpuLoadPerCoreApi);
@@ -0,0 +1,80 @@
import type { z, ZodType } from "zod";
import type { HealthMonitoring } from "../../interfaces/health-monitoring/healt-monitoring-data";
import { HealthMonitoringIntegration } from "../../interfaces/health-monitoring/health-monitoring-interface";
import {
configSchema,
cpuLoadSchema,
memoryLoadSchema,
networkLoadSchema,
serverInfoSchema,
storageLoadSchema,
} from "./dashdot-schema";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
export class DashDotIntegration extends HealthMonitoringIntegration {
public async testConnectionAsync(): Promise<void> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/info"));
await response.json();
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
const { config } = await this.dashDotApiCallAsync("/config", configSchema);
const info = await this.dashDotApiCallAsync("/info", serverInfoSchema);
const cpuLoad = await this.dashDotApiCallAsync("/load/cpu", cpuLoadSchema);
const memoryLoad = await this.dashDotApiCallAsync("/load/ram", memoryLoadSchema);
const storageLoad = await this.dashDotApiCallAsync("/load/storage", storageLoadSchema);
const networkLoad = await this.dashDotApiCallAsync("/load/network", networkLoadSchema);
return {
system: {
name: config.override.os ?? info.os.distro,
type: "single",
version: info.os.release,
uptime: info.os.uptime * 1000,
},
cpu: cpuLoad.map((cpu) => {
return {
id: `${cpu.core}`,
name: `${cpu.core}`,
temp: cpu.temp ? (config.use_imperial ? (cpu.temp - 32) / 1.8 : cpu.temp) : undefined,
maxValue: 100,
};
}),
memory: [
{
id: "unique",
name: `${config.override.ram_brand ?? info.ram.layout[0].brand} - ${config.override.ram_type ?? info.ram.layout[0].type}`,
maxValue: config.override.ram_size ?? info.ram.size,
},
],
storage: info.storage.map((storage, index) => {
return {
name: storage.disks.map((disk) => config.override.storage_brands?.[disk.brand] ?? disk.brand).join(", "),
used: storageLoad[index] ?? -1,
size: storage.size,
};
}),
network: [
{
id: "unique",
name: config.use_network_interface,
maxValue: ((config.override.network_interface_speed ?? info.network.interfaceSpeed) * 1000 ** 3) / 8,
},
],
history: [
{
timestamp: Date.now(),
cpu: cpuLoad.map(({ core, load }) => ({ id: `${core}`, value: load })),
memory: [{ id: "unique", value: memoryLoad.load }],
networkUp: [{ id: "unique", value: Math.round(networkLoad.up) }],
networkDown: [{ id: "unique", value: Math.round(networkLoad.down) }],
},
],
};
}
private async dashDotApiCallAsync<T extends ZodType>(path: `/${string}`, schema: T): Promise<z.infer<T>> {
const response = await fetchWithTrustedCertificatesAsync(this.url(path));
return await schema.parseAsync(await response.json()) as Promise<z.infer<T>>;
}
}
@@ -0,0 +1,84 @@
import { z } from "zod";
export const cpuLoadSchema = z.array(
z.object({
load: z.number().min(0),
temp: z.number().min(0).optional(),
core: z.number(),
}),
);
export const memoryLoadSchema = z.object({
load: z.number().min(0),
});
export const storageLoadSchema = z.array(z.number().min(0));
export const networkLoadSchema = z.object({
up: z.number().min(0),
down: z.number().min(0),
});
export const serverInfoSchema = z.object({
os: z.object({
distro: z.string(),
kernel: z.string(),
release: z.string(),
uptime: z.number().min(0),
}),
cpu: z.object({
brand: z.string(),
model: z.string(),
}),
ram: z.object({
size: z.number().min(0),
layout: z.array(z.object({ brand: z.string(), type: z.string() })).nonempty(),
}),
storage: z.array(
z.object({
size: z.number().min(0),
disks: z.array(
z.object({
device: z.string(),
brand: z.string(),
type: z.string(),
}),
),
}),
),
network: z.object({
interfaceSpeed: z
.number()
.min(0)
.transform((speed) => speed / 1000),
type: z.enum(["Wireless", "Bridge", "Bond", "TAP", "Wired"]),
}),
});
export const configSchema = z.object({
config: z.object({
use_network_interface: z.string().default("UNKNOWN"),
network_speed_as_bytes: z.boolean(),
fs_virtual_mounts: z.array(z.string()),
use_imperial: z.boolean(),
override: z
.object({
os: z.string(),
cpu_brand: z.string(),
cpu_model: z.string(),
cpu_frequency: z.number().min(0),
ram_brand: z.string(),
ram_size: z.number().min(0),
ram_type: z.string(),
ram_frequency: z.number().min(0),
storage_brands: z.record(z.string(), z.string()),
storage_sizes: z.record(z.string(), z.number().min(0)),
storage_types: z.record(z.string(), z.string()),
network_interface_speed: z
.number()
.min(0)
.transform((speed) => speed / 1000),
})
.partial(),
}),
});
+3 -1
View File
@@ -21,13 +21,15 @@ export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
export { DashDotIntegration } from "./health-monitoring/dashdot/dashdot-integration";
export { HealthMonitoringIntegration } from "./interfaces/health-monitoring/health-monitoring-interface";
// Types
export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring-data";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
export type { StreamSession } from "./interfaces/media-server/session";
@@ -0,0 +1,52 @@
import type { AtLeastOneOf } from "@homarr/common/types";
// Array in this interface are for multiple of the component, while following internal arrays are for historical data
export interface HealthMonitoring {
system: SystemInfos; //General system information
cpu: (CpuExtraStats & StatsBase)[]; //Per core or Per node model
memory: StatsBase[]; //Per system/node model
network: StatsBase[]; //Per network interface, to be revisited if needed
storage: StorageStats[]; //Per disk, not influenced by cluster
history: AtLeastOneOf<HistoricalData>;
}
interface StatsBase {
id: string;
name: string;
maxValue: number;
}
interface HistoricalData {
timestamp: number;
cpu: HistoryElement[];
memory: HistoryElement[];
networkUp: HistoryElement[];
networkDown: HistoryElement[];
}
interface HistoryElement {
id: string;
value: number;
}
interface SystemInfos {
name: string;
type: "single" | "cluster";
version: string;
uptime: number;
rebootRequired?: boolean;
updateAvailable?: boolean;
}
interface CpuExtraStats {
status?: unknown;
temp?: number;
}
interface StorageStats {
name: string;
used: number;
size: number;
temp?: number;
smartStatus?: string;
}
@@ -1,27 +0,0 @@
export interface HealthMonitoring {
version: string;
cpuModelName: string;
cpuUtilization: number;
memUsed: string;
memAvailable: string;
uptime: number;
loadAverage: {
"1min": number;
"5min": number;
"15min": number;
};
rebootRequired: boolean;
availablePkgUpdates: number;
cpuTemp: number | undefined;
fileSystem: {
deviceName: string;
used: string;
available: string;
percentage: number;
}[];
smart: {
deviceName: string;
temperature: number | null;
overallStatus: string;
}[];
}
@@ -0,0 +1,6 @@
import { Integration } from "../../base/integration";
import type { HealthMonitoring } from "./healt-monitoring-data";
export abstract class HealthMonitoringIntegration extends Integration {
public abstract getSystemInfoAsync(): Promise<HealthMonitoring>;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export * from "./calendar-types";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/health-monitoring/healt-monitoring";
export * from "./interfaces/health-monitoring/healt-monitoring-data";
export * from "./interfaces/indexer-manager/indexer";
export * from "./interfaces/media-requests/media-request";
export * from "./base/searchable-integration";
+1
View File
@@ -7,6 +7,7 @@ export {
createIntegrationOptionsChannel,
createWidgetOptionsChannel,
createChannelWithLatestAndEvents,
createIntegrationHistoryChannel,
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
+19 -37
View File
@@ -227,59 +227,41 @@ export const createItemChannel = <TData>(itemId: string) => {
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
};
export const createChannelEventHistory = <TData>(channelName: string, maxElements = 15) => {
const popElementsOverMaxAsync = async () => {
const length = await getSetClient.llen(channelName);
if (length <= maxElements) {
return;
}
await getSetClient.ltrim(channelName, length - maxElements, length);
};
export const createIntegrationHistoryChannel = <TData>(integrationId: string, queryKey: string, maxElements = 32) => {
const channelName = `integration:${integrationId}:history:${queryKey}`;
return createChannelEventHistory<TData>(channelName, maxElements);
};
export const createChannelEventHistory = <TData>(channelName: string, maxElements = 32) => {
return {
subscribe: (callback: (data: TData) => void) => {
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
callback(superjson.parse(message));
});
},
publishAndPushAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data));
pushAsync: async (data: TData, options = { publish: false }) => {
if (options.publish) await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
},
pushAsync: async (data: TData) => {
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
await getSetClient.ltrim(channelName, 0, maxElements);
},
clearAsync: async () => {
await getSetClient.del(channelName);
},
getLastAsync: async () => {
const length = await getSetClient.llen(channelName);
const data = await getSetClient.lrange(channelName, length - 1, length);
if (data.length !== 1) return null;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!);
},
getSliceAsync: async (startIndex: number, endIndex: number) => {
/**
* Returns a slice of the available data in the channel.
* If any of the indexes are out of range (or -range), returned data will be clamped.
* @param startIndex Start index of the slice, negative values are counted from the end, defaults at beginning of range.
* @param endIndex End index of the slice, negative values are counted from the end, defaults at end of range.
*/
getSliceAsync: async (startIndex = 0, endIndex = -1) => {
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
},
getSliceUntilTimeAsync: async (time: Date) => {
const length = await getSetClient.llen(channelName);
const items: TData[] = [];
const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1);
for (let i = 0; i < length - 1; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const deserializedItem = superjson.parse<{ data: TData; timestamp: Date }>(itemsInCollection[i]!);
if (deserializedItem.timestamp < time) {
continue;
}
items.push(deserializedItem.data);
}
return items;
const itemsInCollection = await getSetClient.lrange(channelName, 0, -1);
return itemsInCollection
.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item))
.filter((item) => item.timestamp < time);
},
getLengthAsync: async () => {
return await getSetClient.llen(channelName);
@@ -2,32 +2,30 @@ import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types";
import type { HealthMonitoring } from "@homarr/integrations/types";
import { createIntegrationHistoryChannel } from "@homarr/redis";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
HealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox">,
Record<string, never>
IntegrationKindByCategory<"healthMonitoring">,
{ pointCount: number; maxElements: number }
>({
async requestAsync(integration, _input) {
async requestAsync(integration, { pointCount, maxElements }) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSystemInfoAsync();
const data = await integrationInstance.getSystemInfoAsync();
const historyHandler = createIntegrationHistoryChannel<HealthMonitoring["history"][number]>(
integration.id,
"healthMonitoring",
maxElements,
);
await historyHandler.pushAsync(data.history[0]);
if (pointCount === 1) return data;
const dbHistory = (await historyHandler.getSliceAsync(-pointCount, -1)).map(({ data }) => data);
const history = (dbHistory.length > 0 ? dbHistory : data.history) as HealthMonitoring["history"];
return { ...data, history } as HealthMonitoring;
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "systemInfo",
});
export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
ProxmoxClusterInfo,
"proxmox",
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClusterInfoAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "clusterInfo",
});
@@ -1,53 +1,362 @@
"use client";
import { ScrollArea, Tabs } from "@mantine/core";
import { AreaChart, getFilteredChartTooltipPayload } from "@mantine/charts";
import type { DefaultMantineColor } from "@mantine/core";
import { Box, Group, Progress, Stack, Tabs, Text } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import type { HealthMonitoring } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { SystemHealthMonitoring } from "./system-health";
import "@mantine/charts/styles.css";
import type { AtLeastOneOf } from "@homarr/common/types";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
dayjs.extend(duration);
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds);
const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery(
{
integrationIds: props.integrationIds,
maxElements: Number(props.options.pointDensity),
pointCount: Number(props.options.pointDensity),
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.healthMonitoring.subscribeHealthStatus.useSubscription(
{
integrationIds: props.integrationIds,
maxElements: Number(props.options.pointDensity),
},
{
onData(data) {
utils.widget.healthMonitoring.getHealthStatus.setData(
{
integrationIds: props.integrationIds,
maxElements: Number(props.options.pointDensity),
pointCount: Number(props.options.pointDensity),
},
(prevData) => {
if (!prevData) {
return undefined;
}
const newData = prevData.map((item) =>
item.integration.id === data.integration.id
? {
integration: item.integration,
healthInfo: {
timestamp: data.healthInfo.timestamp,
data: {
...data.healthInfo.data,
history: [...item.healthInfo.data.history, ...data.healthInfo.data.history].slice(
-Number(props.options.pointDensity),
) as AtLeastOneOf<HealthMonitoring["history"][number]>,
},
},
}
: item,
);
return newData;
},
);
},
},
);
const t = useI18n();
const proxmoxIntegrationId = integrations.find((integration) => integration.kind === "proxmox")?.id;
if (!proxmoxIntegrationId) {
return <SystemHealthMonitoring {...props} />;
}
const otherIntegrationIds = integrations
.filter((integration) => integration.kind !== "proxmox")
.map((integration) => integration.id);
if (otherIntegrationIds.length === 0) {
return <ClusterHealthMonitoring {...props} integrationId={proxmoxIntegrationId} />;
if (props.integrationIds.length === 0) {
throw new NoIntegrationSelectedError();
}
return (
<ScrollArea h="100%">
<Tabs defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system" fz="xs">
<b>{t("widget.healthMonitoring.tab.system")}</b>
<Tabs
defaultValue={props.integrationIds[0]}
h="100%"
display="flex"
dir="vertical"
style={{ flexDirection: "column" }}
>
<Tabs.List flex={0} display={healthData.length > 1 ? "flex" : "none"}>
{healthData.map(({ integration }) => (
<Tabs.Tab value={integration.id} key={integration.id}>
{integration.name}
</Tabs.Tab>
<Tabs.Tab value="cluster" fz="xs">
<b>{t("widget.healthMonitoring.tab.cluster")}</b>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="system">
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">
<ClusterHealthMonitoring integrationId={proxmoxIntegrationId} {...props} />
</Tabs.Panel>
</Tabs>
</ScrollArea>
))}
</Tabs.List>
{healthData.map(({ integration, healthInfo: { data } }) => {
return (
<Tabs.Panel
value={integration.id}
key={integration.id}
flex="1 1 auto"
display="flex"
style={{ flexDirection: "column" }}
h="100%"
>
{props.options.systemInfo && (
<Box p="sm">
<Stack gap={2} align="center">
<Text fz="md" fw="bold">{data.system.name}</Text>
<Text fz="sm">{formatUptime(data.system.uptime, t)}</Text>
</Stack>
</Box>
)}
{props.options.cpu && (
<GridAreaChart columns={Number(props.options.cpuColumns)} data={data} series={["cpu"]} />
)}
{props.options.memory && data.system.type === "single" && (
<GridAreaChart columns={1} data={data} series={["memory"]} />
)}
{props.options.network && <GridAreaChart columns={1} data={data} series={["networkUp", "networkDown"]} />}
{props.options.fileSystem && (
<StorageEntries columns={1} fahrenheit={props.options.fahrenheit} storages={data.storage} />
)}
</Tabs.Panel>
);
})}
</Tabs>
);
}
type HistoryKeys = keyof Omit<HealthMonitoring["history"][number], "timestamp">;
const historyFormatTable = {
cpu: { key: "cpu", color: "blue", format: "percent", negative: false },
memory: { key: "memory", color: "orange", format: "humanFileSize", negative: false },
networkUp: { key: "network", color: "green", format: "humanFileSize", negative: false },
networkDown: { key: "network", color: "red", format: "humanFileSize", negative: true },
} satisfies Record<
HistoryKeys,
{
key: keyof Pick<HealthMonitoring, "cpu" | "memory" | "network">;
color: DefaultMantineColor;
format: "percent" | "humanFileSize";
negative: boolean;
}
>;
const dataToGraph = (data: HealthMonitoring, series: AtLeastOneOf<HistoryKeys>) => {
const referenceArray = data.history[0][series[0]];
// Check that all selected series have the same length
if (series.some((name) => data.history[0][name].length !== data.history[0][series[0]].length)) {
return null;
}
return referenceArray.map(({ id }) => ({
id,
negative: series.some((key) => historyFormatTable[key].negative),
graph: data.history.map((item) => ({
date: item.timestamp,
name: data[historyFormatTable[series[0]].key].find((element) => element.id === id)?.name ?? "",
...series.reduce(
(acc, key) => {
const value = item[key].find((element) => element.id === id)?.value ?? null;
const scale = data[historyFormatTable[key].key].find((element) => element.id === id)?.maxValue ?? 100;
acc[`original.${key}`] = value;
acc[`max.${key}`] = scale;
acc[`format.${key}`] = historyFormatTable[key].format;
acc[key] = value ? value / (scale / (historyFormatTable[key].negative ? -100 : 100)) : null;
return acc;
},
{} as Partial<Record<HistoryKeys | `${string}.${HistoryKeys}`, number | string | null>>,
),
})),
}));
};
interface GridAreaChartProps {
data: HealthMonitoring;
series: AtLeastOneOf<HistoryKeys>;
columns?: number;
}
const GridAreaChart = ({ data, series, columns = 1 }: GridAreaChartProps) => {
const formattedData = dataToGraph(data, series);
const formattedSeries = series.map((name) => ({
name,
color: historyFormatTable[name].color,
}));
if (!formattedData || formattedData.length === 0) return null;
return (
<Box
display="grid"
flex="1 1 auto"
w="100%"
style={{
gap: 7,
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: "auto-fit",
height: "50px",
}}
>
{formattedData.map(({ id, negative, graph }) => {
return (
<AreaChart
data={graph}
key={id}
h="100%"
w="100%"
bg="var(--background-color)"
p={0}
yAxisProps={{ domain: [negative ? -100 : 0, 100] }}
dataKey="date"
series={formattedSeries}
gridAxis="none"
dotProps={{ r: 0 }}
activeDotProps={{ r: 3 }}
withYAxis={false}
withXAxis={false}
flex="1 1 auto"
style={{ borderRadius: "3cqmin", border: "1px red solid" }}
tooltipProps={{
content: ({ payload }) => <CustomTooltip payload={payload as Record<string, unknown>[] | undefined} />,
}}
/>
)
})}
</Box>
);
};
interface CustomTooltipProps {
payload: Record<string, unknown>[] | undefined;
}
const CustomTooltip = ({ payload }: CustomTooltipProps) => {
if (!payload) return;
// TODO: check why this is needed?
// const filteredData = getFilteredChartTooltipPayload(payload);
const filteredData = payload;
return (
<Box bg="var(--background-color)" p="md" style={{ borderRadius: "3cqmin" }}>
<Text>{((filteredData[0]?.payload ?? { name: "" }) as { name: string }).name}</Text>
{filteredData.map(({ dataKey, payload: subPayload }) => {
const values = subPayload as Record<string, number> & Record<`format.${string}`, string>;
const displayValue =
historyFormatTable[dataKey as keyof typeof historyFormatTable].format === "humanFileSize"
? `${humanFileSize(values[`original.${dataKey}`] ?? 0)}/${humanFileSize(values[`max.${dataKey}`] ?? 0)}`
: `${values[dataKey as string]?.toFixed()}%`;
return (
<Text key={dataKey as string} fz="md">
{`${dataKey}: ${displayValue}`}
</Text>
);
})}
</Box>
);
};
interface StorageEntriesProps {
columns: number;
fahrenheit: boolean;
storages: HealthMonitoring["storage"];
}
const StorageEntries = ({ columns, fahrenheit, storages }: StorageEntriesProps) => {
return (
<Box
display="grid"
p="xs"
w="100%"
style={{
gap: "0.5cqmin",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
}}
>
{storages.map((storage) => {
const percentage = (storage.used / storage.size) * 100;
return (
<Stack
key={storage.name}
bg="var(--background-color)"
gap="xs"
w="100%"
flex="1 1 auto"
style={{ borderRadius: "3cqmin" }}
>
<Group w="100%" justify="space-between" gap="1cqmin">
<Text fz="md">{storage.name}</Text>
{storage.temp && (
<Group>
<Text fz="md">{temperatureFormatter(storage.temp, fahrenheit)}</Text>
</Group>
)}
<Text fz="md">{`${humanFileSize(storage.used)}/${humanFileSize(storage.size)}`}</Text>
</Group>
<Progress value={percentage} color={progressColor(percentage)} />
</Stack>
);
})}
</Box>
);
};
const temperatureFormatter = (temperature: number, fahrenheit: boolean) =>
fahrenheit ? `${temperature * (9 / 5) + 32}°F` : `${temperature}°C`;
export const progressColor = (percentage: number) => {
if (percentage < 40) return "green";
else if (percentage < 60) return "yellow";
else if (percentage < 90) return "orange";
else return "red";
};
export const formatUptime = (time: number, t: TranslationFunction) => {
const timeDiff = dayjs.duration(time, "milliseconds");
return t("widget.healthMonitoring.popover.uptime", {
months: `${Math.floor(timeDiff.as("months"))}`,
days: `${Math.floor(timeDiff.as("days"))}`,
//replace with timeDiff.hours() and timeDiff.minutes() once it's fixed
hours: `${Math.floor(timeDiff.as("hours") % 24)}`,
minutes: `${Math.floor(timeDiff.as("minutes") % 60)}`,
});
};
interface FileSystem {
deviceName: string;
used: string;
available: string;
percentage: number;
}
interface SmartData {
deviceName: string;
temperature: number;
overallStatus: string;
}
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems
.map((fileSystem) => {
const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
used: fileSystem.used,
available: fileSystem.available,
percentage: fileSystem.percentage,
temperature: smartDisk?.temperature ?? 0,
overallStatus: smartDisk?.overallStatus ?? "",
};
})
.sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
};
+20 -14
View File
@@ -1,6 +1,7 @@
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { z } from "zod";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -12,30 +13,35 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
fahrenheit: factory.switch({
defaultValue: false,
}),
systemInfo: factory.switch({
defaultValue: true,
}),
cpu: factory.switch({
defaultValue: true,
}),
cpuDetailed: factory.switch({
defaultValue: true,
}),
cpuColumns: factory.number({
defaultValue: 2,
step: 1,
validate: z.number().min(1).max(4),
}),
memory: factory.switch({
defaultValue: true,
}),
network: factory.switch({
defaultValue: true,
}),
fileSystem: factory.switch({
defaultValue: true,
}),
defaultTab: factory.select({
defaultValue: "system",
options: [
{ value: "system", label: "System" },
{ value: "cluster", label: "Cluster" },
] as const,
pointDensity: factory.number({
defaultValue: 12,
step: 1,
validate: z.number().min(1).max(60),
}),
sectionIndicatorRequirement: factory.select({
defaultValue: "all",
options: [
{ value: "all", label: "All active" },
{ value: "any", label: "Any active" },
] as const,
}),
}));
}))
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
errors: {
@@ -1,7 +0,0 @@
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
@@ -1,322 +0,0 @@
"use client";
import {
ActionIcon,
Box,
Card,
Divider,
Flex,
Group,
Indicator,
List,
Modal,
Progress,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconBrain,
IconClock,
IconCpu,
IconCpu2,
IconFileReport,
IconInfoCircle,
IconServer,
IconTemperature,
IconVersions,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { formatMemoryUsage, MemoryRing } from "./rings/memory-ring";
import classes from "./system-health.module.css";
dayjs.extend(duration);
export const SystemHealthMonitoring = ({
options,
integrationIds,
width,
}: WidgetComponentProps<"healthMonitoring">) => {
const t = useI18n();
const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
const board = useRequiredBoard();
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
{ integrationIds },
{
onData(data) {
utils.widget.healthMonitoring.getSystemHealthStatus.setData({ integrationIds }, (prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integrationId === data.integrationId
? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp }
: item,
);
});
},
},
);
const isTiny = width < 256;
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
return (
<Stack
gap="sm"
key={integrationId}
h="100%"
className={`health-monitoring-information health-monitoring-${integrationName}`}
p="sm"
pos="relative"
>
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
processing
styles={{ indicator: { pointerEvents: "none" } }}
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
position="top-end"
size={16}
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
<ActionIcon
className="health-monitoring-information-icon-avatar"
variant={"light"}
color="var(--mantine-color-text)"
size="sm"
radius={board.itemRadius}
>
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
</ActionIcon>
</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="xs">
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={30} />}>
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
{t("widget.healthMonitoring.popover.memoryAvailable", {
memoryAvailable: memoryUsage.memFree.GB,
percent: String(memoryUsage.memFree.percent),
})}
</List.Item>
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={30} />}>
{t("widget.healthMonitoring.popover.version", {
version: healthInfo.version,
})}
</List.Item>
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
{formatUptime(healthInfo.uptime, t)}
</List.Item>
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
{t("widget.healthMonitoring.popover.loadAverage")}
</List.Item>
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
<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>
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{options.cpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{options.memory && (
<MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} isTiny={isTiny} />
)}
</Flex>
{
<Text className="health-monitoring-status-update-time" c="dimmed" size="xs" ta="center">
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
</Text>
}
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
className={combineClasses(
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
classes.card,
)}
style={{ overflow: "visible" }}
key={disk.deviceName}
radius={board.itemRadius}
p="xs"
>
<Stack gap="sm">
<Group
className="health-monitoring-disk-status"
justify="space-between"
align="center"
wrap="wrap"
gap={8}
>
<Group gap={4} wrap="nowrap">
<IconServer className="health-monitoring-disk-icon" size="1rem" />
<Text className="dihealth-monitoring-disk-name" size="xs">
{disk.deviceName}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
<Text className="health-monitoring-disk-temperature-value" size="xs">
{options.fahrenheit
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
: `${disk.temperature}°C`}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
<Text className="health-monitoring-disk-status-value" size="xs">
{disk.overallStatus ? disk.overallStatus : "N/A"}
</Text>
</Group>
</Group>
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} h="md">
<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" fz="xs">
{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" fz="xs">
{t("widget.healthMonitoring.popover.available")}
</Progress.Label>
</Progress.Section>
</Tooltip>
</Progress.Root>
</Stack>
</Card>
);
})}
</Stack>
);
})}
</Stack>
);
};
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const months = uptimeDuration.months();
const days = uptimeDuration.days();
const hours = uptimeDuration.hours();
const minutes = uptimeDuration.minutes();
return t("widget.healthMonitoring.popover.uptime", {
months: String(months),
days: String(days),
hours: String(hours),
minutes: String(minutes),
});
};
export const progressColor = (percentage: number) => {
if (percentage < 40) return "green";
else if (percentage < 60) return "yellow";
else if (percentage < 90) return "orange";
else return "red";
};
interface FileSystem {
deviceName: string;
used: string;
available: string;
percentage: number;
}
interface SmartData {
deviceName: string;
temperature: number | null;
overallStatus: string;
}
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems
.map((fileSystem) => {
const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
used: fileSystem.used,
available: fileSystem.available,
percentage: fileSystem.percentage,
temperature: smartDisk?.temperature ?? 0,
overallStatus: smartDisk?.overallStatus ?? "",
};
})
.sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
};