From e9da602af6ee5293d39177cf07b2de19bfdb0d64 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 16 Jan 2026 19:07:49 -0500 Subject: [PATCH] feat: Add Proxmox LXC container monitoring support ## Summary Add support for monitoring Proxmox LXC containers through the Capture agent's /api/v1/metrics/proxmox endpoint. This feature is opt-in via a new setting in the infrastructure monitor configuration. ## Backend Changes - Add enableProxmoxContainers field to Monitor schema and types - Update networkService to conditionally fetch Proxmox metrics - Handle HTTP 207 multi-status responses and merge errors - Add containers field to Check schema for storing container data - Update validation schemas for the new setting ## Frontend Changes - Add ContainerStats component with charts and stat boxes - Add Containers tab to infrastructure monitor details (conditional) - Add enableProxmoxContainers switch in monitor create/edit form - Fix chart colors for disk I/O and network I/O to be distinct - Add dotColor2 prop to InfrastructureTooltip for multi-series charts - Add translation keys to all 16 locale files ## How It Works 1. User enables "Proxmox container monitoring" in monitor settings 2. Checkmate appends /proxmox to the metrics URL when fetching 3. Container data is displayed in a dedicated tab with: - CPU, memory, disk, swap, and uptime stat boxes - Interactive charts for CPU, memory, disk I/O, network I/O 4. Errors from Capture are merged and logged appropriately --- .../Components/v1/Charts/Utils/chartUtils.jsx | 38 ++++- .../hooks/useInfrastructureMonitorForm.jsx | 3 + .../Create/hooks/useInfrastructureSubmit.jsx | 3 + .../src/Pages/Infrastructure/Create/index.jsx | 30 +++- .../ContainerStats/ContainerCharts.jsx | 141 ++++++++++++++++++ .../ContainerStats/ContainerStatBoxes.jsx | 130 ++++++++++++++++ .../Components/ContainerStats/index.jsx | 125 ++++++++++++++++ .../Pages/Infrastructure/Details/index.jsx | 16 ++ client/src/Validation/validation.js | 1 + client/src/locales/ar.json | 29 +++- client/src/locales/cs.json | 29 +++- client/src/locales/de.json | 5 +- client/src/locales/en.json | 3 + client/src/locales/es.json | 29 +++- client/src/locales/fi.json | 29 +++- client/src/locales/fr.json | 5 +- client/src/locales/ja.json | 25 +++- client/src/locales/pt-BR.json | 5 +- client/src/locales/ru.json | 5 +- client/src/locales/th.json | 5 +- client/src/locales/tr.json | 29 +++- client/src/locales/uk.json | 9 +- client/src/locales/vi.json | 9 +- client/src/locales/zh-CN.json | 17 ++- client/src/locales/zh-TW.json | 29 +++- server/src/db/models/Check.ts | 30 ++++ server/src/db/models/Monitor.ts | 4 + .../checks/MongoChecksRepistory.ts | 104 +++++++++++++ .../monitors/MongoMonitorsRepository.ts | 1 + server/src/service/business/checkService.ts | 3 +- .../service/infrastructure/networkService.ts | 126 +++++++++++++++- server/src/types/check.ts | 23 +++ server/src/types/monitor.ts | 1 + server/src/types/network.ts | 2 + server/src/validation/joi.js | 2 + 35 files changed, 981 insertions(+), 64 deletions(-) create mode 100644 client/src/Pages/Infrastructure/Details/Components/ContainerStats/ContainerCharts.jsx create mode 100644 client/src/Pages/Infrastructure/Details/Components/ContainerStats/ContainerStatBoxes.jsx create mode 100644 client/src/Pages/Infrastructure/Details/Components/ContainerStats/index.jsx diff --git a/client/src/Components/v1/Charts/Utils/chartUtils.jsx b/client/src/Components/v1/Charts/Utils/chartUtils.jsx index 1bab80595..10085116a 100644 --- a/client/src/Components/v1/Charts/Utils/chartUtils.jsx +++ b/client/src/Components/v1/Charts/Utils/chartUtils.jsx @@ -154,7 +154,10 @@ export const InfrastructureTooltip = ({ yKey, yIdx = -1, yLabel, + yKey2, + yLabel2, dotColor, + dotColor2, dateRange, formatter = getFormattedPercentage, }) => { @@ -216,7 +219,37 @@ export const InfrastructureTooltip = ({ - {/* Display original value */} + {yKey2 && yLabel2 && ( + + + + + {`${yLabel2} ${formatter(payload[0].payload[yKey2])}`} + + + + )} ); } @@ -234,7 +267,10 @@ InfrastructureTooltip.propTypes = { yKey: PropTypes.string, yIdx: PropTypes.number, yLabel: PropTypes.string, + yKey2: PropTypes.string, + yLabel2: PropTypes.string, dotColor: PropTypes.string, + dotColor2: PropTypes.string, dateRange: PropTypes.string, }; diff --git a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx index a38ac9fc6..8ff6c3a2e 100644 --- a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx +++ b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx @@ -18,6 +18,7 @@ const useInfrastructureMonitorForm = () => { usage_temperature: "", secret: "", selectedDisks: [], + enableProxmoxContainers: false, }); const onChangeForm = (name, value) => { @@ -50,6 +51,7 @@ const useInfrastructureMonitorForm = () => { usage_temperature: gt.temperature !== undefined ? gt.temperature.toString() : "", secret: "", selectedDisks: [], + enableProxmoxContainers: false, })); }, []); @@ -84,6 +86,7 @@ const useInfrastructureMonitorForm = () => { : "", secret: monitor.secret || "", selectedDisks: monitor.selectedDisks || [], + enableProxmoxContainers: monitor.enableProxmoxContainers ?? false, })); }, []); return { diff --git a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx index 04279cac4..cbfba9bb9 100644 --- a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx +++ b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx @@ -31,6 +31,7 @@ const useInfrastructureSubmit = () => { : {}), secret: infrastructureMonitor.secret, selectedDisks: infrastructureMonitor.selectedDisks, + enableProxmoxContainers: infrastructureMonitor.enableProxmoxContainers, }; return form; }; @@ -50,6 +51,7 @@ const useInfrastructureSubmit = () => { temperature, usage_temperature, selectedDisks, + enableProxmoxContainers, ...rest } = form; @@ -67,6 +69,7 @@ const useInfrastructureSubmit = () => { type: "hardware", notifications: infrastructureMonitor.notifications, selectedDisks, + enableProxmoxContainers, thresholds, }; // Handle create or update diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index 21d70720d..c424ea969 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -5,7 +5,7 @@ import FieldWrapper from "@/Components/v1/Inputs/FieldWrapper/index.jsx"; import Link from "@/Components/v1/Link/index.jsx"; import Select from "@/Components/v1/Inputs/Select/index.jsx"; import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material"; +import { Box, Stack, Typography, Button, ButtonGroup, Switch, FormControlLabel } from "@mui/material"; import { HttpAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; import MonitorStatusHeader from "./Components/MonitorStatusHeader.jsx"; import MonitorActionButtons from "./Components/MonitorActionButtons.jsx"; @@ -368,6 +368,34 @@ const CreateInfrastructureMonitor = () => { /> )} + + + + {t("proxmoxContainerMonitoring")} + + + {t("proxmoxContainerMonitoringDescription")} + + + + + } + label={t("enableProxmoxContainers")} + /> + + + { + const theme = useTheme(); + const { t } = useTranslation(); + + const { formatBytesPerSecondString } = useHardwareUtils(); + + if (!containerData?.length) { + return {t("noContainerStatsAvailable")}; + } + + const formatPercentage = (value) => `${(value * 100).toFixed(1)}%`; + + const configs = [ + { + type: "container-cpu", + data: containerData, + dataKeys: ["cpuUsage"], + heading: t("cpuUsage"), + strokeColor: theme.palette.success.main, + gradientStartColor: theme.palette.success.main, + yLabel: t("usage"), + xTick: , + yTick: , + toolTip: ( + + ), + }, + { + type: "container-memory", + data: containerData, + dataKeys: ["memoryUsage"], + heading: t("memoryUsage"), + strokeColor: theme.palette.accent.main, + gradientStartColor: theme.palette.accent.main, + yLabel: t("usage"), + xTick: , + yTick: , + toolTip: ( + + ), + }, + { + type: "container-disk-io", + data: containerData, + dataKeys: ["diskRead", "diskWrite"], + heading: t("diskIO"), + // Don't pass strokeColor - let chart use distinct colors from palette + yLabel: t("rate"), + xTick: , + yTick: , + toolTip: ( + + ), + }, + { + type: "container-network-io", + data: containerData, + dataKeys: ["netIn", "netOut"], + heading: t("networkIO"), + // Don't pass strokeColor - let chart use distinct colors from palette + yLabel: t("rate"), + xTick: , + yTick: , + toolTip: ( + + ), + }, + ]; + + return ( + *": { + flexBasis: `calc(50% - ${theme.spacing(8)})`, + maxWidth: `calc(50% - ${theme.spacing(8)})`, + }, + }} + > + {configs.map((config) => ( + + ))} + + ); +}; + +ContainerCharts.propTypes = { + containerData: PropTypes.array.isRequired, + dateRange: PropTypes.string.isRequired, +}; + +export default ContainerCharts; diff --git a/client/src/Pages/Infrastructure/Details/Components/ContainerStats/ContainerStatBoxes.jsx b/client/src/Pages/Infrastructure/Details/Components/ContainerStats/ContainerStatBoxes.jsx new file mode 100644 index 000000000..b15d9ce97 --- /dev/null +++ b/client/src/Pages/Infrastructure/Details/Components/ContainerStats/ContainerStatBoxes.jsx @@ -0,0 +1,130 @@ +import PropTypes from "prop-types"; +import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx"; +import StatBox from "@/Components/v1/StatBox/index.jsx"; +import { Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx"; + +const formatUptime = (seconds) => { + if (!seconds || seconds === 0) return "0s"; + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else if (minutes > 0) { + return `${minutes}m`; + } + return `${Math.floor(seconds)}s`; +}; + +const formatPercentage = (value) => { + if (value === undefined || value === null) return "0%"; + return `${(value * 100).toFixed(1)}%`; +}; + +// Map container status to Checkmate monitor status for theming +const containerStatusToMonitorStatus = (status) => { + if (status === "running") return "up"; + if (status === "stopped") return "down"; + return "pending"; +}; + +const ContainerStatBoxes = ({ shouldRender, container }) => { + const { t } = useTranslation(); + const { formatBytes } = useHardwareUtils(); + + if (!container) { + return {t("noContainerSelected")}; + } + + const monitorStatus = containerStatusToMonitorStatus(container.status); + + return ( + + + + {(container.type || "lxc").toUpperCase()} + + } + /> + + + {formatPercentage(container.cpu_usage)} + + ({container.cpu_cores || 0} {t("cores")}) + + + } + /> + + {formatBytes(container.memory_used)} + / + {formatBytes(container.memory_total)} + + } + /> + + {formatBytes(container.disk_used)} + / + {formatBytes(container.disk_total)} + + } + /> + + ); +}; + +ContainerStatBoxes.propTypes = { + shouldRender: PropTypes.bool.isRequired, + container: PropTypes.shape({ + vmid: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + name: PropTypes.string, + node: PropTypes.string, + status: PropTypes.string, + type: PropTypes.string, + uptime: PropTypes.number, + cpu_cores: PropTypes.number, + cpu_usage: PropTypes.number, + memory_used: PropTypes.number, + memory_total: PropTypes.number, + memory_usage: PropTypes.number, + swap_used: PropTypes.number, + swap_total: PropTypes.number, + disk_used: PropTypes.number, + disk_total: PropTypes.number, + disk_read: PropTypes.number, + disk_write: PropTypes.number, + network_in: PropTypes.number, + network_out: PropTypes.number, + }), +}; + +export default ContainerStatBoxes; diff --git a/client/src/Pages/Infrastructure/Details/Components/ContainerStats/index.jsx b/client/src/Pages/Infrastructure/Details/Components/ContainerStats/index.jsx new file mode 100644 index 000000000..dbece6aa5 --- /dev/null +++ b/client/src/Pages/Infrastructure/Details/Components/ContainerStats/index.jsx @@ -0,0 +1,125 @@ +import PropTypes from "prop-types"; +import { useState, useEffect } from "react"; +import { Box, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@emotion/react"; +import Select from "@/Components/v1/Inputs/Select/index.jsx"; +import ContainerStatBoxes from "./ContainerStatBoxes.jsx"; +import ContainerCharts from "./ContainerCharts.jsx"; +import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx"; + +const getAvailableContainers = (containers) => { + return (containers || []) + .filter((c) => c.vmid && c.name) + .map((c) => ({ + vmid: String(c.vmid), + name: c.name, + node: c.node, + status: c.status, + type: c.type, + })); +}; + +const getContainerData = (checks, vmid) => { + if (!vmid) return []; + + return (checks || []) + .map((check) => { + const container = (check.containers || []).find( + (c) => String(c.vmid) === String(vmid) + ); + if (!container) return null; + return { + _id: check._id, + cpuUsage: container.avgCpuUsage ?? 0, + memoryUsage: container.avgMemoryUsage ?? 0, + diskRead: container.deltaDiskRead ?? 0, + diskWrite: container.deltaDiskWrite ?? 0, + netIn: container.deltaNetIn ?? 0, + netOut: container.deltaNetOut ?? 0, + }; + }) + .filter(Boolean); +}; + +const ContainerStats = ({ containers, checks, isLoading, dateRange, setDateRange }) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const availableContainers = getAvailableContainers(containers); + const [selectedContainer, setSelectedContainer] = useState(""); + + useEffect(() => { + if (availableContainers.length > 0 && !selectedContainer) { + setSelectedContainer(availableContainers[0].vmid); + } + }, [availableContainers, selectedContainer]); + + const containerData = getContainerData(checks, selectedContainer); + const currentContainer = containers?.find( + (c) => String(c.vmid) === String(selectedContainer) + ); + + if (!containers?.length) { + return ( + + + {t("noContainersAvailable")} + + + ); + } + + return ( + <> + + + {availableContainers.length > 0 && ( +