Feature: pulse widget (#6786)

This commit is contained in:
shamoon
2026-06-18 23:34:25 -07:00
committed by GitHub
parent 797b4ab2bd
commit b11f3f0c4d
10 changed files with 266 additions and 1 deletions
+1 -1
View File
@@ -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}`;
+1
View File
@@ -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")),
+73
View File
@@ -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 <Container service={service} error={resourcesError} />;
}
if (!resourcesData) {
return (
<Container service={service}>
<Block label="pulse.nodes" />
<Block label="pulse.vms" />
<Block label="pulse.lxcs" />
</Container>
);
}
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 (
<Container service={service}>
<Block label="pulse.nodes" value={formatResourceCount(nodes, countActiveResources(resources, "node"))} />
<Block label="pulse.vms" value={formatResourceCount(vms, countActiveResources(resources, "vm"))} />
<Block label="pulse.lxcs" value={formatResourceCount(lxcs, countActiveResources(resources, "container"))} />
</Container>
);
}
+142
View File
@@ -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(<Component service={{ widget: { type: "pulse" } }} />, {
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(<Component service={{ widget: { type: "pulse" } }} />, {
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(<Component service={{ widget: { type: "pulse" } }} />, {
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(<Component service={{ widget: { type: "pulse" } }} />, {
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(<Component service={{ widget: { type: "pulse" } }} />, {
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(<Component service={{ widget: { type: "pulse" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "pulse.nodes", 2);
expectBlockValue(container, "pulse.vms", 2);
expectBlockValue(container, "pulse.lxcs", 1);
});
});
+14
View File
@@ -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;
+11
View File
@@ -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);
});
});
+2
View File
@@ -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,