From e938c3ac1e6933d78459b370b088948ad554331f Mon Sep 17 00:00:00 2001
From: Felix Cornelius <9767036+fcornelius@users.noreply.github.com>
Date: Tue, 12 Nov 2024 01:42:14 +0100
Subject: [PATCH 1/4] Feature: Prometheus Metric service widget (#4269)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
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 | 115 +++++++++++++++++++++
src/widgets/prometheusmetric/widget.js | 16 +++
src/widgets/widgets.js | 2 +
8 files changed, 211 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 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/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/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index 63dfb608..1566a135 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 50564e49..7e6b68e1 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 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/widgets.js b/src/widgets/widgets.js
index 40b121ae..4d76fa07 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 1a22065c3a08b0b1bd0e807aeb3f0040fe618f49 Mon Sep 17 00:00:00 2001
From: John <34685272+Johnomated@users.noreply.github.com>
Date: Mon, 11 Nov 2024 19:15:26 -0600
Subject: [PATCH 2/4] Fix: use session_key instead of Id in tautulli component
to resolve unique key warning (#4278)
---
src/widgets/tautulli/component.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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) => (
Date: Tue, 12 Nov 2024 01:52:51 +0000
Subject: [PATCH 3/4] Feature: suwayomi Service Widget (#4273)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
docs/widgets/services/suwayomi.md | 20 ++++
public/locales/en/common.json | 10 ++
src/widgets/components.js | 1 +
src/widgets/suwayomi/component.jsx | 40 +++++++
src/widgets/suwayomi/proxy.js | 175 +++++++++++++++++++++++++++++
src/widgets/suwayomi/widget.js | 8 ++
src/widgets/widgets.js | 2 +
7 files changed, 256 insertions(+)
create mode 100644 docs/widgets/services/suwayomi.md
create mode 100644 src/widgets/suwayomi/component.jsx
create mode 100644 src/widgets/suwayomi/proxy.js
create mode 100644 src/widgets/suwayomi/widget.js
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/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/widgets/components.js b/src/widgets/components.js
index 7e6b68e1..3cba84d2 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -114,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/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/widgets.js b/src/widgets/widgets.js
index 4d76fa07..79110378 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -105,6 +105,7 @@ import stash from "./stash/widget";
import stocks from "./stocks/widget";
import strelaysrv from "./strelaysrv/widget";
import swagdashboard from "./swagdashboard/widget";
+import suwayomi from "./suwayomi/widget";
import tailscale from "./tailscale/widget";
import tandoor from "./tandoor/widget";
import tautulli from "./tautulli/widget";
@@ -238,6 +239,7 @@ const widgets = {
stocks,
strelaysrv,
swagdashboard,
+ suwayomi,
tailscale,
tandoor,
tautulli,
From d87d347aa3d95abf1e24eb9691e34da1243af508 Mon Sep 17 00:00:00 2001
From: Abey Thomas
Date: Tue, 12 Nov 2024 06:19:04 -0800
Subject: [PATCH 4/4] Documentation: corrections Beszel widget docs (#4282)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
docs/widgets/services/beszel.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
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"]`