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