mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-29 07:21:16 -07:00
Feature: pulse widget (#6786)
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
@@ -141,6 +141,7 @@ nav:
|
|||||||
- widgets/services/proxmox.md
|
- widgets/services/proxmox.md
|
||||||
- widgets/services/proxmoxbackupserver.md
|
- widgets/services/proxmoxbackupserver.md
|
||||||
- widgets/services/pterodactyl.md
|
- widgets/services/pterodactyl.md
|
||||||
|
- widgets/services/pulse.md
|
||||||
- widgets/services/pyload.md
|
- widgets/services/pyload.md
|
||||||
- widgets/services/qbittorrent.md
|
- widgets/services/qbittorrent.md
|
||||||
- widgets/services/qnap.md
|
- widgets/services/qnap.md
|
||||||
|
|||||||
@@ -466,6 +466,11 @@
|
|||||||
"lxc": "LXC",
|
"lxc": "LXC",
|
||||||
"vms": "VMs"
|
"vms": "VMs"
|
||||||
},
|
},
|
||||||
|
"pulse": {
|
||||||
|
"nodes": "Nodes",
|
||||||
|
"vms": "VMs",
|
||||||
|
"lxcs": "LXCs"
|
||||||
|
},
|
||||||
"glances": {
|
"glances": {
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"load": "Load",
|
"load": "Load",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default async function credentialedProxyHandler(req, res, map) {
|
|||||||
} else if (widget.type === "proxmoxbackupserver") {
|
} else if (widget.type === "proxmoxbackupserver") {
|
||||||
delete headers["Content-Type"];
|
delete headers["Content-Type"];
|
||||||
headers.Authorization = `PBSAPIToken=${widget.username}:${widget.password}`;
|
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}`;
|
headers["X-API-Token"] = `${widget.key}`;
|
||||||
} else if (widget.type === "tubearchivist") {
|
} else if (widget.type === "tubearchivist") {
|
||||||
headers.Authorization = `Token ${widget.key}`;
|
headers.Authorization = `Token ${widget.key}`;
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const components = {
|
|||||||
pihole: dynamic(() => import("./pihole/component")),
|
pihole: dynamic(() => import("./pihole/component")),
|
||||||
plantit: dynamic(() => import("./plantit/component")),
|
plantit: dynamic(() => import("./plantit/component")),
|
||||||
plex: dynamic(() => import("./plex/component")),
|
plex: dynamic(() => import("./plex/component")),
|
||||||
|
pulse: dynamic(() => import("./pulse/component")),
|
||||||
portainer: dynamic(() => import("./portainer/component")),
|
portainer: dynamic(() => import("./portainer/component")),
|
||||||
prometheus: dynamic(() => import("./prometheus/component")),
|
prometheus: dynamic(() => import("./prometheus/component")),
|
||||||
prometheusmetric: dynamic(() => import("./prometheusmetric/component")),
|
prometheusmetric: dynamic(() => import("./prometheusmetric/component")),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,6 +106,7 @@ import prowlarr from "./prowlarr/widget";
|
|||||||
import proxmox from "./proxmox/widget";
|
import proxmox from "./proxmox/widget";
|
||||||
import proxmoxbackupserver from "./proxmoxbackupserver/widget";
|
import proxmoxbackupserver from "./proxmoxbackupserver/widget";
|
||||||
import pterodactyl from "./pterodactyl/widget";
|
import pterodactyl from "./pterodactyl/widget";
|
||||||
|
import pulse from "./pulse/widget";
|
||||||
import pyload from "./pyload/widget";
|
import pyload from "./pyload/widget";
|
||||||
import qbittorrent from "./qbittorrent/widget";
|
import qbittorrent from "./qbittorrent/widget";
|
||||||
import qnap from "./qnap/widget";
|
import qnap from "./qnap/widget";
|
||||||
@@ -266,6 +267,7 @@ const widgets = {
|
|||||||
prowlarr,
|
prowlarr,
|
||||||
proxmox,
|
proxmox,
|
||||||
pterodactyl,
|
pterodactyl,
|
||||||
|
pulse,
|
||||||
pyload,
|
pyload,
|
||||||
qbittorrent,
|
qbittorrent,
|
||||||
qnap,
|
qnap,
|
||||||
|
|||||||
Reference in New Issue
Block a user