diff --git a/docs/widgets/services/beszel.md b/docs/widgets/services/beszel.md index 3cc828b9..6a5cc269 100644 --- a/docs/widgets/services/beszel.md +++ b/docs/widgets/services/beszel.md @@ -3,10 +3,12 @@ title: Beszel description: Beszel Widget Configuration --- -Learn more about [Beszel]() +Learn more about [Beszel](https://github.com/henrygd/beszel) The widget has two modes, a single system with detailed info if `systemId` is provided, or an overview of all systems if `systemId` is not provided. +The `systemID` in the `id` field on the collections page of Beszel. + Allowed fields for 'overview' mode: `["systems", "up"]` Allowed fields for a single system: `["name", "status", "updated", "cpu", "memory", "disk", "network"]` diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md index 83ad0fed..8ea2e933 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 00000000..19397aa7 --- /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/docs/widgets/services/suwayomi.md b/docs/widgets/services/suwayomi.md new file mode 100644 index 00000000..21672598 --- /dev/null +++ b/docs/widgets/services/suwayomi.md @@ -0,0 +1,20 @@ +--- +title: Suwayomi +description: Suwayomi Widget Configuration +--- + +Learn more about [Suwayomi](https://github.com/Suwayomi/Suwayomi-Server). + +Allowed fields: ["download", "nondownload", "read", "unread", "downloadedread", "downloadedunread", "nondownloadedread", "nondownloadedunread"] + +The widget defaults to the first four above. If more than four fields are provided, only the first 4 are displayed. +Category IDs can be obtained from the url when navigating to it, `?tab={categoryID}`. + +```yaml +widget: + type: suwayomi + url: http://suwayomi.host.or.ip + username: username #optional + password: password #optional + category: 0 #optional, defaults to all categories +``` diff --git a/mkdocs.yml b/mkdocs.yml index cd466892..42abce30 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/public/locales/en/common.json b/public/locales/en/common.json index bdde0a34..81576984 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -309,6 +309,16 @@ "stopped": "Stopped", "total": "Total" }, + "suwayomi": { + "download": "Downloaded", + "nondownload": "Non-Downloaded", + "read": "Read", + "unread": "Unread", + "downloadedread": "Downloaded & Read", + "downloadedunread": "Downloaded & Unread", + "nondownloadedread": "Non-Downloaded & Read", + "nondownloadedunread": "Non-Downloaded & Unread" + }, "tailscale": { "address": "Address", "expires": "Expires", diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 6ef7f87e..f4ffcc77 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -332,7 +332,7 @@ export function cleanServiceGroups(groups) { pointsLimit, diskUnits, - // glances, customapi, iframe + // glances, customapi, iframe, prometheusmetric refreshInterval, // hdhomerun @@ -375,6 +375,9 @@ export function cleanServiceGroups(groups) { // opnsense, pfsense wan, + // prometheusmetric + metrics, + // proxmox node, @@ -560,6 +563,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 50564e49..3cba84d2 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")), @@ -113,6 +114,7 @@ const components = { stocks: dynamic(() => import("./stocks/component")), strelaysrv: dynamic(() => import("./strelaysrv/component")), swagdashboard: dynamic(() => import("./swagdashboard/component")), + suwayomi: dynamic(() => import("./suwayomi/component")), tailscale: dynamic(() => import("./tailscale/component")), tandoor: dynamic(() => import("./tandoor/component")), tautulli: dynamic(() => import("./tautulli/component")), diff --git a/src/widgets/prometheusmetric/component.jsx b/src/widgets/prometheusmetric/component.jsx new file mode 100644 index 00000000..347aaa0c --- /dev/null +++ b/src/widgets/prometheusmetric/component.jsx @@ -0,0 +1,115 @@ +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" && 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 "text": + break; + default: + value = t(`common.${metric.format.type}`, { value, ...metric.format?.options }); + } + + // 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; + + let prometheusmetricError; + + 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) { + prometheusmetricError = resultError; + } + return [metric.key ?? metric.label, resultData]; + }), + ); + + if (prometheusmetricError) { + 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 00000000..22137b0d --- /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/suwayomi/component.jsx b/src/widgets/suwayomi/component.jsx new file mode 100644 index 00000000..b7c34820 --- /dev/null +++ b/src/widgets/suwayomi/component.jsx @@ -0,0 +1,40 @@ +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"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + + const { data: suwayomiData, error: suwayomiError } = useWidgetAPI(widget); + + if (suwayomiError) { + return ; + } + + if (!suwayomiData) { + if (!widget.fields || widget.fields.length === 0) { + widget.fields = ["download", "nondownload", "read", "unread"]; + } else if (widget.fields.length > 4) { + widget.fields = widget.fields.slice(0, 4); + } + return ( + + {widget.fields.map((field) => ( + + ))} + + ); + } + + return ( + + {suwayomiData.map((data) => ( + + ))} + + ); +} diff --git a/src/widgets/suwayomi/proxy.js b/src/widgets/suwayomi/proxy.js new file mode 100644 index 00000000..d4d71675 --- /dev/null +++ b/src/widgets/suwayomi/proxy.js @@ -0,0 +1,175 @@ +import { httpProxy } from "utils/proxy/http"; +import { formatApiCall } from "utils/proxy/api-helpers"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const proxyName = "suwayomiProxyHandler"; +const logger = createLogger(proxyName); + +const countsToExtract = { + download: { + condition: (c) => c.isDownloaded, + gqlCondition: "isDownloaded: true", + }, + nondownload: { + condition: (c) => !c.isDownloaded, + gqlCondition: "isDownloaded: false", + }, + read: { + condition: (c) => c.isRead, + gqlCondition: "isRead: true", + }, + unread: { + condition: (c) => !c.isRead, + gqlCondition: "isRead: false", + }, + downloadedread: { + condition: (c) => c.isDownloaded && c.isRead, + gqlCondition: "isDownloaded: true, isRead: true", + }, + downloadedunread: { + condition: (c) => c.isDownloaded && !c.isRead, + gqlCondition: "isDownloaded: true, isRead: false", + }, + nondownloadedread: { + condition: (c) => !c.isDownloaded && c.isRead, + gqlCondition: "isDownloaded: false, isRead: true", + }, + nondownloadedunread: { + condition: (c) => !c.isDownloaded && !c.isRead, + gqlCondition: "isDownloaded: false, isRead: false", + }, +}; + +function makeBody(fields, category = "all") { + if (Number.isNaN(Number(category))) { + let query = ""; + fields.forEach((field) => { + query += ` + ${field}: chapters( + condition: {${countsToExtract[field].gqlCondition}} + filter: {inLibrary: {equalTo: true}} + ) { + totalCount + }`; + }); + return JSON.stringify({ + operationName: "Counts", + query: ` + query Counts { + ${query} + }`, + }); + } + + return JSON.stringify({ + operationName: "category", + query: ` + query category($id: Int!) { + category(id: $id) { + # name + mangas { + nodes { + chapters { + nodes { + isRead + isDownloaded + } + } + } + } + } + }`, + variables: { + id: Number(category), + }, + }); +} + +function extractCounts(responseJSON, fields) { + if (!("category" in responseJSON.data)) { + return fields.map((field) => ({ + count: responseJSON.data[field].totalCount, + label: `suwayomi.${field}`, + })); + } + const tmp = responseJSON.data.category.mangas.nodes.reduce( + (accumulator, manga) => { + manga.chapters.nodes.forEach((chapter) => { + fields.forEach((field, i) => { + if (countsToExtract[field].condition(chapter)) { + accumulator[i] += 1; + } + }); + }); + return accumulator; + }, + [0, 0, 0, 0], + ); + return fields.map((field, i) => ({ + count: tmp[i], + label: `suwayomi.${field}`, + })); +} + +export default async function suwayomiProxyHandler(req, res) { + const { group, service, endpoint } = req.query; + + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const widget = await getServiceWidget(group, service); + + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + if (!widget.fields || widget.fields.length === 0) { + widget.fields = ["download", "nondownload", "read", "unread"]; + } else if (widget.fields.length > 4) { + widget.fields = widget.fields.slice(0, 4); + } + + const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget })); + + const body = makeBody(widget.fields, widget.category); + + const headers = { + "Content-Type": "application/json", + }; + + if (widget.username && widget.password) { + headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`; + } + + const [status, contentType, data] = await httpProxy(url, { + method: "POST", + body, + headers, + }); + + if (status === 401) { + logger.error("Invalid or missing username or password for service '%s' in group '%s'", service, group); + return res.status(status).send({ error: { message: "401: unauthorized, username or password is incorrect." } }); + } + + if (status !== 200) { + logger.error( + "Error getting data from Suwayomi for service '%s' in group '%s': %d. Data: %s", + service, + group, + status, + data, + ); + return res.status(status).send({ error: { message: "Error getting data. body: %s, data: %s", body, data } }); + } + + const returnData = extractCounts(JSON.parse(data), widget.fields); + + if (contentType) res.setHeader("Content-Type", contentType); + return res.status(status).send(returnData); +} diff --git a/src/widgets/suwayomi/widget.js b/src/widgets/suwayomi/widget.js new file mode 100644 index 00000000..e2ed509c --- /dev/null +++ b/src/widgets/suwayomi/widget.js @@ -0,0 +1,8 @@ +import suwayomiProxyHandler from "./proxy"; + +const widget = { + api: "{url}/api/graphql", + proxyHandler: suwayomiProxyHandler, +}; + +export default widget; diff --git a/src/widgets/tautulli/component.jsx b/src/widgets/tautulli/component.jsx index b540c6d7..ba94f143 100644 --- a/src/widgets/tautulli/component.jsx +++ b/src/widgets/tautulli/component.jsx @@ -205,7 +205,7 @@ export default function Component({ service }) {
{playing.map((session) => (