mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-26 22:11:20 -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.
|
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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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: () => {},
|
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);
|
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}) || {};
|
}) || {};
|
||||||
|
|||||||
@@ -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" } }]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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