Compare commits

...

3 Commits

Author SHA1 Message Date
shamoon
d6e7e7e790 1.12.3
Some checks are pending
Docker CI / Docker Build & Push (push) Waiting to run
Docs / Test Build Docs (push) Waiting to run
Docs / Build & Deploy Docs (push) Waiting to run
Lint / Linting Checks (push) Waiting to run
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run
2026-04-01 08:02:58 -07:00
shamoon
24cb274e03 Fix glances regex 2026-04-01 08:02:03 -07:00
shamoon
af852e748a Normalize widget version in URLs 2026-04-01 08:00:20 -07:00
19 changed files with 98 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.12.2",
"version": "1.12.3",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",

View File

@@ -92,6 +92,23 @@ describe("pages/api/widgets/glances", () => {
expect(res.statusCode).toBe(200);
});
it("falls back to version 3 when version is invalid", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))])
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))])
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]);
const req = { query: { index: "0", version: "3/../../secret-endpoint" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://glances/api/3/cpu", expect.any(Object));
expect(res.statusCode).toBe(200);
});
it("returns 400 when glances returns 401", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);

View File

@@ -1,5 +1,6 @@
import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
import createLogger from "utils/logger";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
const logger = createLogger("glances");
@@ -45,7 +46,7 @@ export default async function handler(req, res) {
const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query;
const privateWidgetOptions = await getPrivateWidgetOptions("glances", index);
privateWidgetOptions.version = version ?? 3;
privateWidgetOptions.version = parseVersionForUrl(version, 3);
try {
const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, "cpu");

View File

@@ -10,6 +10,7 @@ import { getKubeConfig } from "utils/config/kubernetes";
import * as shvl from "utils/config/shvl";
import kubernetes from "utils/kubernetes/export";
import createLogger from "utils/logger";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
const logger = createLogger("service-helpers");
@@ -113,7 +114,7 @@ export async function servicesFromDocker() {
}
let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) {
substitutedVal = parseInt(substitutedVal, 10);
substitutedVal = parseVersionForUrl(substitutedVal);
}
shvl.set(constructedService, value, substitutedVal);
}
@@ -590,7 +591,7 @@ export function cleanServiceGroups(groups) {
"vikunja",
].includes(type)
) {
if (version) widget.version = parseInt(version, 10);
widget.version = parseVersionForUrl(version);
}
if (type === "glances") {
if (metric) widget.metric = metric;

View File

@@ -12,6 +12,22 @@ export function formatApiCall(url, args) {
return url.replace(find, replace).replace(find, replace);
}
export function parseVersionForUrl(version, defaultValue = null) {
if (version === undefined || version === null || version === "") {
return defaultValue;
}
if (typeof version === "number") {
return Number.isInteger(version) && version >= 0 ? version : defaultValue;
}
if (typeof version === "string" && /^\d+$/.test(version)) {
return Number(version);
}
return defaultValue;
}
export function getURLSearchParams(widget, endpoint) {
const params = new URLSearchParams({
group: widget.service_group,

View File

@@ -7,6 +7,7 @@ import {
getURLSearchParams,
jsonArrayFilter,
jsonArrayTransform,
parseVersionForUrl,
sanitizeErrorURL,
} from "./api-helpers";
@@ -21,6 +22,20 @@ describe("utils/proxy/api-helpers", () => {
expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-");
});
it("parseVersionForUrl accepts canonical non-negative integers", () => {
expect(parseVersionForUrl("3")).toBe(3);
expect(parseVersionForUrl(4)).toBe(4);
expect(parseVersionForUrl(undefined, 3)).toBe(3);
});
it("parseVersionForUrl rejects non-canonical values", () => {
expect(parseVersionForUrl("3/../../path", 3)).toBe(3);
expect(parseVersionForUrl("1e2", 3)).toBe(3);
expect(parseVersionForUrl("0x10", 3)).toBe(3);
expect(parseVersionForUrl(-1, 3)).toBe(3);
expect(parseVersionForUrl(Number.NaN, 3)).toBe(3);
});
it("getURLSearchParams includes group/service/index and optionally endpoint", () => {
const widget = { service_group: "g", service_name: "s", index: "0" };

View File

@@ -2,7 +2,7 @@ import cache from "memory-cache";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import { asJson, formatApiCall } from "utils/proxy/api-helpers";
import { asJson, formatApiCall, parseVersionForUrl } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
import widgets from "widgets/widgets";
@@ -56,7 +56,7 @@ async function getApiInfo(serviceWidget, apiName, serviceName) {
const json = asJson(data);
if (json?.data?.[apiName]) {
cgiPath = json.data[apiName].path;
maxVersion = json.data[apiName].maxVersion;
maxVersion = parseVersionForUrl(json.data[apiName].maxVersion);
logger.debug(
`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`,
);

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const statusMap = {
@@ -19,11 +20,12 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const idKey = version === 3 ? "Id" : "id";
const statusKey = version === 3 ? "Status" : "status";
const idKey = apiVersion === 3 ? "Id" : "id";
const statusKey = apiVersion === 3 ? "Status" : "status";
const { data, error } = useWidgetAPI(service.widget, `${version}/containers`, {
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/containers`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
@@ -16,14 +17,15 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, {
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/cpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`);
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`);
useEffect(() => {
if (data) {

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -16,6 +17,7 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, diskName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(
@@ -23,7 +25,7 @@ export default function Component({ service }) {
);
const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, {
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/diskio`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const defaultInterval = 1000;
@@ -11,10 +12,11 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, fsName] = widget.metric.split("fs:");
const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
const { data, error } = useWidgetAPI(widget, `${version}/fs`, {
const { data, error } = useWidgetAPI(widget, `${apiVersion}/fs`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -16,11 +17,12 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, gpuName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(widget, `${version}/gpu`, {
const { data, error } = useWidgetAPI(widget, `${apiVersion}/gpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
function Swap({ quicklookData, className = "" }) {
@@ -75,12 +76,13 @@ const defaultSystemInterval = 30000; // This data (OS, hostname, distribution) i
export default function Component({ service }) {
const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, {
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`, {
refreshInterval,
});
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, {
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${apiVersion}/system`, {
refreshInterval: defaultSystemInterval,
});

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -17,10 +18,11 @@ export default function Component({ service }) {
const { widget } = service;
const { chart } = widget;
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, {
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/mem`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
});

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
@@ -17,15 +18,16 @@ export default function Component({ service }) {
const { widget } = service;
const { chart, metric } = widget;
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const rxKey = version === 3 ? "rx" : "bytes_recv";
const txKey = version === 3 ? "tx" : "bytes_sent";
const rxKey = apiVersion === 3 ? "rx" : "bytes_recv";
const txKey = apiVersion === 3 ? "tx" : "bytes_sent";
const [, interfaceName] = metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(widget, `${version}/network`, {
const { data, error } = useWidgetAPI(widget, `${apiVersion}/network`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
});

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const statusMap = {
@@ -22,10 +23,11 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const memoryInfoKey = version === 3 ? 0 : "rss";
const memoryInfoKey = apiVersion === 3 ? 0 : "rss";
const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/processlist`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Block from "../components/block";
import Container from "../components/container";
import { parseVersionForUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
@@ -16,11 +17,12 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const apiVersion = parseVersionForUrl(version, 3);
const [, sensorName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, {
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/sensors`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});

View File

@@ -3,7 +3,7 @@ import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
allowedEndpoints: /\d\/quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers/,
allowedEndpoints: /^\d+\/(quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers)$/,
};
export default widget;

View File

@@ -8,6 +8,10 @@ describe("glances widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.allowedEndpoints?.test("3/quicklook")).toBe(true);
expect(widget.allowedEndpoints?.test("12/cpu")).toBe(true);
expect(widget.allowedEndpoints?.test("unknown")).toBe(false);
expect(widget.allowedEndpoints?.test("xxcpuyy")).toBe(false);
expect(widget.allowedEndpoints?.test("3/cpu/extra")).toBe(false);
expect(widget.allowedEndpoints?.test("membrane")).toBe(false);
});
});