diff --git a/docs/widgets/services/apcups.md b/docs/widgets/services/apcups.md new file mode 100644 index 00000000000..9f0b9195edb --- /dev/null +++ b/docs/widgets/services/apcups.md @@ -0,0 +1,16 @@ +--- +title: APC UPS Monitoring +description: Lightweight monitoring widget for APC UPSs using apcupsd daemon +--- + +This widget extracts UPS information from an apcupsd daemon. +Only works for [APC/Schneider](https://www.se.com/us/en/product-range/61915-smartups/#products) UPS products. + +[!NOTE] +By default apcupsd daemon is bound to 127.0.0.1. Edit `/etc/apcupsd.conf` and change `NISIP` to an IP accessible from your homepage docker (usually your internal LAN interface). + +```yaml +widget: + type: apcups + url: tcp://your.acpupsd.host:3551 +``` diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md index 2e3965820ae..58b1409d198 100644 --- a/docs/widgets/services/index.md +++ b/docs/widgets/services/index.md @@ -8,6 +8,7 @@ search: You can also find a list of all available service widgets in the sidebar navigation. - [Adguard Home](adguard-home.md) +- [APC UPS](apcups.md) - [ArgoCD](argocd.md) - [Atsumeru](atsumeru.md) - [Audiobookshelf](audiobookshelf.md) diff --git a/mkdocs.yml b/mkdocs.yml index 958c3398c89..c5f3a03888e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - "Service Widgets": - widgets/services/index.md - widgets/services/adguard-home.md + - widgets/services/apcups.md - widgets/services/argocd.md - widgets/services/atsumeru.md - widgets/services/audiobookshelf.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 9e390867008..65eb8256738 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1016,5 +1016,11 @@ "issues": "Issues", "merges": "Merge Requests", "projects": "Projects" + }, + "apcups": { + "status": "Status", + "load": "Load", + "bcharge":"Battery Charge", + "timeleft":"Time Left" } } diff --git a/src/widgets/apcups/component.jsx b/src/widgets/apcups/component.jsx new file mode 100644 index 00000000000..c1c26b5ccdd --- /dev/null +++ b/src/widgets/apcups/component.jsx @@ -0,0 +1,33 @@ +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Component({ service }) { + const { widget } = service; + const { data, error } = useWidgetAPI(widget); + + if (error) { + return ; + } + + if (!data) { + return ( + + + + + + + ); + } + + return ( + + + + + + + ); +} diff --git a/src/widgets/apcups/proxy.js b/src/widgets/apcups/proxy.js new file mode 100644 index 00000000000..8e1d7ffcfbf --- /dev/null +++ b/src/widgets/apcups/proxy.js @@ -0,0 +1,112 @@ +import net from "node:net"; +import { Buffer } from "node:buffer"; + +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; + +const logger = createLogger("apcupsProxyHandler"); + +function parseResponse(buffer) { + let ptr = 0; + const output = []; + while (ptr < buffer.length) { + const lineLen = buffer.readUInt16BE(ptr); + const asciiData = buffer.toString("ascii", ptr + 2, lineLen + ptr + 2); + output.push(asciiData); + ptr += 2 + lineLen; + } + + return output; +} + +function statusAsJSON(statusOutput) { + return statusOutput?.reduce((output, line) => { + if (!line || line.startsWith("END APC")) return output; + const [key, value] = line.trim().split(":"); + const newOutput = { ...output }; + newOutput[key.trim()] = value?.trim(); + return newOutput; + }, {}); +} + +async function getStatus(host = "127.0.0.1", port = 3551) { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.setTimeout(5000); + socket.connect({ host, port }); + + const response = []; + + socket.on("connect", () => { + const CMD = "status"; + logger.debug(`Connecting to ${host}:${port}`); + const buffer = Buffer.alloc(CMD.length + 2); + buffer.writeUInt16BE(CMD.length, 0); + buffer.write(CMD, 2); + socket.write(buffer); + }); + + socket.on("data", (data) => { + response.push(data); + + if (data.readUInt16BE(data.length - 2) === 0) { + try { + const buffer = Buffer.concat(response); + const output = parseResponse(buffer); + resolve(output); + } catch (e) { + reject(e); + } + socket.end(); + } + }); + + socket.on("error", (err) => { + socket.destroy(); + reject(err); + }); + socket.on("timeout", () => { + socket.destroy(); + reject(new Error("socket timeout")); + }); + socket.on("end", () => { + logger.debug("socket end"); + }); + socket.on("close", () => { + logger.debug("socket closed"); + }); + }); +} + +export default async function apcupsProxyHandler(req, res) { + const { group, service, index } = req.query; + + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const widget = await getServiceWidget(group, service, index); + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const url = new URL(widget.url); + const data = {}; + + try { + const statusData = await getStatus(url.hostname, url.port); + const jsonData = statusAsJSON(statusData); + + data.status = jsonData.STATUS; + data.load = jsonData.LOADPCT; + data.bcharge = jsonData.BCHARGE; + data.timeleft = jsonData.TIMELEFT; + } catch (e) { + logger.error(e); + return res.status(500).json({ error: e.message }); + } + + return res.status(200).send(data); +} diff --git a/src/widgets/apcups/widget.js b/src/widgets/apcups/widget.js new file mode 100644 index 00000000000..68f6371b83f --- /dev/null +++ b/src/widgets/apcups/widget.js @@ -0,0 +1,7 @@ +import apcupsProxyHandler from "./proxy"; + +const widget = { + proxyHandler: apcupsProxyHandler, +}; + +export default widget; diff --git a/src/widgets/components.js b/src/widgets/components.js index 67d17d50bd7..c34b9a4d26d 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; const components = { adguard: dynamic(() => import("./adguard/component")), + apcups: dynamic(() => import("./apcups/component")), argocd: dynamic(() => import("./argocd/component")), atsumeru: dynamic(() => import("./atsumeru/component")), audiobookshelf: dynamic(() => import("./audiobookshelf/component")), diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index b78f5b9c2b8..bb7748ec99d 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -1,4 +1,5 @@ import adguard from "./adguard/widget"; +import apcups from "./apcups/widget"; import argocd from "./argocd/widget"; import atsumeru from "./atsumeru/widget"; import audiobookshelf from "./audiobookshelf/widget"; @@ -134,6 +135,7 @@ import zabbix from "./zabbix/widget"; const widgets = { adguard, + apcups, argocd, atsumeru, audiobookshelf,