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,