From 0dcd25d5ad5511369b999deafdb13898cb9df983 Mon Sep 17 00:00:00 2001 From: Erv Walter Date: Mon, 8 Dec 2025 15:30:08 -0600 Subject: [PATCH] Feature: Pangolin service widget (#6065) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/widgets/services/pangolin.md | 29 ++++++++++ mkdocs.yml | 1 + public/locales/en/common.json | 9 +++ src/utils/proxy/handlers/credentialed.js | 1 + src/widgets/components.js | 1 + src/widgets/pangolin/component.jsx | 70 ++++++++++++++++++++++++ src/widgets/pangolin/widget.js | 17 ++++++ src/widgets/widgets.js | 2 + 8 files changed, 130 insertions(+) create mode 100644 docs/widgets/services/pangolin.md create mode 100644 src/widgets/pangolin/component.jsx create mode 100644 src/widgets/pangolin/widget.js diff --git a/docs/widgets/services/pangolin.md b/docs/widgets/services/pangolin.md new file mode 100644 index 000000000..ab5cb44d5 --- /dev/null +++ b/docs/widgets/services/pangolin.md @@ -0,0 +1,29 @@ +--- +title: Pangolin +description: Pangolin Widget Configuration +--- + +Learn more about [Pangolin](https://github.com/fosrl/pangolin). + +This widget shows sites (online/total), resources (healthy/total), targets (healthy/total), and traffic statistics for a Pangolin organization. A resource is considered healthy if at least one of its targets is healthy, or if it has no targets. + +Allowed fields: `["sites", "resources", "targets", "traffic", "in", "out"]` (maximum of 4). + +```yaml +widget: + type: pangolin + url: https://api.pangolin.net + key: your-api-key + org: your-org-id +``` + +Find your organization ID in the URL when logged in (e.g., `https://app.pangolin.net/{org-id}/...`). + +## API Key Setup + +Create an API key with the following permissions: + +- **List Sites** +- **List Resources** + +**Self-Hosted:** Enable the [Integration API](https://docs.pangolin.net/self-host/advanced/integration-api) in your Pangolin configuration before creating the key. diff --git a/mkdocs.yml b/mkdocs.yml index 8be043596..b3865d3c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,6 +122,7 @@ nav: - widgets/services/opnsense.md - widgets/services/openwrt.md - widgets/services/overseerr.md + - widgets/services/pangolin.md - widgets/services/paperlessngx.md - widgets/services/peanut.md - widgets/services/pfsense.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 05582127c..2d237a23c 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -599,6 +599,15 @@ "inbox": "Inbox", "total": "Total" }, + "pangolin": { + "orgs": "Orgs", + "sites": "Sites", + "resources": "Resources", + "targets": "Targets", + "traffic": "Traffic", + "in": "In", + "out": "Out" + }, "peanut": { "battery_charge": "Battery Charge", "ups_load": "UPS Load", diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js index e92dddaed..f5e9cf51a 100644 --- a/src/utils/proxy/handlers/credentialed.js +++ b/src/utils/proxy/handlers/credentialed.js @@ -53,6 +53,7 @@ export default async function credentialedProxyHandler(req, res, map) { "linkwarden", "mealie", "netalertx", + "pangolin", "tailscale", "tandoor", "pterodactyl", diff --git a/src/widgets/components.js b/src/widgets/components.js index d6b150b85..911be5fb8 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -97,6 +97,7 @@ const components = { openmediavault: dynamic(() => import("./openmediavault/component")), openwrt: dynamic(() => import("./openwrt/component")), paperlessngx: dynamic(() => import("./paperlessngx/component")), + pangolin: dynamic(() => import("./pangolin/component")), pfsense: dynamic(() => import("./pfsense/component")), photoprism: dynamic(() => import("./photoprism/component")), proxmoxbackupserver: dynamic(() => import("./proxmoxbackupserver/component")), diff --git a/src/widgets/pangolin/component.jsx b/src/widgets/pangolin/component.jsx new file mode 100644 index 000000000..5e81eecca --- /dev/null +++ b/src/widgets/pangolin/component.jsx @@ -0,0 +1,70 @@ +import Block from "components/services/widget/block"; +import Container from "components/services/widget/container"; +import { useTranslation } from "next-i18next"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; + +const MAX_ALLOWED_FIELDS = 4; + +export default function Component({ service }) { + const { t } = useTranslation(); + const { widget } = service; + + if (!widget.fields) { + widget.fields = ["sites", "resources", "targets", "traffic"]; + } else if (widget.fields?.length > MAX_ALLOWED_FIELDS) { + widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS); + } + + const { data: sitesData, error: sitesError } = useWidgetAPI(widget, "sites"); + const { data: resourcesData, error: resourcesError } = useWidgetAPI(widget, "resources"); + + if (sitesError || resourcesError) { + return ; + } + + if (!sitesData || !resourcesData) { + return ( + + + + + + + + + ); + } + + const sites = sitesData.data?.sites || []; + const resources = resourcesData.data?.resources || []; + + const sitesTotal = sites.length; + const sitesOnline = sites.filter((s) => s.online).length; + + const resourcesTotal = resources.length; + const resourcesHealthy = resources.filter( + (r) => r.targets?.some((t) => t.healthStatus !== "unhealthy") || !r.targets?.length, + ).length; + + const targetsTotal = resources.reduce((sum, r) => sum + (r.targets?.length || 0), 0); + const targetsHealthy = resources.reduce( + (sum, r) => sum + (r.targets?.filter((t) => t.healthStatus !== "unhealthy").length || 0), + 0, + ); + + const trafficIn = sites.reduce((sum, s) => sum + (s.megabytesIn || 0), 0) * 1_000_000; + const trafficOut = sites.reduce((sum, s) => sum + (s.megabytesOut || 0), 0) * 1_000_000; + const trafficTotal = trafficIn + trafficOut; + + return ( + + + + + + + + + ); +} diff --git a/src/widgets/pangolin/widget.js b/src/widgets/pangolin/widget.js new file mode 100644 index 000000000..899949fa6 --- /dev/null +++ b/src/widgets/pangolin/widget.js @@ -0,0 +1,17 @@ +import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; + +const widget = { + api: "{url}/v1/{endpoint}", + proxyHandler: credentialedProxyHandler, + + mappings: { + sites: { + endpoint: "org/{org}/sites", + }, + resources: { + endpoint: "org/{org}/resources", + }, + }, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 90cd7bfc8..dcc0ba65e 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -87,6 +87,7 @@ import openmediavault from "./openmediavault/widget"; import openwrt from "./openwrt/widget"; import opnsense from "./opnsense/widget"; import overseerr from "./overseerr/widget"; +import pangolin from "./pangolin/widget"; import paperlessngx from "./paperlessngx/widget"; import peanut from "./peanut/widget"; import pfsense from "./pfsense/widget"; @@ -237,6 +238,7 @@ const widgets = { openmediavault, openwrt, paperlessngx, + pangolin, peanut, pfsense, photoprism,