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); + }); +});