From 666d7796035e4bfd1e05a7623a89b61a14e0ef46 Mon Sep 17 00:00:00 2001 From: Felix Cornelius Date: Sat, 9 Nov 2024 19:58:27 +0100 Subject: [PATCH 1/3] Feature: Prometheus Metric service widget --- docs/widgets/services/index.md | 1 + docs/widgets/services/prometheusmetric.md | 67 +++++++++ mkdocs.yml | 1 + src/utils/config/service-helpers.js | 9 +- src/widgets/components.js | 1 + src/widgets/prometheusmetric/component.jsx | 155 +++++++++++++++++++++ src/widgets/prometheusmetric/widget.js | 16 +++ src/widgets/widgets.js | 2 + 8 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 docs/widgets/services/prometheusmetric.md create mode 100644 src/widgets/prometheusmetric/component.jsx create mode 100644 src/widgets/prometheusmetric/widget.js diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md index 83ad0fed0f6..8ea2e9331fa 100644 --- a/docs/widgets/services/index.md +++ b/docs/widgets/services/index.md @@ -98,6 +98,7 @@ You can also find a list of all available service widgets in the sidebar navigat - [Plex](plex.md) - [Portainer](portainer.md) - [Prometheus](prometheus.md) +- [Prometheus Metric](prometheusmetric.md) - [Prowlarr](prowlarr.md) - [Proxmox](proxmox.md) - [Proxmox Backup Server](proxmoxbackupserver.md) diff --git a/docs/widgets/services/prometheusmetric.md b/docs/widgets/services/prometheusmetric.md new file mode 100644 index 00000000000..19397aa7e59 --- /dev/null +++ b/docs/widgets/services/prometheusmetric.md @@ -0,0 +1,67 @@ +--- +title: Prometheus Metric +description: Prometheus Metric Widget Configuration +--- + +Learn more about [Querying Prometheus](https://prometheus.io/docs/prometheus/latest/querying/basics/). + +This widget can show metrics for your service defined by PromQL queries which are requested from a running Prometheus instance. + +Quries can be defined in the `metrics` array of the widget along with a label to be used to present the metric value. You can optionally specify a global `refreshInterval` in milliseconds and/or define the `refreshInterval` per metric. Inside the optional `format` object of a metric various formatting styles and transformations can be applied (see below). + +```yaml +widget: + type: prometheusmetric + url: https://prometheus.host.or.ip + refreshInterval: 10000 # optional - in milliseconds, defaults to 10s + metrics: + - label: Metric 1 + query: alertmanager_alerts{state="active"} + - label: Metric 2 + query: apiserver_storage_size_bytes{node="mynode"} + format: + type: bytes + - label: Metric 3 + query: avg(prometheus_notifications_latency_seconds) + format: + type: number + suffix: s + options: + maximumFractionDigits: 4 + - label: Metric 4 + query: time() + refreshInterval: 1000 # will override global refreshInterval + format: + type: date + scale: 1000 + options: + timeStyle: medium +``` + +## Formatting + +Supported values for `format.type` are `text`, `number`, `percent`, `bytes`, `bits`, `bbytes`, `bbits`, `byterate`, `bibyterate`, `bitrate`, `bibitrate`, `date`, `duration`, `relativeDate`, and `text` which is the default. + +The `dateStyle` and `timeStyle` options of the `date` format are passed directly to [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) and the `style` and `numeric` options of `relativeDate` are passed to [Intl.RelativeTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat). For the `number` format, options of [Intl.NumberFormat](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) can be used, e.g. `maximumFractionDigits` or `minimumFractionDigits`. + +### Data Transformation + +You can manipulate your metric value with the following tools: `scale`, `prefix` and `suffix`, for example: + +```yaml +- query: my_custom_metric{} + label: Metric 1 + format: + type: number + scale: 1000 # multiplies value by a number or fraction string e.g. 1/16 +- query: my_custom_metric{} + label: Metric 2 + format: + type: number + prefix: "$" # prefixes value with given string +- query: my_custom_metric{} + label: Metric 3 + format: + type: number + suffix: "€" # suffixes value with given string +``` diff --git a/mkdocs.yml b/mkdocs.yml index cd46689204b..42abce30152 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -121,6 +121,7 @@ nav: - widgets/services/plex.md - widgets/services/portainer.md - widgets/services/prometheus.md + - widgets/services/prometheusmetric.md - widgets/services/prowlarr.md - widgets/services/proxmox.md - widgets/services/proxmoxbackupserver.md diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 63dfb608209..1566a135b3d 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -418,7 +418,7 @@ export function cleanServiceGroups(groups) { pointsLimit, diskUnits, - // glances, customapi, iframe + // glances, customapi, iframe, prometheusmetric refreshInterval, // hdhomerun @@ -461,6 +461,9 @@ export function cleanServiceGroups(groups) { // opnsense, pfsense wan, + // prometheusmetric + metrics, + // proxmox node, @@ -646,6 +649,10 @@ export function cleanServiceGroups(groups) { if (type === "vikunja") { if (enableTaskList !== undefined) cleanedService.widget.enableTaskList = !!enableTaskList; } + if (type === "prometheusmetric") { + if (metrics) cleanedService.widget.metrics = metrics; + if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; + } } return cleanedService; diff --git a/src/widgets/components.js b/src/widgets/components.js index 50564e4932e..7e6b68e14b9 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -95,6 +95,7 @@ const components = { plex: dynamic(() => import("./plex/component")), portainer: dynamic(() => import("./portainer/component")), prometheus: dynamic(() => import("./prometheus/component")), + prometheusmetric: dynamic(() => import("./prometheusmetric/component")), prowlarr: dynamic(() => import("./prowlarr/component")), proxmox: dynamic(() => import("./proxmox/component")), pterodactyl: dynamic(() => import("./pterodactyl/component")), diff --git a/src/widgets/prometheusmetric/component.jsx b/src/widgets/prometheusmetric/component.jsx new file mode 100644 index 00000000000..81b315ec360 --- /dev/null +++ b/src/widgets/prometheusmetric/component.jsx @@ -0,0 +1,155 @@ +import { useTranslation } from "next-i18next"; + +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +function formatValue(t, metric, rawValue) { + if (!rawValue) return "-"; + + let value = rawValue; + + // Scale the value. Accepts either a number to multiply by or a string + // like "12/345". + const scale = metric?.format?.scale; + if (typeof scale === "number") { + value *= scale; + } else if (typeof scale === "string") { + const parts = scale.split("/"); + const numerator = parts[0] ? parseFloat(parts[0]) : 1; + const denominator = parts[1] ? parseFloat(parts[1]) : 1; + value = (value * numerator) / denominator; + } + + // Format the value using a known type and optional options. + switch (metric?.format?.type) { + case "bytes": + value = t("common.bytes", { value, ...metric.format?.options }); + break; + case "bits": + value = t("common.bits", { value, ...metric.format?.options }); + break; + case "bbytes": + value = t("common.bbytes", { value, ...metric.format?.options }); + break; + case "bbits": + value = t("common.bbits", { value, ...metric.format?.options }); + break; + case "byterate": + value = t("common.byterate", { value, ...metric.format?.options }); + break; + case "bibyterate": + value = t("common.bibyterate", { value, ...metric.format?.options }); + break; + case "bitrate": + value = t("common.bitrate", { value, ...metric.format?.options }); + break; + case "bibitrate": + value = t("common.bibitrate", { value, ...metric.format?.options }); + break; + case "percent": + value = t("common.percent", { value, ...metric.format?.options }); + break; + case "number": + value = t("common.number", { value, ...metric.format?.options }); + break; + case "ms": + value = t("common.ms", { value, ...metric.format?.options }); + break; + case "date": + value = t("common.date", { value, ...metric.format?.options }); + break; + case "duration": + value = t("common.duration", { value, ...metric.format?.options }); + break; + case "relativeDate": + value = t("common.relativeDate", { value, ...metric.format?.options }); + break; + case "text": + default: + // nothing + } + + // Apply fixed prefix. + const prefix = metric?.format?.prefix; + if (prefix) { + value = `${prefix}${value}`; + } + + // Apply fixed suffix. + const suffix = metric?.format?.suffix; + if (suffix) { + value = `${value}${suffix}`; + } + + return value; +} + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + + const { metrics = [], refreshInterval = 10000 } = widget; + + const prometheusmetricErrors = []; + + const prometheusmetricData = new Map( + metrics.slice(0, 4).map((metric) => { + // disable the rule that hooks should not be called from a callback, + // because we don't need a strong guarantee of hook execution order here. + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data: resultData, error: resultError } = useWidgetAPI(widget, "query", { + query: metric.query, + refreshInterval: Math.max(1000, metric.refreshInterval ?? refreshInterval), + }); + if (resultError) { + prometheusmetricErrors.push(resultError); + } + return [metric.key ?? metric.label, resultData]; + }), + ); + + if (prometheusmetricErrors.length) { + // Only shows first metric query error in the container + return ; + } + + if (!prometheusmetricData) { + return ( + + {metrics.slice(0, 4).map((item) => ( + + ))} + + ); + } + + function getResultValue(data) { + // Fetches the first metric result from the Prometheus query result data. + // The first element in the result value is the timestamp which is ignored here. + const resultType = data?.data?.resultType; + const result = data?.data?.result; + + switch (resultType) { + case "vector": + return result?.[0]?.value?.[1]; + case "scalar": + return result?.[1]; + default: + return "" + } + } + + return ( + + {metrics.map((metric) => ( + + ))} + + ); +} diff --git a/src/widgets/prometheusmetric/widget.js b/src/widgets/prometheusmetric/widget.js new file mode 100644 index 00000000000..22137b0d7de --- /dev/null +++ b/src/widgets/prometheusmetric/widget.js @@ -0,0 +1,16 @@ +import genericProxyHandler from "utils/proxy/handlers/generic"; + +const widget = { + api: "{url}/api/v1/{endpoint}", + proxyHandler: genericProxyHandler, + + mappings: { + query: { + method: "GET", + endpoint: "query", + params: ["query"], + }, + }, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 40b121aef8c..4d76fa0707f 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -87,6 +87,7 @@ import plantit from "./plantit/widget"; import plex from "./plex/widget"; import portainer from "./portainer/widget"; import prometheus from "./prometheus/widget"; +import prometheusmetric from "./prometheusmetric/widget"; import prowlarr from "./prowlarr/widget"; import proxmox from "./proxmox/widget"; import pterodactyl from "./pterodactyl/widget"; @@ -218,6 +219,7 @@ const widgets = { plex, portainer, prometheus, + prometheusmetric, prowlarr, proxmox, pterodactyl, From 8bec3b80f4ce19b3feea54aa11ecb84d0f1e9442 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:36:58 -0800 Subject: [PATCH 2/3] Apply changes from code review --- src/widgets/prometheusmetric/component.jsx | 51 +++------------------- 1 file changed, 6 insertions(+), 45 deletions(-) diff --git a/src/widgets/prometheusmetric/component.jsx b/src/widgets/prometheusmetric/component.jsx index 81b315ec360..918dc72fd07 100644 --- a/src/widgets/prometheusmetric/component.jsx +++ b/src/widgets/prometheusmetric/component.jsx @@ -14,60 +14,21 @@ function formatValue(t, metric, rawValue) { const scale = metric?.format?.scale; if (typeof scale === "number") { value *= scale; - } else if (typeof scale === "string") { + } else if (typeof scale === "string" && scale.includes("/")) { const parts = scale.split("/"); const numerator = parts[0] ? parseFloat(parts[0]) : 1; const denominator = parts[1] ? parseFloat(parts[1]) : 1; value = (value * numerator) / denominator; + } else { + value = parseFloat(value); } // Format the value using a known type and optional options. switch (metric?.format?.type) { - case "bytes": - value = t("common.bytes", { value, ...metric.format?.options }); - break; - case "bits": - value = t("common.bits", { value, ...metric.format?.options }); - break; - case "bbytes": - value = t("common.bbytes", { value, ...metric.format?.options }); - break; - case "bbits": - value = t("common.bbits", { value, ...metric.format?.options }); - break; - case "byterate": - value = t("common.byterate", { value, ...metric.format?.options }); - break; - case "bibyterate": - value = t("common.bibyterate", { value, ...metric.format?.options }); - break; - case "bitrate": - value = t("common.bitrate", { value, ...metric.format?.options }); - break; - case "bibitrate": - value = t("common.bibitrate", { value, ...metric.format?.options }); - break; - case "percent": - value = t("common.percent", { value, ...metric.format?.options }); - break; - case "number": - value = t("common.number", { value, ...metric.format?.options }); - break; - case "ms": - value = t("common.ms", { value, ...metric.format?.options }); - break; - case "date": - value = t("common.date", { value, ...metric.format?.options }); - break; - case "duration": - value = t("common.duration", { value, ...metric.format?.options }); - break; - case "relativeDate": - value = t("common.relativeDate", { value, ...metric.format?.options }); - break; case "text": + break; default: - // nothing + value = t(`common.${metric.format.type}`, { value, ...metric.format?.options }); } // Apply fixed prefix. @@ -137,7 +98,7 @@ export default function Component({ service }) { case "scalar": return result?.[1]; default: - return "" + return ""; } } From 99def687d3bab8bfa3637fd1a85f587eb7d30840 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:40:56 -0800 Subject: [PATCH 3/3] And the error handling --- src/widgets/prometheusmetric/component.jsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/widgets/prometheusmetric/component.jsx b/src/widgets/prometheusmetric/component.jsx index 918dc72fd07..347aaa0c21d 100644 --- a/src/widgets/prometheusmetric/component.jsx +++ b/src/widgets/prometheusmetric/component.jsx @@ -53,7 +53,7 @@ export default function Component({ service }) { const { metrics = [], refreshInterval = 10000 } = widget; - const prometheusmetricErrors = []; + let prometheusmetricError; const prometheusmetricData = new Map( metrics.slice(0, 4).map((metric) => { @@ -65,15 +65,14 @@ export default function Component({ service }) { refreshInterval: Math.max(1000, metric.refreshInterval ?? refreshInterval), }); if (resultError) { - prometheusmetricErrors.push(resultError); + prometheusmetricError = resultError; } return [metric.key ?? metric.label, resultData]; }), ); - if (prometheusmetricErrors.length) { - // Only shows first metric query error in the container - return ; + if (prometheusmetricError) { + return ; } if (!prometheusmetricData) {