Feature: Add APC UPS widget (#4840)

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Nicu Pavel 2025-03-02 17:33:44 +02:00 committed by GitHub
parent 9b8dd94aae
commit fdf405fe0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 179 additions and 0 deletions

View File

@ -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
```

View File

@ -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)

View File

@ -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

View File

@ -1016,5 +1016,11 @@
"issues": "Issues",
"merges": "Merge Requests",
"projects": "Projects"
},
"apcups": {
"status": "Status",
"load": "Load",
"bcharge":"Battery Charge",
"timeleft":"Time Left"
}
}

View File

@ -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 <Container service={service} error={error} />;
}
if (!data) {
return (
<Container service={service}>
<Block label="apcups.status" />
<Block label="apcups.load" />
<Block label="apcups.bcharge" />
<Block label="apcups.timeleft" />
</Container>
);
}
return (
<Container service={service}>
<Block label="apcups.status" value={data.status} />
<Block label="apcups.load" value={data.load} />
<Block label="apcups.bcharge" value={data.bcharge} />
<Block label="apcups.timeleft" value={data.timeleft} />
</Container>
);
}

112
src/widgets/apcups/proxy.js Normal file
View File

@ -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);
}

View File

@ -0,0 +1,7 @@
import apcupsProxyHandler from "./proxy";
const widget = {
proxyHandler: apcupsProxyHandler,
};
export default widget;

View File

@ -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")),

View File

@ -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,