Feature: customapi info widget (#7168)

This commit is contained in:
shamoon
2026-09-22 21:03:51 -07:00
committed by GitHub
parent b8ed72a03f
commit e0be80085e
15 changed files with 629 additions and 153 deletions
+36
View File
@@ -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.
+1
View File
@@ -7,6 +7,7 @@ search:
You can also find a list of all available info widgets in the sidebar navigation. You can also find a list of all available info widgets in the sidebar navigation.
- [Custom API](customapi.md)
- [Date & Time](datetime.md) - [Date & Time](datetime.md)
- [Glances](glances.md) - [Glances](glances.md)
- [Greeting](greeting.md) - [Greeting](greeting.md)
+2
View File
@@ -3,6 +3,8 @@ title: Custom API
description: Custom Widget Configuration from the 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. 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. 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.
+1
View File
@@ -194,6 +194,7 @@ nav:
- widgets/services/zabbix.md - widgets/services/zabbix.md
- "Information Widgets": - "Information Widgets":
- widgets/info/index.md - widgets/info/index.md
- widgets/info/customapi.md
- widgets/info/datetime.md - widgets/info/datetime.md
- widgets/info/glances.md - widgets/info/glances.md
- widgets/info/greeting.md - widgets/info/greeting.md
@@ -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("<html><body>Login</body></html>")]],
])("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|<html>|token=abc/);
expect(res.setHeader).not.toHaveBeenCalled();
});
});
@@ -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 <Error options={options} />;
}
return (
<Container options={options} additionalClassNames="information-widget-customapi">
<Raw>
<div className="flex flex-row items-center">
<div className="flex-none mr-2">
{icon ? (
<ResolvedIcon icon={icon} width={24} height={24} />
) : (
<TbApi className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className={classNames("flex flex-wrap items-center gap-0.5", !data && "animate-pulse")}>
{mappings.map((mapping) => (
<div key={mapping.label} className="flex flex-col items-center justify-center px-1 text-xs">
<span className="text-theme-800 dark:text-theme-200">{mapping.label}</span>
<span className="text-theme-800/70 dark:text-theme-200/50">
{data ? formatValue(t, mapping, getValue(mapping.field, data)) : "-"}
</span>
</div>
))}
</div>
</div>
{label && <WidgetLabel label={label} />}
</Raw>
</Container>
);
}
@@ -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 }) => <div data-testid="resolved-icon" data-icon={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(<CustomApi options={options} />, 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(<CustomApi options={options} />);
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(
<CustomApi
options={{
index: 7,
icon: "mdi-thermometer",
mappings: [{ field: "current.temperature_2m", label: "Temp", format: "float", suffix: "°C" }],
}}
/>,
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(
<CustomApi
options={{
index: 0,
mappings: [
{ field: "error", label: "Status", remap: [{ value: "E_NONE", to: "OK" }] },
{ field: "pending", label: "Queue", format: "number" },
],
}}
/>,
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(
<CustomApi
options={{ index: 6, refreshInterval: 500, mappings: [{ field: "stargazers_count", label: "Stars" }] }}
/>,
settings,
);
expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=6", { refreshInterval: 1000 });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
expect(screen.queryByText("Stars")).toBeNull();
});
});
+1
View File
@@ -16,6 +16,7 @@ const widgetMappings = {
longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")), longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")),
kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")), kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")),
stocks: dynamic(() => import("components/widgets/stocks/stocks")), stocks: dynamic(() => import("components/widgets/stocks/stocks")),
customapi: dynamic(() => import("components/widgets/customapi/customapi")),
}; };
export default function Widget({ widget, style }) { export default function Widget({ widget, style }) {
+46
View File
@@ -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) } });
}
}
+3 -1
View File
@@ -9,5 +9,7 @@ export function renderWithProviders(ui, { settings = {} } = {}) {
setSettings: () => {}, setSettings: () => {},
}; };
return render(<SettingsContext.Provider value={value}>{ui}</SettingsContext.Provider>); const wrapper = ({ children }) => <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
return render(ui, { wrapper });
} }
+5 -2
View File
@@ -31,7 +31,7 @@ export async function cleanWidgetGroups(widgets) {
const optionKeys = Object.keys(sanitizedOptions); const optionKeys = Object.keys(sanitizedOptions);
// delete private options from the sanitized options // 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)) { if (optionKeys.includes(pO)) {
delete sanitizedOptions[pO]; delete sanitizedOptions[pO];
} }
@@ -57,7 +57,7 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
const privateOptions = const privateOptions =
widgets.map((widget) => { 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 { return {
type: widget.type, type: widget.type,
@@ -68,6 +68,9 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
password, password,
key, key,
apiKey, apiKey,
headers,
requestBody,
method,
}, },
}; };
}) || {}; }) || {};
+11
View File
@@ -60,6 +60,17 @@ describe("utils/config/widget-helpers", () => {
expect(cleaned[2].options.apiKey).toBeUndefined(); 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 () => { it("getPrivateWidgetOptions returns private options for a specific widget", async () => {
fs.readFile.mockResolvedValueOnce("ignored"); fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]); yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]);
+2 -150
View File
@@ -1,161 +1,13 @@
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "next-i18next/pages"; import { useTranslation } from "next-i18next/pages";
import { formatValue, getColor, getValue } from "./utils";
import Block from "components/services/widget/block"; import Block from "components/services/widget/block";
import Container from "components/services/widget/container"; import Container from "components/services/widget/container";
import * as shvl from "utils/config/shvl"; import * as shvl from "utils/config/shvl";
import useWidgetAPI from "utils/proxy/use-widget-api"; 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 }) { export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
+151
View File
@@ -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 "";
}
}
+82
View File
@@ -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);
});
});