feat: Initial commit for health monitoring widget with graphs (Work in Progress)
This commit is contained in:
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: () => ({}),
|
||||
},
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
Whitespace-only changes.
@@ -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,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";
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
createIntegrationOptionsChannel,
|
||||
createWidgetOptionsChannel,
|
||||
createChannelWithLatestAndEvents,
|
||||
createIntegrationHistoryChannel,
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
createGetSetChannel,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
Reference in new issue
Block a user