Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Prometheus Metric service widget #4269

Merged
merged 3 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/widgets/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions docs/widgets/services/prometheusmetric.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/utils/config/service-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export function cleanServiceGroups(groups) {
pointsLimit,
diskUnits,

// glances, customapi, iframe
// glances, customapi, iframe, prometheusmetric
refreshInterval,

// hdhomerun
Expand Down Expand Up @@ -461,6 +461,9 @@ export function cleanServiceGroups(groups) {
// opnsense, pfsense
wan,

// prometheusmetric
metrics,

// proxmox
node,

Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/widgets/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
155 changes: 155 additions & 0 deletions src/widgets/prometheusmetric/component.jsx
Original file line number Diff line number Diff line change
@@ -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;
}
shamoon marked this conversation as resolved.
Show resolved Hide resolved

// 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
}
shamoon marked this conversation as resolved.
Show resolved Hide resolved

// 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.
shamoon marked this conversation as resolved.
Show resolved Hide resolved
// 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);
}
shamoon marked this conversation as resolved.
Show resolved Hide resolved
return [metric.key ?? metric.label, resultData];
}),
);

if (prometheusmetricErrors.length) {
// Only shows first metric query error in the container
return <Container service={service} error={prometheusmetricErrors[0]} />;
}

if (!prometheusmetricData) {
return (
<Container service={service}>
{metrics.slice(0, 4).map((item) => (
<Block label={item.label} key={item.label} />
))}
</Container>
);
}

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 (
<Container service={service}>
{metrics.map((metric) => (
<Block
label={metric.label}
key={metric.key ?? metric.label}
value={formatValue(t, metric, getResultValue(prometheusmetricData.get(metric.key ?? metric.label)))}
/>
))}
</Container>
);
}
16 changes: 16 additions & 0 deletions src/widgets/prometheusmetric/widget.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/widgets/widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -218,6 +219,7 @@ const widgets = {
plex,
portainer,
prometheus,
prometheusmetric,
prowlarr,
proxmox,
pterodactyl,
Expand Down
Loading