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:
Meier Lukas
2025-01-17 13:01:04 +01:00
committed by GitHub
parent a31c6a97e0
commit 3ed46aecbd
22 changed files with 1325 additions and 426 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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} />;
}
};

View File

@@ -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>
);
};

View File

@@ -1,425 +1,61 @@
"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 { ScrollArea, Tabs } from "@mantine/core";
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";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { SystemHealthMonitoring } from "./system-health";
dayjs.extend(duration);
export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
const t = useI18n();
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();
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds);
clientApi.widget.healthMonitoring.subscribeHealthStatus.useSubscription(
{ integrationIds },
{
onData(data) {
utils.widget.healthMonitoring.getHealthStatus.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;
});
},
},
);
const proxmoxIntegrationId = integrations.find((integration) => integration.kind === "proxmox")?.id;
if (!proxmoxIntegrationId) {
return <SystemHealthMonitoring {...props} />;
}
const otherIntegrationIds = integrations
.filter((integration) => integration.kind !== "proxmox")
.map((integration) => integration.id);
if (otherIntegrationIds.length === 0) {
return <ClusterHealthMonitoring {...props} integrationId={proxmoxIntegrationId} />;
}
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>
<ScrollArea
h="100%"
styles={{
viewport: {
'& div[style="min-width: 100%"]': {
display: "flex !important",
height: "100%",
},
},
}}
>
<Tabs defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system">
<b>System</b>
</Tabs.Tab>
<Tabs.Tab value="cluster">
<b>Cluster</b>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel mt="lg" value="system">
<SystemHealthMonitoring {...props} />
</Tabs.Panel>
<Tabs.Panel mt="lg" value="cluster">
<ClusterHealthMonitoring integrationId={proxmoxIntegrationId} {...props} />
</Tabs.Panel>
</Tabs>
</ScrollArea>
);
}
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 },
};
};

View File

@@ -20,6 +20,20 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
fileSystem: factory.switch({
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"),
errors: {

View 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 },
};
};