From b11f3f0c4d8d2fde30b946ac3616f7824795c6d3 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:34:25 -0700 Subject: [PATCH] Feature: pulse widget (#6786) --- docs/widgets/services/pulse.md | 16 +++ mkdocs.yml | 1 + public/locales/en/common.json | 5 + src/utils/proxy/handlers/credentialed.js | 2 +- src/widgets/components.js | 1 + src/widgets/pulse/component.jsx | 73 ++++++++++++ src/widgets/pulse/component.test.jsx | 142 +++++++++++++++++++++++ src/widgets/pulse/widget.js | 14 +++ src/widgets/pulse/widget.test.js | 11 ++ src/widgets/widgets.js | 2 + 10 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 docs/widgets/services/pulse.md create mode 100644 src/widgets/pulse/component.jsx create mode 100644 src/widgets/pulse/component.test.jsx create mode 100644 src/widgets/pulse/widget.js create mode 100644 src/widgets/pulse/widget.test.js diff --git a/docs/widgets/services/pulse.md b/docs/widgets/services/pulse.md new file mode 100644 index 000000000..0e435a38b --- /dev/null +++ b/docs/widgets/services/pulse.md @@ -0,0 +1,16 @@ +--- +title: Pulse +description: Pulse Widget Configuration +--- + +Learn more about [Pulse](https://github.com/rcourtman/Pulse). + +Allowed fields: `["nodes", "vms", "lxcs"]`. + +```yaml +widget: + type: pulse + url: http://pulse.host.or.ip:7655 + key: your-api-token # `monitoring:read` scope is required + fields: ["nodes", "vms", "lxcs"] # optional +``` diff --git a/mkdocs.yml b/mkdocs.yml index e954e0219..eb526a662 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -141,6 +141,7 @@ nav: - widgets/services/proxmox.md - widgets/services/proxmoxbackupserver.md - widgets/services/pterodactyl.md + - widgets/services/pulse.md - widgets/services/pyload.md - widgets/services/qbittorrent.md - widgets/services/qnap.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 8908133b8..2700d4a18 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -466,6 +466,11 @@ "lxc": "LXC", "vms": "VMs" }, + "pulse": { + "nodes": "Nodes", + "vms": "VMs", + "lxcs": "LXCs" + }, "glances": { "cpu": "CPU", "load": "Load", diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js index e97bf97d9..1e4e7c481 100644 --- a/src/utils/proxy/handlers/credentialed.js +++ b/src/utils/proxy/handlers/credentialed.js @@ -88,7 +88,7 @@ export default async function credentialedProxyHandler(req, res, map) { } else if (widget.type === "proxmoxbackupserver") { delete headers["Content-Type"]; headers.Authorization = `PBSAPIToken=${widget.username}:${widget.password}`; - } else if (["autobrr", "jellystat"].includes(widget.type)) { + } else if (["autobrr", "jellystat", "pulse"].includes(widget.type)) { headers["X-API-Token"] = `${widget.key}`; } else if (widget.type === "tubearchivist") { headers.Authorization = `Token ${widget.key}`; diff --git a/src/widgets/components.js b/src/widgets/components.js index fbc34d3a2..322a80c4a 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -110,6 +110,7 @@ const components = { pihole: dynamic(() => import("./pihole/component")), plantit: dynamic(() => import("./plantit/component")), plex: dynamic(() => import("./plex/component")), + pulse: dynamic(() => import("./pulse/component")), portainer: dynamic(() => import("./portainer/component")), prometheus: dynamic(() => import("./prometheus/component")), prometheusmetric: dynamic(() => import("./prometheusmetric/component")), diff --git a/src/widgets/pulse/component.jsx b/src/widgets/pulse/component.jsx new file mode 100644 index 000000000..ffb260c4e --- /dev/null +++ b/src/widgets/pulse/component.jsx @@ -0,0 +1,73 @@ +import Block from "components/services/widget/block"; +import Container from "components/services/widget/container"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; + +const ACTIVE_STATUSES = ["online", "running"]; + +function countResources(data, resources, type) { + const statsCount = data?.stats?.byType?.[type]; + if (typeof statsCount === "number") { + return statsCount; + } + + if (resources) { + return resources.filter((resource) => resource.type === type).length; + } + + return undefined; +} + +function countActiveResources(resources, type) { + return resources + ? resources.filter((resource) => resource.type === type && ACTIVE_STATUSES.includes(resource.status)).length + : undefined; +} + +function formatResourceCount(total, active) { + if (total === undefined) { + return undefined; + } + + if (active === undefined) { + return total; + } + + return `${active} / ${total}`; +} + +export default function Component({ service }) { + const { widget } = service; + + const { data: resourcesData, error: resourcesError } = useWidgetAPI(widget, "resources"); + + if (resourcesError) { + return ; + } + + if (!resourcesData) { + return ( + + + + + + ); + } + + let resources = resourcesData.resources; + if (!resources && resourcesData.count === 0) { + resources = []; + } + const nodes = countResources(resourcesData, resources, "node"); + const vms = countResources(resourcesData, resources, "vm"); + const lxcs = countResources(resourcesData, resources, "container"); + + return ( + + + + + + ); +} diff --git a/src/widgets/pulse/component.test.jsx b/src/widgets/pulse/component.test.jsx new file mode 100644 index 000000000..34b09f517 --- /dev/null +++ b/src/widgets/pulse/component.test.jsx @@ -0,0 +1,142 @@ +// @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"; +import { expectBlockValue } from "test-utils/widget-assertions"; + +const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); +vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); + +import Component from "./component"; + +describe("widgets/pulse/component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders placeholders while loading", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(container.querySelectorAll(".service-block")).toHaveLength(3); + expect(screen.getByText("pulse.nodes")).toBeInTheDocument(); + expect(screen.getByText("pulse.vms")).toBeInTheDocument(); + expect(screen.getByText("pulse.lxcs")).toBeInTheDocument(); + }); + + it("renders error UI when the resources endpoint errors", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "missing token" } }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0); + expect(screen.getByText("missing token")).toBeInTheDocument(); + }); + + it("renders active and total resource counts", () => { + useWidgetAPI.mockReturnValue({ + data: { + resources: [ + { type: "node", status: "online" }, + { type: "node", status: "offline" }, + { type: "vm", status: "running" }, + { type: "vm", status: "stopped" }, + { type: "container", status: "running" }, + ], + stats: { + byType: { + node: 2, + vm: 2, + container: 1, + }, + }, + }, + error: undefined, + }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expectBlockValue(container, "pulse.nodes", "1 / 2"); + expectBlockValue(container, "pulse.vms", "1 / 2"); + expectBlockValue(container, "pulse.lxcs", "1 / 1"); + }); + + it("falls back to stats totals when resources are not returned", () => { + useWidgetAPI.mockReturnValue({ + data: { + stats: { + byType: { + node: 2, + vm: 4, + container: 3, + }, + }, + }, + error: undefined, + }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expectBlockValue(container, "pulse.nodes", 2); + expectBlockValue(container, "pulse.vms", 4); + expectBlockValue(container, "pulse.lxcs", 3); + }); + + it("shows 0 counts when resources is missing but count is 0", () => { + useWidgetAPI.mockReturnValue({ + data: { + count: 0, + stats: { + byType: { + node: 0, + vm: 0, + container: 0, + }, + }, + }, + error: undefined, + }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expectBlockValue(container, "pulse.nodes", 0); + expectBlockValue(container, "pulse.vms", 0); + expectBlockValue(container, "pulse.lxcs", 0); + }); + + it("falls back to resources length when stats totals are not returned", () => { + useWidgetAPI.mockReturnValue({ + data: { + resources: [ + { type: "node", status: "online" }, + { type: "node", status: "offline" }, + { type: "vm", status: "running" }, + { type: "vm", status: "stopped" }, + { type: "container", status: "running" }, + ], + }, + error: undefined, + }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expectBlockValue(container, "pulse.nodes", 2); + expectBlockValue(container, "pulse.vms", 2); + expectBlockValue(container, "pulse.lxcs", 1); + }); +}); diff --git a/src/widgets/pulse/widget.js b/src/widgets/pulse/widget.js new file mode 100644 index 000000000..ca3d46c48 --- /dev/null +++ b/src/widgets/pulse/widget.js @@ -0,0 +1,14 @@ +import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; + +const widget = { + api: "{url}/{endpoint}", + proxyHandler: credentialedProxyHandler, + + mappings: { + resources: { + endpoint: "api/resources", + }, + }, +}; + +export default widget; diff --git a/src/widgets/pulse/widget.test.js b/src/widgets/pulse/widget.test.js new file mode 100644 index 000000000..411b06739 --- /dev/null +++ b/src/widgets/pulse/widget.test.js @@ -0,0 +1,11 @@ +import { describe, it } from "vitest"; + +import { expectWidgetConfigShape } from "test-utils/widget-config"; + +import widget from "./widget"; + +describe("pulse widget config", () => { + it("exports a valid widget config", () => { + expectWidgetConfigShape(widget); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 7f18efad3..5bc7f7fca 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -106,6 +106,7 @@ import prowlarr from "./prowlarr/widget"; import proxmox from "./proxmox/widget"; import proxmoxbackupserver from "./proxmoxbackupserver/widget"; import pterodactyl from "./pterodactyl/widget"; +import pulse from "./pulse/widget"; import pyload from "./pyload/widget"; import qbittorrent from "./qbittorrent/widget"; import qnap from "./qnap/widget"; @@ -266,6 +267,7 @@ const widgets = { prowlarr, proxmox, pterodactyl, + pulse, pyload, qbittorrent, qnap,