From e0be80085efe57dd8eca0c793c53c908efa2f4de Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Tue, 22 Sep 2026 21:03:51 -0700
Subject: [PATCH] Feature: customapi info widget (#7168)
---
docs/widgets/info/customapi.md | 36 +++++
docs/widgets/info/index.md | 1 +
docs/widgets/services/customapi.md | 2 +
mkdocs.yml | 1 +
.../pages/api/widgets/customapi.test.js | 118 ++++++++++++++
.../widgets/customapi/customapi.jsx | 53 ++++++
.../widgets/customapi/customapi.test.jsx | 117 ++++++++++++++
src/components/widgets/widget.jsx | 1 +
src/pages/api/widgets/customapi.js | 46 ++++++
src/test-utils/render-with-providers.jsx | 4 +-
src/utils/config/widget-helpers.js | 7 +-
src/utils/config/widget-helpers.test.js | 11 ++
src/widgets/customapi/component.jsx | 152 +-----------------
src/widgets/customapi/utils.js | 151 +++++++++++++++++
src/widgets/customapi/utils.test.js | 82 ++++++++++
15 files changed, 629 insertions(+), 153 deletions(-)
create mode 100644 docs/widgets/info/customapi.md
create mode 100644 src/__tests__/pages/api/widgets/customapi.test.js
create mode 100644 src/components/widgets/customapi/customapi.jsx
create mode 100644 src/components/widgets/customapi/customapi.test.jsx
create mode 100644 src/pages/api/widgets/customapi.js
create mode 100644 src/widgets/customapi/utils.js
create mode 100644 src/widgets/customapi/utils.test.js
diff --git a/docs/widgets/info/customapi.md b/docs/widgets/info/customapi.md
new file mode 100644
index 000000000..d9920081d
--- /dev/null
+++ b/docs/widgets/info/customapi.md
@@ -0,0 +1,36 @@
+---
+title: Custom API
+description: Custom API Information Widget Configuration
+---
+
+_(Find the Custom API service widget [here](../services/customapi.md))_
+
+The Custom API information widget shows values from a custom self-hosted or third party API in your Homepage header, alongside an icon.
+
+```yaml
+- customapi:
+ url: http://custom.api.host.or.ip:port/path/to/exact/api/endpoint
+ icon: mdi-api # optional, see below
+ label: My API # optional, shown below the values
+ href: https://custom.api.host # optional, makes the widget a link
+ refreshInterval: 10000 # optional - in milliseconds, defaults to 10s
+ username: username # auth - optional
+ password: password # auth - optional
+ method: GET # optional, e.g. POST
+ headers: # optional, must be object
+ X-API-Token: token
+ requestBody: # optional, can be string or object
+ mappings:
+ - field: key
+ label: Field 1
+ - field: path.to.key2
+ label: Field 2
+ format: number # optional - defaults to text
+ - field: path.to.key3
+ label: Field 3
+ format: percent
+```
+
+The `icon` accepts the same values as [service icons](../../configs/services.md#icons), e.g. `mdi-api`, `si-github`, `sh-homepage`, a dashboard icon name like `homepage.png`, or a full URL. If no icon is set, a generic API icon is shown.
+
+Mappings support the same `field`, `format`, `remap`, `scale`, `prefix` and `suffix` options as the [Custom API service widget](../services/customapi.md). The `additionalField` option and the `display` modes are not supported.
diff --git a/docs/widgets/info/index.md b/docs/widgets/info/index.md
index 961d93a68..f6ddce26a 100644
--- a/docs/widgets/info/index.md
+++ b/docs/widgets/info/index.md
@@ -7,6 +7,7 @@ search:
You can also find a list of all available info widgets in the sidebar navigation.
+- [Custom API](customapi.md)
- [Date & Time](datetime.md)
- [Glances](glances.md)
- [Greeting](greeting.md)
diff --git a/docs/widgets/services/customapi.md b/docs/widgets/services/customapi.md
index c820a21bb..3d0b55b68 100644
--- a/docs/widgets/services/customapi.md
+++ b/docs/widgets/services/customapi.md
@@ -3,6 +3,8 @@ title: Custom API
description: Custom Widget Configuration from the API
---
+_(Find the Custom API information widget [here](../info/customapi.md))_
+
This widget can show information from custom self-hosted or third party API.
Fields need to be defined in the `mappings` section YAML object to correlate with the value in the APIs JSON object. Final field definition needs to be the key with the desired value information.
diff --git a/mkdocs.yml b/mkdocs.yml
index f1a8936d9..0af27d41b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -194,6 +194,7 @@ nav:
- widgets/services/zabbix.md
- "Information Widgets":
- widgets/info/index.md
+ - widgets/info/customapi.md
- widgets/info/datetime.md
- widgets/info/glances.md
- widgets/info/greeting.md
diff --git a/src/__tests__/pages/api/widgets/customapi.test.js b/src/__tests__/pages/api/widgets/customapi.test.js
new file mode 100644
index 000000000..20f5681be
--- /dev/null
+++ b/src/__tests__/pages/api/widgets/customapi.test.js
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import createMockRes from "test-utils/create-mock-res";
+
+const { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({
+ getPrivateWidgetOptions: vi.fn(),
+ httpProxy: vi.fn(),
+ logger: { debug: vi.fn() },
+}));
+
+vi.mock("utils/config/widget-helpers", () => ({
+ getPrivateWidgetOptions,
+}));
+
+vi.mock("utils/proxy/http", () => ({
+ httpProxy,
+}));
+
+vi.mock("utils/logger", () => ({
+ default: () => logger,
+}));
+
+import handler from "pages/api/widgets/customapi";
+
+const json = (body) => Buffer.from(JSON.stringify(body));
+
+async function callRoute(options, index = "0") {
+ getPrivateWidgetOptions.mockResolvedValueOnce(options);
+ const res = createMockRes();
+ await handler({ query: { index } }, res);
+ return res;
+}
+
+describe("pages/api/widgets/customapi", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it.each([
+ ["no widget at that index", undefined],
+ ["a widget without a url", { method: "GET" }],
+ ["an invalid url", { url: "api.local/data" }],
+ ])("returns 400 without calling upstream for %s", async (_, options) => {
+ const res = await callRoute(options, "3");
+
+ expect(getPrivateWidgetOptions).toHaveBeenCalledWith("customapi", "3");
+ expect(res.statusCode).toBe(400);
+ expect(httpProxy).not.toHaveBeenCalled();
+ });
+
+ it("proxies an authenticated POST with custom headers and a JSON body", async () => {
+ httpProxy.mockResolvedValueOnce([200, "application/json", json({ json: { hello: "world" } })]);
+
+ const res = await callRoute({
+ url: "https://httpbin.org/post",
+ username: "admin",
+ password: "hunter2",
+ method: "POST",
+ headers: { "X-Secret-Token": "super-secret", "Content-Type": "application/json" },
+ requestBody: { hello: "world" },
+ });
+
+ expect(httpProxy).toHaveBeenCalledWith(new URL("https://httpbin.org/post"), {
+ method: "POST",
+ headers: {
+ "User-Agent": "homepage",
+ Accept: "application/json",
+ "X-Secret-Token": "super-secret",
+ "Content-Type": "application/json",
+ Authorization: `Basic ${Buffer.from("admin:hunter2").toString("base64")}`,
+ },
+ body: '{"hello":"world"}',
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toEqual({ json: { hello: "world" } });
+ });
+
+ it("sends a string body as-is and lets config override default headers", async () => {
+ httpProxy.mockResolvedValueOnce([200, "application/json", json({ data: { viewer: { login: "shamoon" } } })]);
+ const query = '{"query":"{ viewer { login } }"}';
+
+ const res = await callRoute({
+ url: "https://api.github.com/graphql",
+ method: "POST",
+ headers: { Authorization: "Bearer ghp_token", "User-Agent": "my-dashboard" },
+ requestBody: query,
+ });
+
+ expect(httpProxy).toHaveBeenCalledWith(expect.any(URL), {
+ method: "POST",
+ headers: { "User-Agent": "my-dashboard", Accept: "application/json", Authorization: "Bearer ghp_token" },
+ body: query,
+ });
+ expect(res.body).toEqual({ data: { viewer: { login: "shamoon" } } });
+ });
+
+ it.each([
+ ["an HTTP error", [403, "application/json", json({ message: "API rate limit exceeded for 1.2.3.4" })]],
+ [
+ "a network failure",
+ [500, "application/json", { error: { message: "connect ECONNREFUSED", rawError: { address: "10.0.0.5" } } }],
+ ],
+ ["a non-JSON response", [200, "text/html", Buffer.from("
Login")]],
+ ])("returns only a sanitized error for %s", async (_, upstream) => {
+ httpProxy.mockResolvedValueOnce(upstream);
+
+ const res = await callRoute({ url: "https://api.github.com/repos/gethomepage/homepage?token=abc" });
+
+ expect(httpProxy).toHaveBeenCalledWith(expect.any(URL), {
+ method: "GET",
+ headers: { "User-Agent": "homepage", Accept: "application/json" },
+ });
+ expect(res.statusCode).toBe(upstream[0] === 200 ? 500 : upstream[0]);
+ expect(res.body.error.url).toBe("api.github.com (see logs for details)");
+ expect(JSON.stringify(res.body)).not.toMatch(/rate limit|ECONNREFUSED|10\.0\.0\.5||token=abc/);
+ expect(res.setHeader).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/widgets/customapi/customapi.jsx b/src/components/widgets/customapi/customapi.jsx
new file mode 100644
index 000000000..40bb7f877
--- /dev/null
+++ b/src/components/widgets/customapi/customapi.jsx
@@ -0,0 +1,53 @@
+import classNames from "classnames";
+import { useTranslation } from "next-i18next/pages";
+import { TbApi } from "react-icons/tb";
+import useSWR from "swr";
+
+import Container from "../widget/container";
+import Error from "../widget/error";
+import Raw from "../widget/raw";
+import WidgetLabel from "../widget/widget_label";
+
+import ResolvedIcon from "components/resolvedicon";
+import { formatValue, getValue } from "widgets/customapi/utils";
+
+export default function CustomApi({ options }) {
+ const { t } = useTranslation();
+
+ const { index, icon, label, mappings = [], refreshInterval = 10000 } = options;
+
+ const { data, error } = useSWR(`/api/widgets/customapi?${new URLSearchParams({ index }).toString()}`, {
+ refreshInterval: Math.max(1000, refreshInterval),
+ });
+
+ if (error || (data?.error && !mappings.some((mapping) => mapping.field === "error"))) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ {icon ? (
+
+ ) : (
+
+ )}
+
+
+ {mappings.map((mapping) => (
+
+ {mapping.label}
+
+ {data ? formatValue(t, mapping, getValue(mapping.field, data)) : "-"}
+
+
+ ))}
+
+
+ {label && }
+
+
+ );
+}
diff --git a/src/components/widgets/customapi/customapi.test.jsx b/src/components/widgets/customapi/customapi.test.jsx
new file mode 100644
index 000000000..0012e4783
--- /dev/null
+++ b/src/components/widgets/customapi/customapi.test.jsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
+vi.mock("swr", () => ({ default: useSWR }));
+
+vi.mock("components/resolvedicon", () => ({
+ default: ({ icon }) => ,
+}));
+
+import CustomApi from "./customapi";
+
+const settings = { settings: { target: "_self" } };
+
+describe("components/widgets/customapi", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("shows GitHub repo stats once loaded, with the fallback icon and a label", () => {
+ const options = {
+ index: 6,
+ label: "Homepage",
+ refreshInterval: 300000,
+ mappings: [
+ { field: "stargazers_count", label: "Stars", format: "number" },
+ { field: "open_issues_count", label: "Open", format: "number" },
+ { field: "forks_count", label: "Forks", format: "number" },
+ ],
+ };
+
+ useSWR.mockReturnValue({ data: undefined, error: undefined });
+ const { container, rerender } = renderWithProviders(, settings);
+
+ expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=6", { refreshInterval: 300000 });
+ expect(screen.getAllByText("-")).toHaveLength(3);
+ expect(screen.queryByTestId("resolved-icon")).toBeNull();
+ expect(container.querySelector("svg")).not.toBeNull();
+
+ useSWR.mockReturnValue({
+ data: { stargazers_count: 32809, open_issues_count: 1, forks_count: 2127, owner: { login: "gethomepage" } },
+ error: undefined,
+ });
+ rerender();
+
+ expect(screen.queryByText("-")).toBeNull();
+ expect(screen.getByText("Stars").nextSibling).toHaveTextContent("32809");
+ expect(screen.getByText("Open").nextSibling).toHaveTextContent("1");
+ expect(screen.getByText("Forks").nextSibling).toHaveTextContent("2127");
+ expect(screen.getByText("Homepage")).toHaveClass("information-widget-label");
+ });
+
+ it("shows a nested weather value with a configured icon and suffix", () => {
+ useSWR.mockReturnValue({
+ data: { current: { time: "2026-09-22T20:15", temperature_2m: 7.8, wind_speed_10m: 12.4 } },
+ error: undefined,
+ });
+
+ renderWithProviders(
+ ,
+ settings,
+ );
+
+ expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=7", { refreshInterval: 10000 });
+ expect(screen.getByTestId("resolved-icon")).toHaveAttribute("data-icon", "mdi-thermometer");
+ expect(screen.getByText("7.8 °C")).toBeInTheDocument();
+ });
+
+ it("shows a status API's error field as data when it is mapped", () => {
+ useSWR.mockReturnValue({ data: { error: "E_NONE", pending: 3 }, error: undefined });
+
+ renderWithProviders(
+ ,
+ settings,
+ );
+
+ expect(screen.queryByText("widget.api_error")).toBeNull();
+ expect(screen.getByText("OK")).toBeInTheDocument();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+
+ it("shows the error state when the upstream API fails, clamping a too-fast refresh", () => {
+ useSWR.mockReturnValue({
+ data: { error: { message: "HTTP Error", url: "api.github.com (see logs for details)" } },
+ error: undefined,
+ });
+
+ renderWithProviders(
+ ,
+ settings,
+ );
+
+ expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=6", { refreshInterval: 1000 });
+ expect(screen.getByText("widget.api_error")).toBeInTheDocument();
+ expect(screen.queryByText("Stars")).toBeNull();
+ });
+});
diff --git a/src/components/widgets/widget.jsx b/src/components/widgets/widget.jsx
index c7f0bf4df..0da481131 100644
--- a/src/components/widgets/widget.jsx
+++ b/src/components/widgets/widget.jsx
@@ -16,6 +16,7 @@ const widgetMappings = {
longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")),
kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")),
stocks: dynamic(() => import("components/widgets/stocks/stocks")),
+ customapi: dynamic(() => import("components/widgets/customapi/customapi")),
};
export default function Widget({ widget, style }) {
diff --git a/src/pages/api/widgets/customapi.js b/src/pages/api/widgets/customapi.js
new file mode 100644
index 000000000..1fbb4c7fc
--- /dev/null
+++ b/src/pages/api/widgets/customapi.js
@@ -0,0 +1,46 @@
+import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
+import createLogger from "utils/logger";
+import { sanitizeErrorURL } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+
+const logger = createLogger("customapi");
+
+export default async function handler(req, res) {
+ const { index } = req.query;
+
+ const options = await getPrivateWidgetOptions("customapi", index);
+
+ if (!options?.url) {
+ return res.status(400).json({ error: "Missing Custom API URL" });
+ }
+
+ let url;
+ try {
+ url = new URL(options.url);
+ } catch {
+ return res.status(400).json({ error: "Invalid Custom API URL" });
+ }
+
+ const headers = { "User-Agent": "homepage", Accept: "application/json", ...(options.headers ?? {}) };
+ if (options.username && options.password) {
+ headers.Authorization = `Basic ${Buffer.from(`${options.username}:${options.password}`).toString("base64")}`;
+ }
+
+ const params = { method: options.method ?? "GET", headers };
+ if (options.requestBody) {
+ params.body = typeof options.requestBody === "object" ? JSON.stringify(options.requestBody) : options.requestBody;
+ }
+
+ const [status, , data] = await httpProxy(url, params);
+
+ if (status >= 400) {
+ logger.debug("HTTP Error %d calling %s//%s%s", status, url.protocol, url.host, url.pathname);
+ return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url) } });
+ }
+
+ try {
+ return res.status(status).json(JSON.parse(Buffer.from(data).toString()));
+ } catch {
+ return res.status(500).json({ error: { message: "Invalid JSON", url: sanitizeErrorURL(url) } });
+ }
+}
diff --git a/src/test-utils/render-with-providers.jsx b/src/test-utils/render-with-providers.jsx
index 7d6073fa9..459888882 100644
--- a/src/test-utils/render-with-providers.jsx
+++ b/src/test-utils/render-with-providers.jsx
@@ -9,5 +9,7 @@ export function renderWithProviders(ui, { settings = {} } = {}) {
setSettings: () => {},
};
- return render({ui});
+ const wrapper = ({ children }) => {children};
+
+ return render(ui, { wrapper });
}
diff --git a/src/utils/config/widget-helpers.js b/src/utils/config/widget-helpers.js
index 561424c96..d573bc3c8 100644
--- a/src/utils/config/widget-helpers.js
+++ b/src/utils/config/widget-helpers.js
@@ -31,7 +31,7 @@ export async function cleanWidgetGroups(widgets) {
const optionKeys = Object.keys(sanitizedOptions);
// delete private options from the sanitized options
- ["username", "password", "key", "apiKey"].forEach((pO) => {
+ ["username", "password", "key", "apiKey", "headers", "requestBody", "method"].forEach((pO) => {
if (optionKeys.includes(pO)) {
delete sanitizedOptions[pO];
}
@@ -57,7 +57,7 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
const privateOptions =
widgets.map((widget) => {
- const { index, url, username, password, key, apiKey } = widget.options;
+ const { index, url, username, password, key, apiKey, headers, requestBody, method } = widget.options;
return {
type: widget.type,
@@ -68,6 +68,9 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
password,
key,
apiKey,
+ headers,
+ requestBody,
+ method,
},
};
}) || {};
diff --git a/src/utils/config/widget-helpers.test.js b/src/utils/config/widget-helpers.test.js
index 2bec9e517..bcb6474b2 100644
--- a/src/utils/config/widget-helpers.test.js
+++ b/src/utils/config/widget-helpers.test.js
@@ -60,6 +60,17 @@ describe("utils/config/widget-helpers", () => {
expect(cleaned[2].options.apiKey).toBeUndefined();
});
+ it("cleanWidgetGroups removes customapi request options", async () => {
+ const cleaned = await cleanWidgetGroups([
+ {
+ type: "customapi",
+ options: { index: 0, url: "http://x", headers: { a: "b" }, requestBody: "body", method: "POST", icon: "mdi-x" },
+ },
+ ]);
+
+ expect(cleaned[0].options).toEqual({ index: 0, icon: "mdi-x" });
+ });
+
it("getPrivateWidgetOptions returns private options for a specific widget", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]);
diff --git a/src/widgets/customapi/component.jsx b/src/widgets/customapi/component.jsx
index af94434da..bccfe430e 100644
--- a/src/widgets/customapi/component.jsx
+++ b/src/widgets/customapi/component.jsx
@@ -1,161 +1,13 @@
import classNames from "classnames";
import { useTranslation } from "next-i18next/pages";
+import { formatValue, getColor, getValue } from "./utils";
+
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import * as shvl from "utils/config/shvl";
import useWidgetAPI from "utils/proxy/use-widget-api";
-function getValue(field, data) {
- let value = data;
- let lastField = field;
- let key = "";
-
- // Support APIs that return arrays or scalars directly.
- if (typeof field === "undefined") {
- return value;
- }
-
- // shvl is easier, everything else is kept for backwards compatibility.
- if (typeof field === "string") {
- return shvl.get(data, field, null) ?? data[field] ?? null;
- }
-
- while (typeof lastField === "object") {
- key = Object.keys(lastField)[0] ?? null;
-
- if (key === null) {
- break;
- }
-
- value = value[key];
- lastField = lastField[key];
- }
-
- if (typeof value === "undefined") {
- return null;
- }
-
- return value[lastField] ?? null;
-}
-
-function getSize(data) {
- if (Array.isArray(data) || typeof data === "string") {
- return data.length;
- } else if (typeof data === "object" && data !== null) {
- return Object.keys(data).length;
- }
-
- return NaN;
-}
-
-function formatValue(t, mapping, rawValue) {
- let value = rawValue;
-
- // Remap the value.
- const remaps = mapping?.remap ?? [];
- for (let i = 0; i < remaps.length; i += 1) {
- const remap = remaps[i];
- if (remap?.any || remap?.value === value) {
- value = remap.to;
- break;
- }
- }
-
- // Scale the value. Accepts either a number to multiply by or a string
- // like "12/345".
- const scale = mapping?.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.
- switch (mapping?.format) {
- case "number":
- value = t("common.number", { value: parseInt(value, 10) });
- break;
- case "float":
- value = t("common.number", { value });
- break;
- case "percent":
- value = t("common.percent", { value });
- break;
- case "duration":
- value = t("common.duration", { value });
- break;
- case "bytes":
- value = t("common.bytes", { value });
- break;
- case "bitrate":
- value = t("common.bitrate", { value });
- break;
- case "date":
- value = t("common.date", {
- value,
- lng: mapping?.locale,
- dateStyle: mapping?.dateStyle ?? "long",
- timeStyle: mapping?.timeStyle,
- });
- break;
- case "relativeDate":
- value = t("common.relativeDate", {
- value,
- lng: mapping?.locale,
- style: mapping?.style,
- numeric: mapping?.numeric,
- });
- break;
- case "size":
- value = t("common.number", { value: getSize(value) });
- break;
- case "text":
- default:
- // nothing
- }
-
- // Apply fixed prefix.
- const prefix = mapping?.prefix;
- if (prefix) {
- value = `${prefix} ${value}`;
- }
-
- // Apply fixed suffix.
- const suffix = mapping?.suffix;
- if (suffix) {
- value = `${value} ${suffix}`;
- }
-
- return value;
-}
-
-function getColor(mapping, customData) {
- const value = getValue(mapping.additionalField.field, customData);
- const { color } = mapping.additionalField;
-
- switch (color) {
- case "adaptive":
- try {
- const number = parseFloat(value);
- return number > 0 ? "text-emerald-300" : "text-rose-300";
- } catch (e) {
- return "";
- }
- case "black":
- return `text-black`;
- case "white":
- return `text-white`;
- case "theme":
- return `text-theme-500`;
- default:
- return "";
- }
-}
-
export default function Component({ service }) {
const { t } = useTranslation();
diff --git a/src/widgets/customapi/utils.js b/src/widgets/customapi/utils.js
new file mode 100644
index 000000000..42ed36b68
--- /dev/null
+++ b/src/widgets/customapi/utils.js
@@ -0,0 +1,151 @@
+import * as shvl from "utils/config/shvl";
+
+export function getValue(field, data) {
+ let value = data;
+ let lastField = field;
+ let key = "";
+
+ // Support APIs that return arrays or scalars directly.
+ if (typeof field === "undefined") {
+ return value;
+ }
+
+ // shvl is easier, everything else is kept for backwards compatibility.
+ if (typeof field === "string") {
+ return shvl.get(data, field, null) ?? data[field] ?? null;
+ }
+
+ while (typeof lastField === "object") {
+ key = Object.keys(lastField)[0] ?? null;
+
+ if (key === null) {
+ break;
+ }
+
+ value = value[key];
+ lastField = lastField[key];
+ }
+
+ if (typeof value === "undefined") {
+ return null;
+ }
+
+ return value[lastField] ?? null;
+}
+
+export function getSize(data) {
+ if (Array.isArray(data) || typeof data === "string") {
+ return data.length;
+ } else if (typeof data === "object" && data !== null) {
+ return Object.keys(data).length;
+ }
+
+ return NaN;
+}
+
+export function formatValue(t, mapping, rawValue) {
+ let value = rawValue;
+
+ // Remap the value.
+ const remaps = mapping?.remap ?? [];
+ for (let i = 0; i < remaps.length; i += 1) {
+ const remap = remaps[i];
+ if (remap?.any || remap?.value === value) {
+ value = remap.to;
+ break;
+ }
+ }
+
+ // Scale the value. Accepts either a number to multiply by or a string
+ // like "12/345".
+ const scale = mapping?.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.
+ switch (mapping?.format) {
+ case "number":
+ value = t("common.number", { value: parseInt(value, 10) });
+ break;
+ case "float":
+ value = t("common.number", { value });
+ break;
+ case "percent":
+ value = t("common.percent", { value });
+ break;
+ case "duration":
+ value = t("common.duration", { value });
+ break;
+ case "bytes":
+ value = t("common.bytes", { value });
+ break;
+ case "bitrate":
+ value = t("common.bitrate", { value });
+ break;
+ case "date":
+ value = t("common.date", {
+ value,
+ lng: mapping?.locale,
+ dateStyle: mapping?.dateStyle ?? "long",
+ timeStyle: mapping?.timeStyle,
+ });
+ break;
+ case "relativeDate":
+ value = t("common.relativeDate", {
+ value,
+ lng: mapping?.locale,
+ style: mapping?.style,
+ numeric: mapping?.numeric,
+ });
+ break;
+ case "size":
+ value = t("common.number", { value: getSize(value) });
+ break;
+ case "text":
+ default:
+ // nothing
+ }
+
+ // Apply fixed prefix.
+ const prefix = mapping?.prefix;
+ if (prefix) {
+ value = `${prefix} ${value}`;
+ }
+
+ // Apply fixed suffix.
+ const suffix = mapping?.suffix;
+ if (suffix) {
+ value = `${value} ${suffix}`;
+ }
+
+ return value;
+}
+
+export function getColor(mapping, customData) {
+ const value = getValue(mapping.additionalField.field, customData);
+ const { color } = mapping.additionalField;
+
+ switch (color) {
+ case "adaptive":
+ try {
+ const number = parseFloat(value);
+ return number > 0 ? "text-emerald-300" : "text-rose-300";
+ } catch (e) {
+ return "";
+ }
+ case "black":
+ return `text-black`;
+ case "white":
+ return `text-white`;
+ case "theme":
+ return `text-theme-500`;
+ default:
+ return "";
+ }
+}
diff --git a/src/widgets/customapi/utils.test.js b/src/widgets/customapi/utils.test.js
new file mode 100644
index 000000000..49e4f8aff
--- /dev/null
+++ b/src/widgets/customapi/utils.test.js
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+
+import { formatValue, getColor, getSize, getValue } from "./utils";
+
+const t = (key, { value }) => `${key.replace("common.", "")}:${value}`;
+
+const data = {
+ status: "online",
+ stats: { cpu: 12.5, change: -1.2, users: ["a", "b"] },
+ "dotted.key": 7,
+};
+
+describe("widgets/customapi/utils", () => {
+ it.each([
+ ["the root when no field is set", undefined, data],
+ ["a dotted path", "stats.cpu", 12.5],
+ ["a literal key containing a dot", "dotted.key", 7],
+ ["null for a missing path", "stats.missing", null],
+ ["a legacy object path", { stats: "cpu" }, 12.5],
+ ["null for a legacy path through a missing key", { missing: "cpu" }, null],
+ ["null for an empty legacy path", {}, null],
+ ])("getValue returns %s", (_, field, expected) => {
+ expect(getValue(field, data)).toEqual(expected);
+ });
+
+ it.each([
+ [["a", "b", "c"], 3],
+ ["abcd", 4],
+ [{ a: 1, b: 2 }, 2],
+ [null, NaN],
+ [5, NaN],
+ ])("getSize(%j) is %s", (input, expected) => {
+ expect(getSize(input)).toEqual(expected);
+ });
+
+ it.each([
+ [{}, "raw", "raw"],
+ [{ format: "number" }, "42.9", "number:42"],
+ [{ format: "float" }, 4.2, "number:4.2"],
+ [{ format: "percent", scale: 100 }, 0.25, "percent:25"],
+ [{ format: "duration" }, 90, "duration:90"],
+ [{ format: "bytes", scale: "1024/1" }, 2, "bytes:2048"],
+ [{ format: "bitrate", scale: "/8" }, 800, "bitrate:100"],
+ [{ format: "date" }, "2026-09-22", "date:2026-09-22"],
+ [{ format: "relativeDate" }, "2026-09-22", "relativeDate:2026-09-22"],
+ [{ format: "size" }, ["a", "b"], "number:2"],
+ [
+ {
+ remap: [
+ { value: 0, to: "Off" },
+ { value: 1, to: "On" },
+ ],
+ },
+ 1,
+ "On",
+ ],
+ [
+ {
+ remap: [
+ { value: "ok", to: "Healthy" },
+ { any: true, to: "Unknown" },
+ ],
+ },
+ "degraded",
+ "Unknown",
+ ],
+ [{ format: "number", prefix: "$", suffix: "USD" }, 10, "$ number:10 USD"],
+ ])("formatValue with %j formats %j as %j", (mapping, raw, expected) => {
+ expect(formatValue(t, mapping, raw)).toBe(expected);
+ });
+
+ it.each([
+ ["adaptive", "stats.cpu", "text-emerald-300"],
+ ["adaptive", "stats.change", "text-rose-300"],
+ ["black", "stats.cpu", "text-black"],
+ ["white", "stats.cpu", "text-white"],
+ ["theme", "stats.cpu", "text-theme-500"],
+ [undefined, "stats.cpu", ""],
+ ])("getColor %s for %s is %j", (color, field, expected) => {
+ expect(getColor({ additionalField: { field, color } }, data)).toBe(expected);
+ });
+});