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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user