mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-23 20:45:49 -07:00
Feature: customapi info widget (#7168)
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) } });
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,7 @@ export function renderWithProviders(ui, { settings = {} } = {}) {
|
||||
setSettings: () => {},
|
||||
};
|
||||
|
||||
return render(<SettingsContext.Provider value={value}>{ui}</SettingsContext.Provider>);
|
||||
const wrapper = ({ children }) => <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
|
||||
return render(ui, { wrapper });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}) || {};
|
||||
|
||||
@@ -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" } }]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user