Compare commits

..

6 Commits

Author SHA1 Message Date
shamoon c6ada08a94 Docs 2026-09-22 20:12:48 -07:00
shamoon 7c3774ee5a Finally the widget 2026-09-22 20:06:16 -07:00
shamoon 44d8699519 json only 2026-09-22 20:04:31 -07:00
shamoon 19497d9908 Add a basic endpoint 2026-09-22 19:05:23 -07:00
shamoon cf1964d652 Add these private widget options 2026-09-22 19:04:27 -07:00
shamoon 3b18dc1f45 Extract helpers 2026-09-22 18:21:35 -07:00
13 changed files with 487 additions and 152 deletions
+35
View File
@@ -0,0 +1,35 @@
---
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
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.
- [Custom API](customapi.md)
- [Date & Time](datetime.md)
- [Glances](glances.md)
- [Greeting](greeting.md)
+2
View File
@@ -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.
+1
View File
@@ -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,94 @@
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";
describe("pages/api/widgets/customapi", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when the widget URL is missing", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
const res = createMockRes();
await handler({ query: { index: "0" } }, res);
expect(getPrivateWidgetOptions).toHaveBeenCalledWith("customapi", "0");
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("Missing Custom API URL");
});
it("returns 400 when the widget URL is invalid", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "not a url" });
const res = createMockRes();
await handler({ query: { index: "0" } }, res);
expect(res.statusCode).toBe(400);
expect(httpProxy).not.toHaveBeenCalled();
});
it("proxies with headers, basic auth, method and JSON body", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({
url: "http://api.local/data",
username: "u",
password: "p",
method: "POST",
headers: { "X-Test": "1" },
requestBody: { foo: "bar" },
});
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from('{"a":1}')]);
const res = createMockRes();
await handler({ query: { index: "0" } }, res);
expect(httpProxy).toHaveBeenCalledWith(new URL("http://api.local/data"), {
method: "POST",
headers: { "X-Test": "1", Authorization: `Basic ${Buffer.from("u:p").toString("base64")}` },
body: '{"foo":"bar"}',
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ a: 1 });
});
it("defaults to GET and passes string bodies through", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://api.local", requestBody: "raw" });
httpProxy.mockResolvedValueOnce([200, null, Buffer.from("{}")]);
const res = createMockRes();
await handler({ query: { index: "0" } }, res);
expect(httpProxy).toHaveBeenCalledWith(expect.any(URL), { method: "GET", headers: {}, body: "raw" });
});
it("returns a sanitized error without upstream data on HTTP errors", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://api.local/secret?token=abc" });
httpProxy.mockResolvedValueOnce([500, "text/plain", Buffer.from("boom")]);
const res = createMockRes();
await handler({ query: { index: "0" } }, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: { message: "HTTP Error", url: "api.local (see logs for details)" } });
});
});
@@ -0,0 +1,51 @@
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 ResolvedIcon from "components/resolvedicon";
import { formatValue, getValue } from "widgets/customapi/utils";
export default function CustomApi({ options }) {
const { t } = useTranslation();
const { index, icon, 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>
</Raw>
</Container>
);
}
@@ -0,0 +1,87 @@
// @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 mappings = [
{ field: "count", label: "Count" },
{ field: "nested.name", label: "Name" },
];
describe("components/widgets/customapi", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("requests the route by widget index with a minimum refresh interval", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<CustomApi options={{ index: 2, mappings, refreshInterval: 10 }} />, {
settings: { target: "_self" },
});
expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=2", { refreshInterval: 1000 });
});
it("renders labels with placeholders while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, mappings }} />, { settings: { target: "_self" } });
expect(screen.getByText("Count")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(2);
});
it("renders mapped values and the configured icon", () => {
useSWR.mockReturnValue({ data: { count: 5, nested: { name: "foo" } }, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, icon: "mdi-api", mappings }} />, {
settings: { target: "_self" },
});
expect(screen.getByTestId("resolved-icon")).toHaveAttribute("data-icon", "mdi-api");
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.getByText("foo")).toBeInTheDocument();
});
it("falls back to a default icon when none is configured", () => {
useSWR.mockReturnValue({ data: { count: 5 }, error: undefined });
const { container } = renderWithProviders(<CustomApi options={{ index: 0, mappings }} />, {
settings: { target: "_self" },
});
expect(screen.queryByTestId("resolved-icon")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("renders an error widget when the route returns an error", () => {
useSWR.mockReturnValue({ data: { error: { message: "HTTP Error" } }, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, mappings }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("treats an error key as data when a mapping reads it", () => {
useSWR.mockReturnValue({ data: { error: "none" }, error: undefined });
renderWithProviders(<CustomApi options={{ index: 0, mappings: [{ field: "error", label: "Error" }] }} />, {
settings: { target: "_self" },
});
expect(screen.queryByText("widget.api_error")).toBeNull();
expect(screen.getByText("none")).toBeInTheDocument();
});
});
+1
View File
@@ -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 }) {
+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 = { ...(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) } });
}
}
+5 -2
View File
@@ -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,
},
};
}) || {};
+11
View File
@@ -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" } }]);
+2 -150
View File
@@ -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();
+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 "";
}
}