feat(widget): add proxmox integration (#1969)
* feat(widget): add proxmox integration * fix: broken lock file * fix: ci issues * fix: ci issues * fix: ci issues * chore: debug temporary * fix: name is not used correctly for nodes and storage in proxmox * fix: remove temporary debu logs * fix: job runs for both cluster and system health and throws error * fix: ts-expect-error is unnecessary * fix: remove unused import
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
|
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
@@ -7,4 +7,6 @@ export const integrationSecretIcons = {
|
|||||||
username: IconUser,
|
username: IconUser,
|
||||||
apiKey: IconKey,
|
apiKey: IconKey,
|
||||||
password: IconPassword,
|
password: IconPassword,
|
||||||
|
realm: IconServer,
|
||||||
|
tokenId: IconGrid3x3,
|
||||||
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"allowNonAppliedPatches": true,
|
"allowNonAppliedPatches": true,
|
||||||
|
"overrides": {
|
||||||
|
"proxmox-api>undici": "7.2.3"
|
||||||
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
|
||||||
|
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||||
|
return await ctx.db.query.integrations.findMany({
|
||||||
|
where: inArray(integrations.id, input),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
kind: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
|
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||||
const integration = await ctx.db.query.integrations.findFirst({
|
const integration = await ctx.db.query.integrations.findFirst({
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
|
||||||
import type { HealthMonitoring } from "@homarr/integrations";
|
import type { HealthMonitoring } from "@homarr/integrations";
|
||||||
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
|
||||||
|
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const healthMonitoringRouter = createTRPCRouter({
|
export const healthMonitoringRouter = createTRPCRouter({
|
||||||
getHealthStatus: publicProcedure
|
getSystemHealthStatus: publicProcedure
|
||||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
|
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
@@ -25,9 +25,8 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
subscribeSystemHealthStatus: publicProcedure
|
||||||
subscribeHealthStatus: publicProcedure
|
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
|
||||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
|
|
||||||
.subscription(({ ctx }) => {
|
.subscription(({ ctx }) => {
|
||||||
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
@@ -49,4 +48,26 @@ 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,14 +1,23 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
|
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
|
||||||
createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
|
createRequestIntegrationJobHandler(
|
||||||
widgetKinds: ["healthMonitoring"],
|
(integration, itemOptions: Record<string, never>) => {
|
||||||
getInput: {
|
const { kind } = integration;
|
||||||
healthMonitoring: () => ({}),
|
if (kind !== "proxmox") {
|
||||||
|
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
||||||
|
}
|
||||||
|
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
||||||
},
|
},
|
||||||
}),
|
{
|
||||||
|
widgetKinds: ["healthMonitoring"],
|
||||||
|
getInput: {
|
||||||
|
healthMonitoring: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const integrationSecretKindObject = {
|
|||||||
apiKey: { isPublic: false },
|
apiKey: { isPublic: false },
|
||||||
username: { isPublic: true },
|
username: { isPublic: true },
|
||||||
password: { isPublic: false },
|
password: { isPublic: false },
|
||||||
|
tokenId: { isPublic: true },
|
||||||
|
realm: { isPublic: true },
|
||||||
} satisfies Record<string, { isPublic: boolean }>;
|
} satisfies Record<string, { isPublic: boolean }>;
|
||||||
|
|
||||||
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
||||||
@@ -137,6 +139,12 @@ export const integrationDefs = {
|
|||||||
category: ["mediaTranscoding"],
|
category: ["mediaTranscoding"],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
|
||||||
},
|
},
|
||||||
|
proxmox: {
|
||||||
|
name: "Proxmox",
|
||||||
|
secretKinds: [["username", "tokenId", "apiKey", "realm"]],
|
||||||
|
category: ["healthMonitoring"],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png",
|
||||||
|
},
|
||||||
} as const satisfies Record<string, integrationDefinition>;
|
} as const satisfies Record<string, integrationDefinition>;
|
||||||
|
|
||||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
|
"proxmox-api": "1.1.1",
|
||||||
"undici": "7.2.3",
|
"undici": "7.2.3",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
|||||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||||
import { PlexIntegration } from "../plex/plex-integration";
|
import { PlexIntegration } from "../plex/plex-integration";
|
||||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||||
|
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||||
import type { Integration, IntegrationInput } from "./integration";
|
import type { Integration, IntegrationInput } from "./integration";
|
||||||
|
|
||||||
export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
|
export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
|
||||||
@@ -72,4 +73,5 @@ export const integrationCreators = {
|
|||||||
readarr: ReadarrIntegration,
|
readarr: ReadarrIntegration,
|
||||||
dashDot: DashDotIntegration,
|
dashDot: DashDotIntegration,
|
||||||
tdarr: TdarrIntegration,
|
tdarr: TdarrIntegration,
|
||||||
|
proxmox: ProxmoxIntegration,
|
||||||
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
||||||
|
|||||||
125
packages/integrations/src/proxmox/proxmox-integration.ts
Normal file
125
packages/integrations/src/proxmox/proxmox-integration.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Proxmox } from "proxmox-api";
|
||||||
|
import proxmoxApi from "proxmox-api";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
import { extractErrorMessage } from "@homarr/common";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
|
import { Integration } from "../base/integration";
|
||||||
|
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||||
|
import type {
|
||||||
|
ComputeResourceBase,
|
||||||
|
LxcResource,
|
||||||
|
NodeResource,
|
||||||
|
QemuResource,
|
||||||
|
Resource,
|
||||||
|
StorageResource,
|
||||||
|
} from "./proxmox-types";
|
||||||
|
|
||||||
|
export class ProxmoxIntegration extends Integration {
|
||||||
|
public async testConnectionAsync(): Promise<void> {
|
||||||
|
const proxmox = this.getPromoxApi();
|
||||||
|
await proxmox.nodes.$get().catch((error) => {
|
||||||
|
throw new IntegrationTestConnectionError("internalServerError", extractErrorMessage(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getClusterInfoAsync() {
|
||||||
|
const proxmox = this.getPromoxApi();
|
||||||
|
const resources = await proxmox.cluster.resources.$get();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Found ${resources.length} resources in Proxmox cluster node=${resources.filter((resource) => resource.type === "node").length} lxc=${resources.filter((resource) => resource.type === "lxc").length} qemu=${resources.filter((resource) => resource.type === "qemu").length} storage=${resources.filter((resource) => resource.type === "storage").length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedResources = resources.map(mapResource).filter((resource) => resource !== null);
|
||||||
|
return {
|
||||||
|
nodes: mappedResources.filter((resource): resource is NodeResource => resource.type === "node"),
|
||||||
|
lxcs: mappedResources.filter((resource): resource is LxcResource => resource.type === "lxc"),
|
||||||
|
vms: mappedResources.filter((resource): resource is QemuResource => resource.type === "qemu"),
|
||||||
|
storages: mappedResources.filter((resource): resource is StorageResource => resource.type === "storage"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPromoxApi() {
|
||||||
|
return proxmoxApi({
|
||||||
|
host: this.url("/").host,
|
||||||
|
tokenID: `${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}`,
|
||||||
|
tokenSecret: this.getSecretValue("apiKey"),
|
||||||
|
fetch: fetchWithTrustedCertificatesAsync,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapResource = (resource: Proxmox.clusterResourcesResources): Resource | null => {
|
||||||
|
switch (resource.type) {
|
||||||
|
case "node":
|
||||||
|
return mapNodeResource(resource);
|
||||||
|
case "lxc":
|
||||||
|
case "qemu":
|
||||||
|
return mapVmResource(resource);
|
||||||
|
case "storage":
|
||||||
|
return mapStorageResource(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapComputeResource = (resource: Proxmox.clusterResourcesResources): Omit<ComputeResourceBase<string>, "type"> => {
|
||||||
|
return {
|
||||||
|
cpu: {
|
||||||
|
utilization: resource.cpu ?? 0,
|
||||||
|
cores: resource.maxcpu ?? 0,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
used: resource.mem ?? 0,
|
||||||
|
total: resource.maxmem ?? 0,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
used: resource.disk ?? 0,
|
||||||
|
total: resource.maxdisk ?? 0,
|
||||||
|
read: (resource.diskread as number | null) ?? null,
|
||||||
|
write: (resource.diskwrite as number | null) ?? null,
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
in: (resource.netin as number | null) ?? null,
|
||||||
|
out: (resource.netout as number | null) ?? null,
|
||||||
|
},
|
||||||
|
haState: resource.hastate ?? null,
|
||||||
|
isRunning: resource.status === "running" || resource.status === "online",
|
||||||
|
name: resource.name ?? "",
|
||||||
|
node: resource.node ?? "",
|
||||||
|
status: resource.status ?? (resource.type === "node" ? "offline" : "stopped"),
|
||||||
|
uptime: resource.uptime ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapNodeResource = (resource: Proxmox.clusterResourcesResources): NodeResource => {
|
||||||
|
return {
|
||||||
|
type: "node",
|
||||||
|
...mapComputeResource(resource),
|
||||||
|
name: resource.node ?? "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapVmResource = (resource: Proxmox.clusterResourcesResources): LxcResource | QemuResource => {
|
||||||
|
return {
|
||||||
|
type: resource.type as "lxc" | "qemu",
|
||||||
|
vmId: resource.vmid ?? 0,
|
||||||
|
...mapComputeResource(resource),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStorageResource = (resource: Proxmox.clusterResourcesResources): StorageResource => {
|
||||||
|
return {
|
||||||
|
type: "storage",
|
||||||
|
name: resource.storage ?? "",
|
||||||
|
node: resource.node ?? "",
|
||||||
|
isRunning: resource.status === "available",
|
||||||
|
status: resource.status ?? "offline",
|
||||||
|
storagePlugin: resource.storage ?? "",
|
||||||
|
total: resource.maxdisk ?? 0,
|
||||||
|
used: resource.disk ?? 0,
|
||||||
|
isShared: resource.shared === 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
57
packages/integrations/src/proxmox/proxmox-types.ts
Normal file
57
packages/integrations/src/proxmox/proxmox-types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
interface ResourceBase<TType extends string> {
|
||||||
|
type: TType;
|
||||||
|
name: string;
|
||||||
|
node: string;
|
||||||
|
isRunning: boolean;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeResourceBase<TType extends string> extends ResourceBase<TType> {
|
||||||
|
cpu: {
|
||||||
|
utilization: number; // previously cpu (0-1)
|
||||||
|
cores: number; // previously cpuCores
|
||||||
|
};
|
||||||
|
memory: {
|
||||||
|
used: number; // previously mem
|
||||||
|
total: number; // previously maxMem
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
used: number; // previously disk
|
||||||
|
total: number; // previously maxDisk
|
||||||
|
read: number | null; // previously diskRead
|
||||||
|
write: number | null; // previously diskWrite
|
||||||
|
};
|
||||||
|
network: {
|
||||||
|
in: number | null; // previously netIn
|
||||||
|
out: number | null; // previously netOut
|
||||||
|
};
|
||||||
|
uptime: number; // expressed in seconds
|
||||||
|
haState: string | null; // HA service status (for HA managed VMs).
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeResource = ComputeResourceBase<"node">;
|
||||||
|
|
||||||
|
export interface LxcResource extends ComputeResourceBase<"lxc"> {
|
||||||
|
vmId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QemuResource extends ComputeResourceBase<"qemu"> {
|
||||||
|
vmId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageResource extends ResourceBase<"storage"> {
|
||||||
|
storagePlugin: string;
|
||||||
|
used: number; // previously disk
|
||||||
|
total: number; // previously maxDisk
|
||||||
|
isShared: boolean; // previously storageShared
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComputeResource = NodeResource | LxcResource | QemuResource;
|
||||||
|
export type Resource = ComputeResource | StorageResource;
|
||||||
|
|
||||||
|
export interface ProxmoxClusterInfo {
|
||||||
|
nodes: NodeResource[];
|
||||||
|
lxcs: LxcResource[];
|
||||||
|
vms: QemuResource[];
|
||||||
|
storages: StorageResource[];
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export * from "./interfaces/media-requests/media-request";
|
|||||||
export * from "./pi-hole/pi-hole-types";
|
export * from "./pi-hole/pi-hole-types";
|
||||||
export * from "./base/searchable-integration";
|
export * from "./base/searchable-integration";
|
||||||
export * from "./homeassistant/homeassistant-types";
|
export * from "./homeassistant/homeassistant-types";
|
||||||
|
export * from "./proxmox/proxmox-types";
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ const optionMapping: OptionMapping = {
|
|||||||
"fileSystem" in oldOptions
|
"fileSystem" in oldOptions
|
||||||
? oldOptions.fileSystem
|
? oldOptions.fileSystem
|
||||||
: oldOptions.graphsOrder.some((graph) => graph.key === "storage" && graph.subValues.enabled),
|
: oldOptions.graphsOrder.some((graph) => graph.key === "storage" && graph.subValues.enabled),
|
||||||
|
defaultTab: (oldOptions) => ("defaultTabState" in oldOptions ? oldOptions.defaultTabState : undefined),
|
||||||
|
sectionIndicatorRequirement: (oldOptions) =>
|
||||||
|
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
|
||||||
},
|
},
|
||||||
mediaTranscoding: {
|
mediaTranscoding: {
|
||||||
defaultView: (oldOptions) => oldOptions.defaultView,
|
defaultView: (oldOptions) => oldOptions.defaultView,
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import dayjs from "dayjs";
|
|||||||
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { integrationCreator } from "@homarr/integrations";
|
||||||
import type { HealthMonitoring } from "@homarr/integrations/types";
|
import type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
|
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
HealthMonitoring,
|
HealthMonitoring,
|
||||||
IntegrationKindByCategory<"healthMonitoring">,
|
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox">,
|
||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
@@ -18,3 +18,16 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
queryKey: "systemInfo",
|
queryKey: "systemInfo",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
|
ProxmoxClusterInfo,
|
||||||
|
"proxmox",
|
||||||
|
Record<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration, _input) {
|
||||||
|
const integrationInstance = integrationCreator(integration);
|
||||||
|
return await integrationInstance.getClusterInfoAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
|
queryKey: "clusterInfo",
|
||||||
|
});
|
||||||
|
|||||||
@@ -733,6 +733,14 @@
|
|||||||
"password": {
|
"password": {
|
||||||
"label": "Password",
|
"label": "Password",
|
||||||
"newLabel": "New password"
|
"newLabel": "New password"
|
||||||
|
},
|
||||||
|
"tokenId": {
|
||||||
|
"label": "Token ID",
|
||||||
|
"newLabel": "New token ID"
|
||||||
|
},
|
||||||
|
"realm": {
|
||||||
|
"label": "Realm",
|
||||||
|
"newLabel": "New realm"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1411,6 +1419,12 @@
|
|||||||
},
|
},
|
||||||
"fileSystem": {
|
"fileSystem": {
|
||||||
"label": "Show Filesystem Info"
|
"label": "Show Filesystem Info"
|
||||||
|
},
|
||||||
|
"defaultTab": {
|
||||||
|
"label": "Default tab"
|
||||||
|
},
|
||||||
|
"sectionIndicatorRequirement": {
|
||||||
|
"label": "Section indicator requirement"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"popover": {
|
"popover": {
|
||||||
@@ -1430,6 +1444,52 @@
|
|||||||
"memory": {},
|
"memory": {},
|
||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Failed to fetch health status"
|
"internalServerError": "Failed to fetch health status"
|
||||||
|
},
|
||||||
|
"cluster": {
|
||||||
|
"summary": {
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "RAM"
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"node": {
|
||||||
|
"name": "Nodes"
|
||||||
|
},
|
||||||
|
"qemu": {
|
||||||
|
"name": "VMs"
|
||||||
|
},
|
||||||
|
"lxc": {
|
||||||
|
"name": "LXCs"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"name": "Storage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"popover": {
|
||||||
|
"rightSection": {
|
||||||
|
"node": "Node",
|
||||||
|
"vmId": "VM ID",
|
||||||
|
"plugin": "Plugin"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"cpu": "Cores",
|
||||||
|
"memory": "Memory",
|
||||||
|
"storage": "Storage",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"haState": "HA State",
|
||||||
|
"storageType": {
|
||||||
|
"local": "Local storage",
|
||||||
|
"shared": "Shared storage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"header": {
|
||||||
|
"name": "Name",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "RAM",
|
||||||
|
"node": "Node"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
|
||||||
|
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { Resource } from "@homarr/integrations/types";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
|
import { formatUptime } from "../system-health";
|
||||||
|
import { ResourceAccordionItem } from "./resource-accordion-item";
|
||||||
|
import { ResourceTable } from "./resource-table";
|
||||||
|
|
||||||
|
const addBadgeColor = ({
|
||||||
|
activeCount,
|
||||||
|
totalCount,
|
||||||
|
sectionIndicatorRequirement,
|
||||||
|
}: {
|
||||||
|
activeCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
sectionIndicatorRequirement: WidgetComponentProps<"healthMonitoring">["options"]["sectionIndicatorRequirement"];
|
||||||
|
}) => ({
|
||||||
|
color: activeCount === totalCount || (sectionIndicatorRequirement === "any" && activeCount >= 1) ? "green" : "orange",
|
||||||
|
activeCount,
|
||||||
|
totalCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
const running = (total: number, current: Resource) => {
|
||||||
|
return current.isRunning ? total + 1 : total;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClusterHealthMonitoring = ({
|
||||||
|
integrationId,
|
||||||
|
options,
|
||||||
|
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [healthData] = clientApi.widget.healthMonitoring.getClusterHealthStatus.useSuspenseQuery(
|
||||||
|
{
|
||||||
|
integrationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
clientApi.widget.healthMonitoring.subscribeClusterHealthStatus.useSubscription(
|
||||||
|
{ integrationId },
|
||||||
|
{
|
||||||
|
onData(data) {
|
||||||
|
utils.widget.healthMonitoring.getClusterHealthStatus.setData({ integrationId }, data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeNodes = healthData.nodes.reduce(running, 0);
|
||||||
|
const activeVMs = healthData.vms.reduce(running, 0);
|
||||||
|
const activeLXCs = healthData.lxcs.reduce(running, 0);
|
||||||
|
const activeStorage = healthData.storages.reduce(running, 0);
|
||||||
|
|
||||||
|
const usedMem = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.memory.used + sum : sum), 0);
|
||||||
|
const maxMem = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.memory.total + sum : sum), 0);
|
||||||
|
const maxCpu = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.cpu.cores + sum : sum), 0);
|
||||||
|
const usedCpu = healthData.nodes.reduce(
|
||||||
|
(sum, item) => (item.isRunning ? item.cpu.utilization * item.cpu.cores + sum : sum),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const uptime = healthData.nodes.reduce((sum, { uptime }) => (sum > uptime ? sum : uptime), 0);
|
||||||
|
|
||||||
|
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
|
||||||
|
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack h="100%">
|
||||||
|
<Group justify="center" wrap="nowrap" pt="md">
|
||||||
|
<Text fz="md" tt="uppercase" fw={700} c="dimmed" ta="center">
|
||||||
|
{formatUptime(uptime, t)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<SummaryHeader cpu={cpuPercent} memory={memPercent} />
|
||||||
|
<Accordion variant="contained" chevronPosition="right" multiple defaultValue={["node"]}>
|
||||||
|
<ResourceAccordionItem
|
||||||
|
value="node"
|
||||||
|
title={t("widget.healthMonitoring.cluster.resource.node.name")}
|
||||||
|
icon={IconServer}
|
||||||
|
badge={addBadgeColor({
|
||||||
|
activeCount: activeNodes,
|
||||||
|
totalCount: healthData.nodes.length,
|
||||||
|
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ResourceTable type="node" data={healthData.nodes} />
|
||||||
|
</ResourceAccordionItem>
|
||||||
|
|
||||||
|
<ResourceAccordionItem
|
||||||
|
value="qemu"
|
||||||
|
title={t("widget.healthMonitoring.cluster.resource.qemu.name")}
|
||||||
|
icon={IconDeviceLaptop}
|
||||||
|
badge={addBadgeColor({
|
||||||
|
activeCount: activeVMs,
|
||||||
|
totalCount: healthData.vms.length,
|
||||||
|
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ResourceTable type="qemu" data={healthData.vms} />
|
||||||
|
</ResourceAccordionItem>
|
||||||
|
|
||||||
|
<ResourceAccordionItem
|
||||||
|
value="lxc"
|
||||||
|
title={t("widget.healthMonitoring.cluster.resource.lxc.name")}
|
||||||
|
icon={IconCube}
|
||||||
|
badge={addBadgeColor({
|
||||||
|
activeCount: activeLXCs,
|
||||||
|
totalCount: healthData.lxcs.length,
|
||||||
|
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ResourceTable type="lxc" data={healthData.lxcs} />
|
||||||
|
</ResourceAccordionItem>
|
||||||
|
|
||||||
|
<ResourceAccordionItem
|
||||||
|
value="storage"
|
||||||
|
title={t("widget.healthMonitoring.cluster.resource.storage.name")}
|
||||||
|
icon={IconDatabase}
|
||||||
|
badge={addBadgeColor({
|
||||||
|
activeCount: activeStorage,
|
||||||
|
totalCount: healthData.storages.length,
|
||||||
|
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ResourceTable type="storage" data={healthData.storages} />
|
||||||
|
</ResourceAccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SummaryHeaderProps {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SummaryHeader = ({ cpu, memory }: SummaryHeaderProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<Center>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<Flex direction="row">
|
||||||
|
<RingProgress
|
||||||
|
roundCaps
|
||||||
|
size={60}
|
||||||
|
thickness={6}
|
||||||
|
label={
|
||||||
|
<Center>
|
||||||
|
<IconCpu />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[{ value: cpu, color: cpu > 75 ? "orange" : "green" }]}
|
||||||
|
/>
|
||||||
|
<Stack align="center" justify="center" gap={0}>
|
||||||
|
<Text fw={500}>{t("widget.healthMonitoring.cluster.summary.cpu")}</Text>
|
||||||
|
<Text>{cpu.toFixed(1)}%</Text>
|
||||||
|
</Stack>
|
||||||
|
</Flex>
|
||||||
|
<Flex>
|
||||||
|
<RingProgress
|
||||||
|
roundCaps
|
||||||
|
size={60}
|
||||||
|
thickness={6}
|
||||||
|
label={
|
||||||
|
<Center>
|
||||||
|
<IconBrain />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[{ value: memory, color: memory > 75 ? "orange" : "green" }]}
|
||||||
|
/>
|
||||||
|
<Stack align="center" justify="center" gap={0}>
|
||||||
|
<Text fw={500}>{t("widget.healthMonitoring.cluster.summary.memory")}</Text>
|
||||||
|
<Text>{memory.toFixed(1)}%</Text>
|
||||||
|
</Stack>
|
||||||
|
</Flex>
|
||||||
|
</Group>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import type { MantineColor } from "@mantine/core";
|
||||||
|
import { Accordion, Badge, Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
|
interface ResourceAccordionItemProps {
|
||||||
|
value: string;
|
||||||
|
title: string;
|
||||||
|
icon: TablerIcon;
|
||||||
|
badge: {
|
||||||
|
color: MantineColor;
|
||||||
|
activeCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourceAccordionItem = ({
|
||||||
|
value,
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<ResourceAccordionItemProps>) => {
|
||||||
|
return (
|
||||||
|
<Accordion.Item value={value}>
|
||||||
|
<Accordion.Control icon={<Icon />}>
|
||||||
|
<Group style={{ rowGap: "0" }}>
|
||||||
|
<Text>{title}</Text>
|
||||||
|
<Badge variant="dot" color={badge.color} size="lg">
|
||||||
|
{badge.activeCount} / {badge.totalCount}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>{children}</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { Badge, Center, Divider, Flex, Group, List, Popover, RingProgress, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconArrowNarrowDown,
|
||||||
|
IconArrowNarrowUp,
|
||||||
|
IconBrain,
|
||||||
|
IconClockHour3,
|
||||||
|
IconCpu,
|
||||||
|
IconDatabase,
|
||||||
|
IconDeviceLaptop,
|
||||||
|
IconHeartBolt,
|
||||||
|
IconNetwork,
|
||||||
|
IconQuestionMark,
|
||||||
|
IconServer,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
|
import { capitalize, humanFileSize } from "@homarr/common";
|
||||||
|
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
dayjs.extend(duration);
|
||||||
|
|
||||||
|
interface ResourcePopoverProps {
|
||||||
|
item: Resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourcePopover = ({ item, children }: PropsWithChildren<ResourcePopoverProps>) => {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
withArrow
|
||||||
|
withinPortal
|
||||||
|
radius="lg"
|
||||||
|
shadow="sm"
|
||||||
|
transitionProps={{
|
||||||
|
transition: "pop",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<ResourceTypeEntryDetails item={item} />
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResourceTypeEntryDetails = ({ item }: { item: Resource }) => {
|
||||||
|
const t = useScopedI18n("widget.healthMonitoring.cluster.popover");
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group wrap="nowrap" align="start" justify="apart">
|
||||||
|
<Group wrap="nowrap" align="center">
|
||||||
|
<ResourceIcon type={item.type} size={35} />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={700} size="md">
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text c={item.isRunning ? "green" : "yellow"}>{capitalize(item.status)}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group align="end">
|
||||||
|
{item.type === "node" && <RightSection label={t("rightSection.node")} value={item.node} />}
|
||||||
|
{item.type === "lxc" && <RightSection label={t("rightSection.vmId")} value={item.vmId} />}
|
||||||
|
{item.type === "qemu" && <RightSection label={t("rightSection.vmId")} value={item.vmId} />}
|
||||||
|
{item.type === "storage" && <RightSection label={t("rightSection.plugin")} value={item.storagePlugin} />}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Divider mt={0} mb="xs" />
|
||||||
|
{item.type !== "storage" && <ComputeResourceDetails item={item} />}
|
||||||
|
{item.type === "storage" && <StorageResourceDetails item={item} />}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RightSectionProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RightSection = ({ label, value }: RightSectionProps) => {
|
||||||
|
return (
|
||||||
|
<Stack align="end" gap={0}>
|
||||||
|
<Text fw={200} size="sm">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
||||||
|
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||||
|
return (
|
||||||
|
<List>
|
||||||
|
<List.Item icon={<IconCpu size={16} />}>
|
||||||
|
{t("cpu")} - {item.cpu.cores}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item icon={<IconBrain size={16} />}>
|
||||||
|
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item icon={<IconDatabase size={16} />}>
|
||||||
|
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item icon={<IconClockHour3 size={16} />}>
|
||||||
|
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
|
||||||
|
</List.Item>
|
||||||
|
{item.haState && (
|
||||||
|
<List.Item icon={<IconHeartBolt size={16} />}>
|
||||||
|
{t("haState")} - {capitalize(item.haState)}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
<NetStats item={item} />
|
||||||
|
<DiskStats item={item} />
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
|
||||||
|
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||||
|
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Center>
|
||||||
|
<RingProgress
|
||||||
|
roundCaps
|
||||||
|
size={100}
|
||||||
|
thickness={10}
|
||||||
|
label={<Text ta="center">{storagePercent.toFixed(1)}%</Text>}
|
||||||
|
sections={[{ value: storagePercent, color: storagePercent > 75 ? "orange" : "green" }]}
|
||||||
|
/>
|
||||||
|
<Group align="center" gap={0}>
|
||||||
|
<Text>
|
||||||
|
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Center>
|
||||||
|
<Flex gap="sm" mt={0} justify="end">
|
||||||
|
<StorageType item={item} />
|
||||||
|
</Flex>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiskStats = ({ item }: { item: ComputeResource }) => {
|
||||||
|
if (!item.storage.read || !item.storage.write) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<List.Item icon={<IconDatabase size={16} />}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Group gap={0}>
|
||||||
|
<Text>{humanFileSize(item.storage.write)}</Text>
|
||||||
|
<IconArrowNarrowDown size={14} />
|
||||||
|
</Group>
|
||||||
|
<Group gap={0}>
|
||||||
|
<Text>{humanFileSize(item.storage.read)}</Text>
|
||||||
|
<IconArrowNarrowUp size={14} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NetStats = ({ item }: { item: ComputeResource }) => {
|
||||||
|
if (!item.network.in || !item.network.out) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<List.Item icon={<IconNetwork size={16} />}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Group gap={0}>
|
||||||
|
<Text>{humanFileSize(item.network.in)}</Text>
|
||||||
|
<IconArrowNarrowDown size={14} />
|
||||||
|
</Group>
|
||||||
|
<Group gap={0}>
|
||||||
|
<Text>{humanFileSize(item.network.out)}</Text>
|
||||||
|
<IconArrowNarrowUp size={14} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StorageType = ({ item }: { item: StorageResource }) => {
|
||||||
|
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail.storageType");
|
||||||
|
if (item.isShared) {
|
||||||
|
return <Badge color="blue">{t("shared")}</Badge>;
|
||||||
|
} else {
|
||||||
|
return <Badge color="teal">{t("local")}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResourceIcon = ({ type, size }: { type: Resource["type"]; size: number }) => {
|
||||||
|
switch (type) {
|
||||||
|
case "node":
|
||||||
|
return <IconServer size={size} />;
|
||||||
|
case "lxc":
|
||||||
|
return <IconDeviceLaptop size={size} />;
|
||||||
|
case "qemu":
|
||||||
|
return <IconDeviceLaptop size={size} />;
|
||||||
|
case "storage":
|
||||||
|
return <IconDatabase size={size} />;
|
||||||
|
default:
|
||||||
|
console.error(`Unknown resource type: ${type as string}`);
|
||||||
|
return <IconQuestionMark size={size} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Group, Indicator, Popover, Table, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { Resource } from "@homarr/integrations/types";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { ResourcePopover } from "./resource-popover";
|
||||||
|
|
||||||
|
interface ResourceTableProps {
|
||||||
|
type: Resource["type"];
|
||||||
|
data: Resource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourceTable = ({ type, data }: ResourceTableProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Table.Th ta="start">{t("widget.healthMonitoring.cluster.table.header.name")}</Table.Th>
|
||||||
|
{type !== "storage" ? (
|
||||||
|
<Table.Th ta="start">{t("widget.healthMonitoring.cluster.table.header.cpu")}</Table.Th>
|
||||||
|
) : null}
|
||||||
|
{type !== "storage" ? (
|
||||||
|
<Table.Th ta="start">{t("widget.healthMonitoring.cluster.table.header.memory")}</Table.Th>
|
||||||
|
) : null}
|
||||||
|
{type === "storage" ? (
|
||||||
|
<Table.Th ta="start">{t("widget.healthMonitoring.cluster.table.header.node")}</Table.Th>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item) => {
|
||||||
|
return (
|
||||||
|
<ResourcePopover key={item.name} item={item}>
|
||||||
|
<Popover.Target>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<Indicator size={14} children={null} color={item.isRunning ? "green" : "yellow"} />
|
||||||
|
<Text lineClamp={1}>{item.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
{item.type === "storage" ? (
|
||||||
|
<td style={{ WebkitLineClamp: "1" }}>{item.node}</td>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</td>
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{(item.memory.total ? (item.memory.used / item.memory.total) * 100 : 0).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</Popover.Target>
|
||||||
|
</ResourcePopover>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,425 +1,61 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { ScrollArea, Tabs } from "@mantine/core";
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
Center,
|
|
||||||
Divider,
|
|
||||||
Flex,
|
|
||||||
Group,
|
|
||||||
Indicator,
|
|
||||||
List,
|
|
||||||
Modal,
|
|
||||||
Progress,
|
|
||||||
RingProgress,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure, useElementSize } from "@mantine/hooks";
|
|
||||||
import {
|
|
||||||
IconBrain,
|
|
||||||
IconClock,
|
|
||||||
IconCpu,
|
|
||||||
IconCpu2,
|
|
||||||
IconFileReport,
|
|
||||||
IconInfoCircle,
|
|
||||||
IconServer,
|
|
||||||
IconTemperature,
|
|
||||||
IconVersions,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import duration from "dayjs/plugin/duration";
|
import duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
|
||||||
|
import { SystemHealthMonitoring } from "./system-health";
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
|
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
|
||||||
const t = useI18n();
|
const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds);
|
||||||
const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery(
|
|
||||||
{
|
|
||||||
integrationIds,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
|
|
||||||
clientApi.widget.healthMonitoring.subscribeHealthStatus.useSubscription(
|
const proxmoxIntegrationId = integrations.find((integration) => integration.kind === "proxmox")?.id;
|
||||||
{ integrationIds },
|
|
||||||
{
|
if (!proxmoxIntegrationId) {
|
||||||
onData(data) {
|
return <SystemHealthMonitoring {...props} />;
|
||||||
utils.widget.healthMonitoring.getHealthStatus.setData({ integrationIds }, (prevData) => {
|
}
|
||||||
if (!prevData) {
|
|
||||||
return undefined;
|
const otherIntegrationIds = integrations
|
||||||
}
|
.filter((integration) => integration.kind !== "proxmox")
|
||||||
const newData = prevData.map((item) =>
|
.map((integration) => integration.id);
|
||||||
item.integrationId === data.integrationId
|
if (otherIntegrationIds.length === 0) {
|
||||||
? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp }
|
return <ClusterHealthMonitoring {...props} integrationId={proxmoxIntegrationId} />;
|
||||||
: item,
|
}
|
||||||
);
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="2.5cqmin" className="health-monitoring">
|
<ScrollArea
|
||||||
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
h="100%"
|
||||||
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
styles={{
|
||||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
viewport: {
|
||||||
return (
|
'& div[style="min-width: 100%"]': {
|
||||||
<Stack
|
display: "flex !important",
|
||||||
gap="2.5cqmin"
|
height: "100%",
|
||||||
key={integrationId}
|
},
|
||||||
h="100%"
|
},
|
||||||
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
}}
|
||||||
p="2.5cqmin"
|
>
|
||||||
>
|
<Tabs defaultValue={props.options.defaultTab} variant="outline">
|
||||||
<Card className="health-monitoring-information-card" p="2.5cqmin" withBorder>
|
<Tabs.List grow>
|
||||||
<Flex
|
<Tabs.Tab value="system">
|
||||||
className="health-monitoring-information-card-elements"
|
<b>System</b>
|
||||||
h="100%"
|
</Tabs.Tab>
|
||||||
w="100%"
|
<Tabs.Tab value="cluster">
|
||||||
justify="space-between"
|
<b>Cluster</b>
|
||||||
align="center"
|
</Tabs.Tab>
|
||||||
key={integrationId}
|
</Tabs.List>
|
||||||
>
|
<Tabs.Panel mt="lg" value="system">
|
||||||
<Box className="health-monitoring-information-card-section">
|
<SystemHealthMonitoring {...props} />
|
||||||
<Indicator
|
</Tabs.Panel>
|
||||||
className="health-monitoring-updates-reboot-indicator"
|
<Tabs.Panel mt="lg" value="cluster">
|
||||||
inline
|
<ClusterHealthMonitoring integrationId={proxmoxIntegrationId} {...props} />
|
||||||
processing
|
</Tabs.Panel>
|
||||||
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
|
</Tabs>
|
||||||
position="top-end"
|
</ScrollArea>
|
||||||
size="4cqmin"
|
|
||||||
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
|
|
||||||
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
|
|
||||||
>
|
|
||||||
<Avatar className="health-monitoring-information-icon-avatar" size="10cqmin" radius="sm">
|
|
||||||
<IconInfoCircle className="health-monitoring-information-icon" size="8cqmin" onClick={open} />
|
|
||||||
</Avatar>
|
|
||||||
</Indicator>
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={close}
|
|
||||||
size="auto"
|
|
||||||
title={t("widget.healthMonitoring.popover.information")}
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Stack gap="10px" className="health-monitoring-modal-stack">
|
|
||||||
<Divider />
|
|
||||||
<List className="health-monitoring-information-list" center spacing="0.5cqmin">
|
|
||||||
<List.Item
|
|
||||||
className="health-monitoring-information-processor"
|
|
||||||
icon={<IconCpu2 size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
|
||||||
</List.Item>
|
|
||||||
<List.Item
|
|
||||||
className="health-monitoring-information-memory"
|
|
||||||
icon={<IconBrain size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
|
||||||
</List.Item>
|
|
||||||
<List.Item
|
|
||||||
className="health-monitoring-information-memory"
|
|
||||||
icon={<IconBrain size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
|
||||||
memoryAvailable: memoryUsage.memFree.GB,
|
|
||||||
percent: memoryUsage.memFree.percent,
|
|
||||||
})}
|
|
||||||
</List.Item>
|
|
||||||
<List.Item
|
|
||||||
className="health-monitoring-information-version"
|
|
||||||
icon={<IconVersions size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.version", {
|
|
||||||
version: healthInfo.version,
|
|
||||||
})}
|
|
||||||
</List.Item>
|
|
||||||
<List.Item
|
|
||||||
className="health-monitoring-information-uptime"
|
|
||||||
icon={<IconClock size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{formatUptime(healthInfo.uptime, t)}
|
|
||||||
</List.Item>
|
|
||||||
<List.Item
|
|
||||||
className="health-monitoring-information-load-average"
|
|
||||||
icon={<IconCpu size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.loadAverage")}
|
|
||||||
</List.Item>
|
|
||||||
<List m="0.5cqmin" withPadding center spacing="0.5cqmin" icon={<IconCpu size="1cqmin" />}>
|
|
||||||
<List.Item className="health-monitoring-information-load-average-1min">
|
|
||||||
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
|
||||||
</List.Item>
|
|
||||||
<List.Item className="health-monitoring-information-load-average-5min">
|
|
||||||
{t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "}
|
|
||||||
{healthInfo.loadAverage["5min"]}%
|
|
||||||
</List.Item>
|
|
||||||
<List.Item className="health-monitoring-information-load-average-15min">
|
|
||||||
{t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "}
|
|
||||||
{healthInfo.loadAverage["15min"]}%
|
|
||||||
</List.Item>
|
|
||||||
</List>
|
|
||||||
</List>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</Box>
|
|
||||||
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} />}
|
|
||||||
{healthInfo.cpuTemp && options.cpu && (
|
|
||||||
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} />
|
|
||||||
)}
|
|
||||||
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
|
||||||
</Flex>
|
|
||||||
{
|
|
||||||
<Text
|
|
||||||
className="health-monitoring-status-update-time"
|
|
||||||
c="dimmed"
|
|
||||||
size="3.5cqmin"
|
|
||||||
ta="center"
|
|
||||||
mb="2.5cqmin"
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
</Card>
|
|
||||||
{options.fileSystem &&
|
|
||||||
disksData.map((disk) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`}
|
|
||||||
key={disk.deviceName}
|
|
||||||
p="2.5cqmin"
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
<Flex className="health-monitoring-disk-status" justify="space-between" align="center" m="1.5cqmin">
|
|
||||||
<Group gap="1cqmin">
|
|
||||||
<IconServer className="health-monitoring-disk-icon" size="5cqmin" />
|
|
||||||
<Text className="dihealth-monitoring-disk-name" size="4cqmin">
|
|
||||||
{disk.deviceName}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="1cqmin">
|
|
||||||
<IconTemperature className="health-monitoring-disk-temperature-icon" size="5cqmin" />
|
|
||||||
<Text className="health-monitoring-disk-temperature-value" size="4cqmin">
|
|
||||||
{options.fahrenheit
|
|
||||||
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
|
||||||
: `${disk.temperature}°C`}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="1cqmin">
|
|
||||||
<IconFileReport className="health-monitoring-disk-status-icon" size="5cqmin" />
|
|
||||||
<Text className="health-monitoring-disk-status-value" size="4cqmin">
|
|
||||||
{disk.overallStatus}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Flex>
|
|
||||||
<Progress.Root className="health-monitoring-disk-use" h="6cqmin">
|
|
||||||
<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="2.5cqmin">
|
|
||||||
{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="2.5cqmin">
|
|
||||||
{t("widget.healthMonitoring.popover.available")}
|
|
||||||
</Progress.Label>
|
|
||||||
</Progress.Section>
|
|
||||||
</Tooltip>
|
|
||||||
</Progress.Root>
|
|
||||||
</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, days, hours, 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;
|
|
||||||
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));
|
|
||||||
};
|
|
||||||
|
|
||||||
const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => {
|
|
||||||
const { width, ref } = useElementSize();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-cpu-utilization"
|
|
||||||
roundCaps
|
|
||||||
size={width * 0.95}
|
|
||||||
thickness={width / 10}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text
|
|
||||||
className="health-monitoring-cpu-utilization-value"
|
|
||||||
size="3cqmin"
|
|
||||||
>{`${cpuUtilization.toFixed(2)}%`}</Text>
|
|
||||||
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: Number(cpuUtilization.toFixed(2)),
|
|
||||||
color: progressColor(Number(cpuUtilization.toFixed(2))),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number }) => {
|
|
||||||
const { width, ref } = useElementSize();
|
|
||||||
return (
|
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-cpu-temp"
|
|
||||||
roundCaps
|
|
||||||
size={width * 0.95}
|
|
||||||
thickness={width / 10}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
|
|
||||||
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
|
|
||||||
</Text>
|
|
||||||
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: cpuTemp,
|
|
||||||
color: progressColor(cpuTemp),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemoryRing = ({ available, used }: { available: string; used: string }) => {
|
|
||||||
const { width, ref } = useElementSize();
|
|
||||||
const memoryUsage = formatMemoryUsage(available, used);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-memory-use"
|
|
||||||
roundCaps
|
|
||||||
size={width * 0.95}
|
|
||||||
thickness={width / 10}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text className="health-monitoring-memory-value" size="3cqmin">
|
|
||||||
{memoryUsage.memUsed.GB}GiB
|
|
||||||
</Text>
|
|
||||||
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: Number(memoryUsage.memUsed.percent),
|
|
||||||
color: progressColor(Number(memoryUsage.memUsed.percent)),
|
|
||||||
tooltip: `${memoryUsage.memUsed.percent}%`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
|
|
||||||
const memFreeBytes = Number(memFree);
|
|
||||||
const memUsedBytes = Number(memUsed);
|
|
||||||
const totalMemory = memFreeBytes + memUsedBytes;
|
|
||||||
const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2);
|
|
||||||
const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2);
|
|
||||||
const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100);
|
|
||||||
const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100);
|
|
||||||
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
|
|
||||||
|
|
||||||
return {
|
|
||||||
memFree: { percent: memFreePercent, GB: memFreeGB },
|
|
||||||
memUsed: { percent: memUsedPercent, GB: memUsedGB },
|
|
||||||
memTotal: { GB: memTotalGB },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
|
|||||||
fileSystem: factory.switch({
|
fileSystem: factory.switch({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
}),
|
}),
|
||||||
|
defaultTab: factory.select({
|
||||||
|
defaultValue: "system",
|
||||||
|
options: [
|
||||||
|
{ value: "system", label: "System" },
|
||||||
|
{ value: "cluster", label: "Cluster" },
|
||||||
|
] as const,
|
||||||
|
}),
|
||||||
|
sectionIndicatorRequirement: factory.select({
|
||||||
|
defaultValue: "all",
|
||||||
|
options: [
|
||||||
|
{ value: "all", label: "All active" },
|
||||||
|
{ value: "any", label: "Any active" },
|
||||||
|
] as const,
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
|
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
425
packages/widgets/src/health-monitoring/system-health.tsx
Normal file
425
packages/widgets/src/health-monitoring/system-health.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Divider,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Indicator,
|
||||||
|
List,
|
||||||
|
Modal,
|
||||||
|
Progress,
|
||||||
|
RingProgress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure, useElementSize } from "@mantine/hooks";
|
||||||
|
import {
|
||||||
|
IconBrain,
|
||||||
|
IconClock,
|
||||||
|
IconCpu,
|
||||||
|
IconCpu2,
|
||||||
|
IconFileReport,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconServer,
|
||||||
|
IconTemperature,
|
||||||
|
IconVersions,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
dayjs.extend(duration);
|
||||||
|
|
||||||
|
export const SystemHealthMonitoring = ({ options, integrationIds }: 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();
|
||||||
|
|
||||||
|
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
|
||||||
|
{ integrationIds },
|
||||||
|
{
|
||||||
|
onData(data) {
|
||||||
|
utils.widget.healthMonitoring.getSystemHealthStatus.setData({ integrationIds }, (prevData) => {
|
||||||
|
if (!prevData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const newData = prevData.map((item) =>
|
||||||
|
item.integrationId === data.integrationId
|
||||||
|
? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack h="100%" gap="2.5cqmin" 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="2.5cqmin"
|
||||||
|
key={integrationId}
|
||||||
|
h="100%"
|
||||||
|
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
||||||
|
p="2.5cqmin"
|
||||||
|
>
|
||||||
|
<Card className="health-monitoring-information-card" p="2.5cqmin" withBorder>
|
||||||
|
<Flex
|
||||||
|
className="health-monitoring-information-card-elements"
|
||||||
|
h="100%"
|
||||||
|
w="100%"
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
key={integrationId}
|
||||||
|
>
|
||||||
|
<Box className="health-monitoring-information-card-section">
|
||||||
|
<Indicator
|
||||||
|
className="health-monitoring-updates-reboot-indicator"
|
||||||
|
inline
|
||||||
|
processing
|
||||||
|
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
|
||||||
|
position="top-end"
|
||||||
|
size="4cqmin"
|
||||||
|
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
|
||||||
|
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
|
||||||
|
>
|
||||||
|
<Avatar className="health-monitoring-information-icon-avatar" size="10cqmin" radius="sm">
|
||||||
|
<IconInfoCircle className="health-monitoring-information-icon" size="8cqmin" onClick={open} />
|
||||||
|
</Avatar>
|
||||||
|
</Indicator>
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
size="auto"
|
||||||
|
title={t("widget.healthMonitoring.popover.information")}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="10px" className="health-monitoring-modal-stack">
|
||||||
|
<Divider />
|
||||||
|
<List className="health-monitoring-information-list" center spacing="0.5cqmin">
|
||||||
|
<List.Item
|
||||||
|
className="health-monitoring-information-processor"
|
||||||
|
icon={<IconCpu2 size="1.5cqmin" />}
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
className="health-monitoring-information-memory"
|
||||||
|
icon={<IconBrain size="1.5cqmin" />}
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
className="health-monitoring-information-memory"
|
||||||
|
icon={<IconBrain size="1.5cqmin" />}
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
||||||
|
memoryAvailable: memoryUsage.memFree.GB,
|
||||||
|
percent: memoryUsage.memFree.percent,
|
||||||
|
})}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
className="health-monitoring-information-version"
|
||||||
|
icon={<IconVersions size="1.5cqmin" />}
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.version", {
|
||||||
|
version: healthInfo.version,
|
||||||
|
})}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
className="health-monitoring-information-uptime"
|
||||||
|
icon={<IconClock size="1.5cqmin" />}
|
||||||
|
>
|
||||||
|
{formatUptime(healthInfo.uptime, t)}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
className="health-monitoring-information-load-average"
|
||||||
|
icon={<IconCpu size="1.5cqmin" />}
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.loadAverage")}
|
||||||
|
</List.Item>
|
||||||
|
<List m="0.5cqmin" withPadding center spacing="0.5cqmin" icon={<IconCpu size="1cqmin" />}>
|
||||||
|
<List.Item className="health-monitoring-information-load-average-1min">
|
||||||
|
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
||||||
|
</List.Item>
|
||||||
|
<List.Item className="health-monitoring-information-load-average-5min">
|
||||||
|
{t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "}
|
||||||
|
{healthInfo.loadAverage["5min"]}%
|
||||||
|
</List.Item>
|
||||||
|
<List.Item className="health-monitoring-information-load-average-15min">
|
||||||
|
{t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "}
|
||||||
|
{healthInfo.loadAverage["15min"]}%
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</List>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Box>
|
||||||
|
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} />}
|
||||||
|
{healthInfo.cpuTemp && options.cpu && (
|
||||||
|
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} />
|
||||||
|
)}
|
||||||
|
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
||||||
|
</Flex>
|
||||||
|
{
|
||||||
|
<Text
|
||||||
|
className="health-monitoring-status-update-time"
|
||||||
|
c="dimmed"
|
||||||
|
size="3.5cqmin"
|
||||||
|
ta="center"
|
||||||
|
mb="2.5cqmin"
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
{options.fileSystem &&
|
||||||
|
disksData.map((disk) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`}
|
||||||
|
key={disk.deviceName}
|
||||||
|
p="2.5cqmin"
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Flex className="health-monitoring-disk-status" justify="space-between" align="center" m="1.5cqmin">
|
||||||
|
<Group gap="1cqmin">
|
||||||
|
<IconServer className="health-monitoring-disk-icon" size="5cqmin" />
|
||||||
|
<Text className="dihealth-monitoring-disk-name" size="4cqmin">
|
||||||
|
{disk.deviceName}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="1cqmin">
|
||||||
|
<IconTemperature className="health-monitoring-disk-temperature-icon" size="5cqmin" />
|
||||||
|
<Text className="health-monitoring-disk-temperature-value" size="4cqmin">
|
||||||
|
{options.fahrenheit
|
||||||
|
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
||||||
|
: `${disk.temperature}°C`}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="1cqmin">
|
||||||
|
<IconFileReport className="health-monitoring-disk-status-icon" size="5cqmin" />
|
||||||
|
<Text className="health-monitoring-disk-status-value" size="4cqmin">
|
||||||
|
{disk.overallStatus}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Flex>
|
||||||
|
<Progress.Root className="health-monitoring-disk-use" h="6cqmin">
|
||||||
|
<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="2.5cqmin">
|
||||||
|
{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="2.5cqmin">
|
||||||
|
{t("widget.healthMonitoring.popover.available")}
|
||||||
|
</Progress.Label>
|
||||||
|
</Progress.Section>
|
||||||
|
</Tooltip>
|
||||||
|
</Progress.Root>
|
||||||
|
</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, days, hours, 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;
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-cpu-utilization"
|
||||||
|
roundCaps
|
||||||
|
size={width * 0.95}
|
||||||
|
thickness={width / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text
|
||||||
|
className="health-monitoring-cpu-utilization-value"
|
||||||
|
size="3cqmin"
|
||||||
|
>{`${cpuUtilization.toFixed(2)}%`}</Text>
|
||||||
|
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: Number(cpuUtilization.toFixed(2)),
|
||||||
|
color: progressColor(Number(cpuUtilization.toFixed(2))),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-cpu-temp"
|
||||||
|
roundCaps
|
||||||
|
size={width * 0.95}
|
||||||
|
thickness={width / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
|
||||||
|
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
|
||||||
|
</Text>
|
||||||
|
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: cpuTemp,
|
||||||
|
color: progressColor(cpuTemp),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemoryRing = ({ available, used }: { available: string; used: string }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
const memoryUsage = formatMemoryUsage(available, used);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-memory-use"
|
||||||
|
roundCaps
|
||||||
|
size={width * 0.95}
|
||||||
|
thickness={width / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text className="health-monitoring-memory-value" size="3cqmin">
|
||||||
|
{memoryUsage.memUsed.GB}GiB
|
||||||
|
</Text>
|
||||||
|
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: Number(memoryUsage.memUsed.percent),
|
||||||
|
color: progressColor(Number(memoryUsage.memUsed.percent)),
|
||||||
|
tooltip: `${memoryUsage.memUsed.percent}%`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
|
||||||
|
const memFreeBytes = Number(memFree);
|
||||||
|
const memUsedBytes = Number(memUsed);
|
||||||
|
const totalMemory = memFreeBytes + memUsedBytes;
|
||||||
|
const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2);
|
||||||
|
const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2);
|
||||||
|
const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100);
|
||||||
|
const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100);
|
||||||
|
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
memFree: { percent: memFreePercent, GB: memFreeGB },
|
||||||
|
memUsed: { percent: memUsedPercent, GB: memUsedGB },
|
||||||
|
memTotal: { GB: memTotalGB },
|
||||||
|
};
|
||||||
|
};
|
||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
proxmox-api>undici: 7.2.3
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
pretty-print-error:
|
pretty-print-error:
|
||||||
hash: 4arrfgbz7em6s4gqywse7esg4u
|
hash: 4arrfgbz7em6s4gqywse7esg4u
|
||||||
@@ -1113,6 +1116,9 @@ importers:
|
|||||||
'@jellyfin/sdk':
|
'@jellyfin/sdk':
|
||||||
specifier: ^0.11.0
|
specifier: ^0.11.0
|
||||||
version: 0.11.0(axios@1.7.7)
|
version: 0.11.0(axios@1.7.7)
|
||||||
|
proxmox-api:
|
||||||
|
specifier: 1.1.1
|
||||||
|
version: 1.1.1
|
||||||
undici:
|
undici:
|
||||||
specifier: 7.2.3
|
specifier: 7.2.3
|
||||||
version: 7.2.3
|
version: 7.2.3
|
||||||
@@ -8031,6 +8037,9 @@ packages:
|
|||||||
proto-list@1.2.4:
|
proto-list@1.2.4:
|
||||||
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
||||||
|
|
||||||
|
proxmox-api@1.1.1:
|
||||||
|
resolution: {integrity: sha512-2qH7pxKBBHa7WtEBmxPaBY2FZEH2R04hqr9zD9PmErLzJ7RGGcfNcXoS/v5G4vBM2Igmnx0EAYBstPwwfDwHnA==}
|
||||||
|
|
||||||
proxy-agent@6.4.0:
|
proxy-agent@6.4.0:
|
||||||
resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==}
|
resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -16318,6 +16327,10 @@ snapshots:
|
|||||||
|
|
||||||
proto-list@1.2.4: {}
|
proto-list@1.2.4: {}
|
||||||
|
|
||||||
|
proxmox-api@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
undici: 7.2.3
|
||||||
|
|
||||||
proxy-agent@6.4.0:
|
proxy-agent@6.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.1
|
agent-base: 7.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user