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