feat: system resources widget (#3538)
* feat: add system resources widget * Update packages/widgets/src/system-resources/index.ts Co-authored-by: Andre Silva <32734153+Aandree5@users.noreply.github.com> * fix: system resources not updating * refactor: improve logic in component * fix: tooltip overflow * feat: add label with last value * feat: hide label when hovering * fix: formatting * fix: lint * fix: formatting * fix: wrong redis channel used for opnsense --------- Co-authored-by: Andre Silva <32734153+Aandree5@users.noreply.github.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -21,6 +21,12 @@ interface BoardItemContentProps {
|
|||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||||
|
if (kind === "iframe") return "hidden";
|
||||||
|
if (kind === "systemResources") return "visible";
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -41,7 +47,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
root: {
|
root: {
|
||||||
"--opacity": board.opacity / 100,
|
"--opacity": board.opacity / 100,
|
||||||
containerType: "size",
|
containerType: "size",
|
||||||
overflow: item.kind === "iframe" ? "hidden" : undefined,
|
overflow: getOverflowFromKind(item.kind),
|
||||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
|
|||||||
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
widgetKinds: ["healthMonitoring"],
|
widgetKinds: ["healthMonitoring", "systemResources"],
|
||||||
getInput: {
|
getInput: {
|
||||||
healthMonitoring: () => ({}),
|
healthMonitoring: () => ({}),
|
||||||
|
systemResources: () => ({}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ export const widgetKinds = [
|
|||||||
"dockerContainers",
|
"dockerContainers",
|
||||||
"firewall",
|
"firewall",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"systemResources",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
|
||||||
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
|
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -32,14 +32,16 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
|||||||
const cpuLoad = await this.getCurrentCpuLoadAsync();
|
const cpuLoad = await this.getCurrentCpuLoadAsync();
|
||||||
const memoryLoad = await this.getCurrentMemoryLoadAsync();
|
const memoryLoad = await this.getCurrentMemoryLoadAsync();
|
||||||
const storageLoad = await this.getCurrentStorageLoadAsync();
|
const storageLoad = await this.getCurrentStorageLoadAsync();
|
||||||
|
const networkLoad = await this.getCurrentNetworkLoadAsync();
|
||||||
|
|
||||||
const channel = this.getChannel();
|
const channel = this.getChannel();
|
||||||
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
|
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cpuUtilization: cpuLoad.sumLoad,
|
cpuUtilization: cpuLoad.sumLoad,
|
||||||
memUsed: `${memoryLoad.loadInBytes}`,
|
memUsedInBytes: memoryLoad.loadInBytes,
|
||||||
memAvailable: `${info.maxAvailableMemoryBytes - memoryLoad.loadInBytes}`,
|
memAvailableInBytes: info.maxAvailableMemoryBytes - memoryLoad.loadInBytes,
|
||||||
|
network: networkLoad,
|
||||||
fileSystem: info.storage
|
fileSystem: info.storage
|
||||||
.filter((_, index) => storageLoad[index] !== -1) // filter out undermoutned drives, they display as -1 in the load API
|
.filter((_, index) => storageLoad[index] !== -1) // filter out undermoutned drives, they display as -1 in the load API
|
||||||
.map((storage, index) => ({
|
.map((storage, index) => ({
|
||||||
@@ -113,8 +115,13 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getCurrentNetworkLoadAsync() {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/load/network"));
|
||||||
|
return await networkLoadApi.parseAsync(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
private getChannel() {
|
private getChannel() {
|
||||||
return createChannelEventHistory<z.infer<typeof cpuLoadPerCoreApiList>>(
|
return createChannelEventHistoryOld<z.infer<typeof cpuLoadPerCoreApiList>>(
|
||||||
`integration:${this.integration.id}:history:cpu`,
|
`integration:${this.integration.id}:history:cpu`,
|
||||||
100,
|
100,
|
||||||
);
|
);
|
||||||
@@ -130,6 +137,11 @@ const memoryLoadApi = z.object({
|
|||||||
load: z.number().min(0),
|
load: z.number().min(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const networkLoadApi = z.object({
|
||||||
|
up: z.number().min(0),
|
||||||
|
down: z.number().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
const internalServerInfoApi = z.object({
|
const internalServerInfoApi = z.object({
|
||||||
os: z.object({
|
os: z.object({
|
||||||
distro: z.string(),
|
distro: z.string(),
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ export interface SystemHealthMonitoring {
|
|||||||
version: string;
|
version: string;
|
||||||
cpuModelName: string;
|
cpuModelName: string;
|
||||||
cpuUtilization: number;
|
cpuUtilization: number;
|
||||||
memUsed: string;
|
memUsedInBytes: number;
|
||||||
memAvailable: string;
|
memAvailableInBytes: number;
|
||||||
uptime: number;
|
uptime: number;
|
||||||
|
network: {
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
} | null;
|
||||||
loadAverage: {
|
loadAverage: {
|
||||||
"1min": number;
|
"1min": number;
|
||||||
"5min": number;
|
"5min": number;
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ export class SystemHealthMonitoringMockService implements ISystemHealthMonitorin
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
cpuModelName: "Mock CPU",
|
cpuModelName: "Mock CPU",
|
||||||
cpuUtilization: Math.random(),
|
cpuUtilization: Math.random(),
|
||||||
memUsed: (4 * 1024 * 1024 * 1024).toString(), // 4 GB in bytes
|
memUsedInBytes: 4 * 1024 * 1024 * 1024, // 4 GB in bytes
|
||||||
memAvailable: (8 * 1024 * 1024 * 1024).toString(), // 8 GB in bytes
|
memAvailableInBytes: 8 * 1024 * 1024 * 1024, // 8 GB in bytes
|
||||||
availablePkgUpdates: 0,
|
availablePkgUpdates: 0,
|
||||||
|
network: {
|
||||||
|
up: 1024 * 16,
|
||||||
|
down: 1024 * 16 * 6,
|
||||||
|
},
|
||||||
rebootRequired: false,
|
rebootRequired: false,
|
||||||
cpuTemp: Math.floor(Math.random() * 100), // Random temperature between 0 and 99
|
cpuTemp: Math.floor(Math.random() * 100), // Random temperature between 0 and 99
|
||||||
uptime: Math.floor(Math.random() * 1000000), // Random uptime in seconds
|
uptime: Math.floor(Math.random() * 1000000), // Random uptime in seconds
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea
|
|||||||
version: systemResult.data.response.version,
|
version: systemResult.data.response.version,
|
||||||
cpuModelName: systemResult.data.response.cpuModelName ?? "Unknown CPU",
|
cpuModelName: systemResult.data.response.cpuModelName ?? "Unknown CPU",
|
||||||
cpuUtilization: systemResult.data.response.cpuUtilization,
|
cpuUtilization: systemResult.data.response.cpuUtilization,
|
||||||
memUsed: systemResult.data.response.memUsed,
|
memUsedInBytes: Number(systemResult.data.response.memUsed),
|
||||||
memAvailable: systemResult.data.response.memAvailable,
|
memAvailableInBytes: Number(systemResult.data.response.memAvailable),
|
||||||
uptime: systemResult.data.response.uptime,
|
uptime: systemResult.data.response.uptime,
|
||||||
|
/* real-time traffic monitoring is not available over the RPC API from OMV */
|
||||||
|
network: null,
|
||||||
loadAverage: {
|
loadAverage: {
|
||||||
"1min": systemResult.data.response.loadAverage["1min"],
|
"1min": systemResult.data.response.loadAverage["1min"],
|
||||||
"5min": systemResult.data.response.loadAverage["5min"],
|
"5min": systemResult.data.response.loadAverage["5min"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||||
import { createChannelEventHistory } from "@homarr/redis";
|
|
||||||
|
|
||||||
|
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
|
||||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
@@ -64,7 +64,7 @@ export class OPNsenseIntegration extends Integration implements FirewallSummaryI
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getInterfacesChannel() {
|
private getInterfacesChannel() {
|
||||||
return createChannelEventHistory<FirewallInterface[]>(`integration:${this.integration.id}:interfaces`, 15);
|
return createChannelEventHistoryOld<FirewallInterface[]>(`integration:${this.integration.id}:interfaces`, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]> {
|
public async getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]> {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export {
|
|||||||
createGetSetChannel,
|
createGetSetChannel,
|
||||||
} from "./lib/channel";
|
} from "./lib/channel";
|
||||||
|
|
||||||
|
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
||||||
|
|
||||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||||
export const pingChannel = createSubPubChannel<
|
export const pingChannel = createSubPubChannel<
|
||||||
{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }
|
{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }
|
||||||
|
|||||||
@@ -226,7 +226,48 @@ export const createItemChannel = <TData>(itemId: string) => {
|
|||||||
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createChannelEventHistory = <TData>(channelName: string, maxElements = 15) => {
|
export const createChannelEventHistory = <TData>(channelName: string, maxElements = 32) => {
|
||||||
|
return {
|
||||||
|
subscribe: (callback: (data: TData) => void) => {
|
||||||
|
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||||
|
callback(superjson.parse(message));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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 getSetClient.ltrim(channelName, 0, maxElements);
|
||||||
|
},
|
||||||
|
clearAsync: async () => {
|
||||||
|
await getSetClient.del(channelName);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 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 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);
|
||||||
|
},
|
||||||
|
name: channelName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated This function should no longer be used, see history-channel functions.
|
||||||
|
*/
|
||||||
|
export const createChannelEventHistoryOld = <TData>(channelName: string, maxElements = 15) => {
|
||||||
const popElementsOverMaxAsync = async () => {
|
const popElementsOverMaxAsync = async () => {
|
||||||
const length = await getSetClient.llen(channelName);
|
const length = await getSetClient.llen(channelName);
|
||||||
if (length <= maxElements) {
|
if (length <= maxElements) {
|
||||||
|
|||||||
6
packages/redis/src/lib/channels/history-channel.ts
Normal file
6
packages/redis/src/lib/channels/history-channel.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createChannelEventHistory } from "../channel";
|
||||||
|
|
||||||
|
export const createIntegrationHistoryChannel = <TData>(integrationId: string, queryKey: string, maxElements = 32) => {
|
||||||
|
const channelName = `integration:${integrationId}:history:${queryKey}`;
|
||||||
|
return createChannelEventHistory<TData>(channelName, maxElements);
|
||||||
|
};
|
||||||
@@ -2447,6 +2447,18 @@
|
|||||||
"description": "Display notification history from an integration",
|
"description": "Display notification history from an integration",
|
||||||
"noItems": "No notifications to display.",
|
"noItems": "No notifications to display.",
|
||||||
"option": {}
|
"option": {}
|
||||||
|
},
|
||||||
|
"systemResources": {
|
||||||
|
"name": "System resources",
|
||||||
|
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
||||||
|
"option": {},
|
||||||
|
"card": {
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "MEM",
|
||||||
|
"network": "NET",
|
||||||
|
"up": "UP",
|
||||||
|
"down": "DOWN"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgetPreview": {
|
"widgetPreview": {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
@import "@mantine/core/styles.css";
|
@import "@mantine/core/styles.css";
|
||||||
|
@import "@mantine/charts/styles.css";
|
||||||
@import "@mantine/dates/styles.css";
|
@import "@mantine/dates/styles.css";
|
||||||
@import "mantine-react-table/styles.css";
|
@import "mantine-react-table/styles.css";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IconBrain } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import { progressColor } from "../system-health";
|
import { progressColor } from "../system-health";
|
||||||
|
|
||||||
export const MemoryRing = ({ available, used, isTiny }: { available: string; used: string; isTiny: boolean }) => {
|
export const MemoryRing = ({ available, used, isTiny }: { available: number; used: number; isTiny: boolean }) => {
|
||||||
const memoryUsage = formatMemoryUsage(available, used);
|
const memoryUsage = formatMemoryUsage(available, used);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,14 +31,12 @@ export const MemoryRing = ({ available, used, isTiny }: { available: string; use
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
|
export const formatMemoryUsage = (memFree: number, memUsed: number) => {
|
||||||
const memFreeBytes = Number(memFree);
|
const totalMemory = memFree + memUsed;
|
||||||
const memUsedBytes = Number(memUsed);
|
const memFreeGB = (memFree / 1024 ** 3).toFixed(2);
|
||||||
const totalMemory = memFreeBytes + memUsedBytes;
|
const memUsedGB = (memUsed / 1024 ** 3).toFixed(2);
|
||||||
const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2);
|
const memFreePercent = Math.round((memFree / totalMemory) * 100);
|
||||||
const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2);
|
const memUsedPercent = Math.round((memUsed / totalMemory) * 100);
|
||||||
const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100);
|
|
||||||
const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100);
|
|
||||||
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
|
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export const SystemHealthMonitoring = ({
|
|||||||
<Stack h="100%" gap="sm" className="health-monitoring">
|
<Stack h="100%" gap="sm" className="health-monitoring">
|
||||||
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
||||||
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
||||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
gap="sm"
|
gap="sm"
|
||||||
@@ -176,7 +176,11 @@ export const SystemHealthMonitoring = ({
|
|||||||
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
|
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
|
||||||
)}
|
)}
|
||||||
{options.memory && (
|
{options.memory && (
|
||||||
<MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} isTiny={isTiny} />
|
<MemoryRing
|
||||||
|
available={healthInfo.memAvailableInBytes}
|
||||||
|
used={healthInfo.memUsedInBytes}
|
||||||
|
isTiny={isTiny}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import * as rssFeed from "./rssFeed";
|
|||||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||||
import * as stockPrice from "./stocks";
|
import * as stockPrice from "./stocks";
|
||||||
|
import * as systemResources from "./system-resources";
|
||||||
import * as video from "./video";
|
import * as video from "./video";
|
||||||
import * as weather from "./weather";
|
import * as weather from "./weather";
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ export const widgetImports = {
|
|||||||
firewall,
|
firewall,
|
||||||
notifications,
|
notifications,
|
||||||
mediaReleases,
|
mediaReleases,
|
||||||
|
systemResources,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { humanFileSize } from "@homarr/common";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { CommonChart } from "./common-chart";
|
||||||
|
|
||||||
|
export const CombinedNetworkTrafficChart = ({
|
||||||
|
usageOverTime,
|
||||||
|
}: {
|
||||||
|
usageOverTime: {
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
}[];
|
||||||
|
}) => {
|
||||||
|
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
|
||||||
|
const t = useScopedI18n("widget.systemResources.card");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonChart
|
||||||
|
data={chartData}
|
||||||
|
dataKey={"index"}
|
||||||
|
series={[
|
||||||
|
{ name: "up", color: "orange.5" },
|
||||||
|
{ name: "down", color: "yellow.5" },
|
||||||
|
]}
|
||||||
|
title={t("network")}
|
||||||
|
yAxisProps={{ domain: [0, "dataMax"] }}
|
||||||
|
tooltipProps={{
|
||||||
|
content: ({ payload }) => {
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Paper px={3} py={2} withBorder shadow="md" radius="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
{payload.map((payloadData) => (
|
||||||
|
<Group key={payloadData.key} gap={4}>
|
||||||
|
<Box bg={payloadData.color} w={10} h={10} style={{ borderRadius: 99 }}></Box>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{payloadData.value === undefined ? (
|
||||||
|
<>N/A</>
|
||||||
|
) : (
|
||||||
|
<>{humanFileSize(Math.round(payloadData.value))}/s</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
packages/widgets/src/system-resources/chart/common-chart.tsx
Normal file
97
packages/widgets/src/system-resources/chart/common-chart.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import type { LineChartSeries } from "@mantine/charts";
|
||||||
|
import { LineChart } from "@mantine/charts";
|
||||||
|
import { Card, Center, Group, Loader, Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core";
|
||||||
|
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||||
|
import type { TooltipProps, YAxisProps } from "recharts";
|
||||||
|
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
|
export const CommonChart = ({
|
||||||
|
data,
|
||||||
|
dataKey,
|
||||||
|
series,
|
||||||
|
title,
|
||||||
|
tooltipProps,
|
||||||
|
yAxisProps,
|
||||||
|
lastValue,
|
||||||
|
}: {
|
||||||
|
data: Record<string, any>[];
|
||||||
|
dataKey: string;
|
||||||
|
series: LineChartSeries[];
|
||||||
|
title: string;
|
||||||
|
tooltipProps?: TooltipProps<number, any>;
|
||||||
|
yAxisProps?: Omit<YAxisProps, "ref">;
|
||||||
|
lastValue?: string;
|
||||||
|
}) => {
|
||||||
|
const { ref: elementSizeRef, height } = useElementSize();
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const scheme = useMantineColorScheme();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const { hovered, ref: hoverRef } = useHover();
|
||||||
|
const ref = useMergedRef(elementSizeRef, hoverRef);
|
||||||
|
|
||||||
|
const opacity = board.opacity / 100;
|
||||||
|
const backgroundColor =
|
||||||
|
scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={ref}
|
||||||
|
h={"100%"}
|
||||||
|
pos={"relative"}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
p={0}
|
||||||
|
bg={data.length <= 1 ? backgroundColor : undefined}
|
||||||
|
radius={board.itemRadius}
|
||||||
|
>
|
||||||
|
{data.length > 1 && height > 40 && !hovered && (
|
||||||
|
<Group
|
||||||
|
pos={"absolute"}
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
p={8}
|
||||||
|
pt={6}
|
||||||
|
gap={5}
|
||||||
|
wrap={"nowrap"}
|
||||||
|
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||||
|
>
|
||||||
|
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{lastValue && (
|
||||||
|
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
|
||||||
|
{lastValue}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{data.length <= 1 ? (
|
||||||
|
<Center pos="absolute" w="100%" h="100%">
|
||||||
|
<Stack px={"xs"} align={"center"}>
|
||||||
|
<Loader type="bars" size={height > 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} />
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<LineChart
|
||||||
|
data={data}
|
||||||
|
dataKey={dataKey}
|
||||||
|
h={"100%"}
|
||||||
|
series={series}
|
||||||
|
curveType="monotone"
|
||||||
|
tickLine="none"
|
||||||
|
gridAxis="none"
|
||||||
|
withXAxis={false}
|
||||||
|
withYAxis={false}
|
||||||
|
withDots={false}
|
||||||
|
bg={backgroundColor}
|
||||||
|
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
|
||||||
|
tooltipAnimationDuration={200}
|
||||||
|
tooltipProps={tooltipProps}
|
||||||
|
withTooltip={height >= 64}
|
||||||
|
yAxisProps={yAxisProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
packages/widgets/src/system-resources/chart/cpu-chart.tsx
Normal file
39
packages/widgets/src/system-resources/chart/cpu-chart.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Paper, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { CommonChart } from "./common-chart";
|
||||||
|
|
||||||
|
export const SystemResourceCPUChart = ({ cpuUsageOverTime }: { cpuUsageOverTime: number[] }) => {
|
||||||
|
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||||
|
const t = useScopedI18n("widget.systemResources.card");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonChart
|
||||||
|
data={chartData}
|
||||||
|
dataKey={"index"}
|
||||||
|
series={[{ name: "usage", color: "blue.5" }]}
|
||||||
|
title={t("cpu")}
|
||||||
|
lastValue={
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined
|
||||||
|
}
|
||||||
|
yAxisProps={{ domain: [0, 100] }}
|
||||||
|
tooltipProps={{
|
||||||
|
content: ({ payload }) => {
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||||
|
return (
|
||||||
|
<Paper px={3} py={2} withBorder shadow="md" radius="md">
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{value.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
50
packages/widgets/src/system-resources/chart/memory-chart.tsx
Normal file
50
packages/widgets/src/system-resources/chart/memory-chart.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Paper, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { humanFileSize } from "@homarr/common";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { CommonChart } from "./common-chart";
|
||||||
|
|
||||||
|
export const SystemResourceMemoryChart = ({
|
||||||
|
memoryUsageOverTime,
|
||||||
|
totalCapacityInBytes,
|
||||||
|
}: {
|
||||||
|
memoryUsageOverTime: number[];
|
||||||
|
totalCapacityInBytes: number;
|
||||||
|
}) => {
|
||||||
|
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||||
|
const t = useScopedI18n("widget.systemResources.card");
|
||||||
|
|
||||||
|
const percentageUsed =
|
||||||
|
memoryUsageOverTime.length > 0
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonChart
|
||||||
|
data={chartData}
|
||||||
|
dataKey={"index"}
|
||||||
|
series={[{ name: "usage", color: "red.6" }]}
|
||||||
|
title={t("memory")}
|
||||||
|
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
|
||||||
|
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
|
||||||
|
tooltipProps={{
|
||||||
|
content: ({ payload }) => {
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||||
|
return (
|
||||||
|
<Paper px={3} py={2} withBorder shadow="md" radius="md">
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{humanFileSize(value)} / {humanFileSize(totalCapacityInBytes)} (
|
||||||
|
{Math.round((value / totalCapacityInBytes) * 100)}%)
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Paper, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { humanFileSize } from "@homarr/common";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { CommonChart } from "./common-chart";
|
||||||
|
|
||||||
|
export const NetworkTrafficChart = ({ usageOverTime, isUp }: { usageOverTime: number[]; isUp: boolean }) => {
|
||||||
|
const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
|
||||||
|
const t = useScopedI18n("widget.systemResources.card");
|
||||||
|
|
||||||
|
const max = Math.max(...usageOverTime);
|
||||||
|
const upperBound = max + max * 0.2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonChart
|
||||||
|
data={chartData}
|
||||||
|
dataKey={"index"}
|
||||||
|
series={[{ name: "usage", color: "yellow.5" }]}
|
||||||
|
title={isUp ? t("up") : t("down")}
|
||||||
|
yAxisProps={{ domain: [0, upperBound] }}
|
||||||
|
lastValue={`${humanFileSize(Math.round(max))}/s`}
|
||||||
|
tooltipProps={{
|
||||||
|
content: ({ payload }) => {
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||||
|
return (
|
||||||
|
<Paper px={3} py={2} withBorder shadow="md" radius="md">
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{humanFileSize(Math.round(value))}/s
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
14
packages/widgets/src/system-resources/component.module.css
Normal file
14
packages/widgets/src/system-resources/component.module.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colSpanWide {
|
||||||
|
grid-column-start: 1;
|
||||||
|
grid-column-end: 3;
|
||||||
|
}
|
||||||
82
packages/widgets/src/system-resources/component.tsx
Normal file
82
packages/widgets/src/system-resources/component.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
|
||||||
|
import { SystemResourceCPUChart } from "./chart/cpu-chart";
|
||||||
|
import { SystemResourceMemoryChart } from "./chart/memory-chart";
|
||||||
|
import { NetworkTrafficChart } from "./chart/network-traffic";
|
||||||
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
|
const MAX_QUEUE_SIZE = 15;
|
||||||
|
|
||||||
|
export default function SystemResources({ integrationIds }: WidgetComponentProps<"systemResources">) {
|
||||||
|
const { ref, width } = useElementSize();
|
||||||
|
|
||||||
|
const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery({
|
||||||
|
integrationIds,
|
||||||
|
});
|
||||||
|
const memoryCapacityInBytes =
|
||||||
|
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
|
||||||
|
const [items, setItems] = useState<{ cpu: number; memory: number; network: { up: number; down: number } | null }[]>(
|
||||||
|
data.map((item) => ({
|
||||||
|
cpu: item.healthInfo.cpuUtilization,
|
||||||
|
memory: item.healthInfo.memUsedInBytes,
|
||||||
|
network: item.healthInfo.network,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
|
||||||
|
{
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onData(data) {
|
||||||
|
setItems((previousItems) => {
|
||||||
|
const next = {
|
||||||
|
cpu: data.healthInfo.cpuUtilization,
|
||||||
|
memory: data.healthInfo.memUsedInBytes,
|
||||||
|
network: data.healthInfo.network,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...previousItems, next].slice(-MAX_QUEUE_SIZE);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const showNetwork = items.length === 0 || items.every((item) => item.network !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={classes.grid}>
|
||||||
|
<div className={classes.colSpanWide}>
|
||||||
|
<SystemResourceCPUChart cpuUsageOverTime={items.map((item) => item.cpu)} />
|
||||||
|
</div>
|
||||||
|
<div className={classes.colSpanWide}>
|
||||||
|
<SystemResourceMemoryChart
|
||||||
|
memoryUsageOverTime={items.map((item) => item.memory)}
|
||||||
|
totalCapacityInBytes={memoryCapacityInBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showNetwork &&
|
||||||
|
(width > 200 ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
|
||||||
|
<NetworkTrafficChart usageOverTime={items.map((item) => item.network!.down)} isUp={false} />
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
|
||||||
|
<NetworkTrafficChart usageOverTime={items.map((item) => item.network!.up)} isUp />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={classes.colSpanWide}>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
|
||||||
|
<CombinedNetworkTrafficChart usageOverTime={items.map((item) => item.network!)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/widgets/src/system-resources/index.ts
Normal file
12
packages/widgets/src/system-resources/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IconGraphFilled } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
|
||||||
|
icon: IconGraphFilled,
|
||||||
|
supportedIntegrations: ["dashDot", "openmediavault"],
|
||||||
|
createOptions() {
|
||||||
|
return optionsBuilder.from(() => ({}));
|
||||||
|
},
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
Reference in New Issue
Block a user