mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-25 21:41:17 -07:00
Feature: pulse widget (#6786)
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
Reference in New Issue
Block a user