diff --git a/public/locales/en/common.json b/public/locales/en/common.json index dfc1a5ba6..669ebd276 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -31,6 +31,17 @@ "used": "Used", "load": "Load" }, + "unifi": { + "users": "Users", + "uptime": "System Uptime", + "days": "Days", + "wan": "WAN", + "lan": "LAN", + "wlan": "WLAN", + "up": "UP", + "down": "DOWN", + "wait": "Please wait" + }, "docker": { "rx": "RX", "tx": "TX", diff --git a/src/components/widgets/unifi_console/unifi_console.jsx b/src/components/widgets/unifi_console/unifi_console.jsx new file mode 100644 index 000000000..7427bd23c --- /dev/null +++ b/src/components/widgets/unifi_console/unifi_console.jsx @@ -0,0 +1,119 @@ +import { BiError, BiWifi, BiCheckCircle, BiXCircle } from "react-icons/bi"; +import { MdSettingsEthernet } from "react-icons/md"; +import { useTranslation } from "next-i18next"; +import { SiUbiquiti } from "react-icons/si"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Widget({ options }) { + const { t } = useTranslation(); + + // eslint-disable-next-line no-param-reassign + options.type = "unifi_console"; + const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites"); + + if (statsError || statsData?.error) { + return ( +
+
+
+ +
+ {t("widget.api_error")} + - +
+
+
+
+ ); + } + + const defaultSite = statsData?.data?.find(s => s.name === "default"); + + if (!defaultSite) { + return ( +
+
+
+ +
+
+ {t("unifi.wait")} +
+
+
+ ); + } + + const wan = defaultSite.health.find(h => h.subsystem === "wan"); + const lan = defaultSite.health.find(h => h.subsystem === "lan"); + const wlan = defaultSite.health.find(h => h.subsystem === "wlan"); + const data = { + name: wan.gw_name, + uptime: wan["gw_system-stats"].uptime, + up: wan.status === 'ok', + wlan: { + users: wlan.num_user, + status: wlan.status + }, + lan: { + users: lan.num_user, + status: lan.status + } + }; + + return ( +
+
+
+ +
+ {data.name} +
+
+
+
+
+ {t("common.number", { + value: data.uptime / 86400, + maximumFractionDigits: 1, + })} +
+
{t("unifi.days")}
+
+
+
{t("unifi.wan")}
+ { data.up + ? + : + } +
+
+
+
+
+ +
+
+ {t("common.number", { + value: data.wlan.users, + maximumFractionDigits: 0, + })} +
+
+
+
+ +
+
+ {t("common.number", { + value: data.lan.users, + maximumFractionDigits: 0, + })} +
+
+
+
+
+ ); +} diff --git a/src/components/widgets/widget.jsx b/src/components/widgets/widget.jsx index cc0288c2b..ac5353eb9 100644 --- a/src/components/widgets/widget.jsx +++ b/src/components/widgets/widget.jsx @@ -10,6 +10,7 @@ const widgetMappings = { greeting: dynamic(() => import("components/widgets/greeting/greeting")), datetime: dynamic(() => import("components/widgets/datetime/datetime")), logo: dynamic(() => import("components/widgets/logo/logo"), { ssr: false }), + unifi_console: dynamic(() => import("components/widgets/unifi_console/unifi_console")), }; export default function Widget({ widget }) { diff --git a/src/utils/proxy/api-helpers.js b/src/utils/proxy/api-helpers.js index 55cd333c5..904c9e967 100644 --- a/src/utils/proxy/api-helpers.js +++ b/src/utils/proxy/api-helpers.js @@ -2,7 +2,7 @@ export function formatApiCall(url, args) { const find = /\{.*?\}/g; const replace = (match) => { const key = match.replace(/\{|\}/g, ""); - return args[key]; + return args[key] || ""; }; return url.replace(/\/+$/, "").replace(find, replace); diff --git a/src/widgets/components.js b/src/widgets/components.js index 5357c0704..c2a6705ed 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -32,6 +32,7 @@ const components = { tautulli: dynamic(() => import("./tautulli/component")), traefik: dynamic(() => import("./traefik/component")), transmission: dynamic(() => import("./transmission/component")), + unifi: dynamic(() => import("./unifi/component")), }; export default components; diff --git a/src/widgets/unifi/component.jsx b/src/widgets/unifi/component.jsx new file mode 100644 index 000000000..8a654a516 --- /dev/null +++ b/src/widgets/unifi/component.jsx @@ -0,0 +1,61 @@ +import { useTranslation } from "next-i18next"; + +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 { t } = useTranslation(); + + const { widget } = service; + + const { data: statsData, error: statsError } = useWidgetAPI(widget, "stat/sites"); + + if (statsError || statsData?.error) { + return ; + } + + const wlanLabel = `${t("unifi.wlan")} ${t("unifi.users")}` + const lanLabel = `${t("unifi.lan")} ${t("unifi.users")}` + + const defaultSite = statsData?.data?.find(s => s.name === "default"); + + if (!defaultSite) { + return ( + + + + + + + ); + } + + const wan = defaultSite.health.find(h => h.subsystem === "wan"); + const lan = defaultSite.health.find(h => h.subsystem === "lan"); + const wlan = defaultSite.health.find(h => h.subsystem === "wlan"); + const data = { + name: wan.gw_name, + uptime: wan["gw_system-stats"].uptime, + up: wan.status === 'ok', + wlan: { + users: wlan.num_user, + status: wlan.status + }, + lan: { + users: lan.num_user, + status: lan.status + } + }; + + const uptime = `${t("common.number", { value: data.uptime / 86400, maximumFractionDigits: 1 })} ${t("unifi.days")}`; + + return ( + + + + + + + ); +} diff --git a/src/widgets/unifi/proxy.js b/src/widgets/unifi/proxy.js new file mode 100644 index 000000000..53ee49f03 --- /dev/null +++ b/src/widgets/unifi/proxy.js @@ -0,0 +1,119 @@ +import cache from "memory-cache"; + +import { formatApiCall } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; +import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar"; +import { getSettings } from "utils/config/config"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const udmpPrefix = "/proxy/network"; +const proxyName = "unifiProxyHandler"; +const prefixCacheKey = `${proxyName}__prefix`; +const logger = createLogger(proxyName); + +async function getWidget(req) { + const { group, service, type } = req.query; + + let widget = null; + if (type === "unifi_console") { + const settings = getSettings(); + widget = settings.unifi_console; + if (!widget) { + logger.debug("There is no unifi_console section in settings.yaml"); + return null; + } + widget.type = "unifi"; + } else { + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return null; + } + + widget = await getServiceWidget(group, service); + + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return null; + } + } + + return widget; +} + +async function login(widget) { + const endpoint = (widget.prefix === udmpPrefix) ? "auth/login" : "login"; + const api = widgets?.[widget.type]?.api?.replace("{prefix}", ""); // no prefix for login url + const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget })); + const loginBody = { username: widget.username, password: widget.password, remember: true }; + const headers = { "Content-Type": "application/json" }; + const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, { + method: "POST", + body: JSON.stringify(loginBody), + headers, + }); + return [status, contentType, data, responseHeaders]; +} + +export default async function unifiProxyHandler(req, res) { + const widget = await getWidget(req); + if (!widget) { + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const api = widgets?.[widget.type]?.api; + if (!api) { + return res.status(403).json({ error: "Service does not support API calls" }); + } + + let [status, contentType, data, responseHeaders] = []; + let prefix = cache.get(prefixCacheKey); + if (prefix === null) { + // auto detect if we're talking to a UDM Pro, and cache the result so that we + // don't make two requests each time data from Unifi is required + [status, contentType, data, responseHeaders] = await httpProxy(widget.url); + prefix = ""; + if (responseHeaders["x-csrf-token"]) { + prefix = udmpPrefix; + } + cache.put(prefixCacheKey, prefix); + } + + widget.prefix = prefix; + + const { endpoint } = req.query; + const url = new URL(formatApiCall(api, { endpoint, ...widget })); + const params = { method: "GET", headers: {} }; + setCookieHeader(url, params); + + [status, contentType, data, responseHeaders] = await httpProxy(url, params); + if (status === 401) { + logger.debug("Unifi isn't logged in or rejected the reqeust, attempting login."); + [status, contentType, data, responseHeaders] = await login(widget); + + if (status !== 200) { + logger.error("HTTP %d logging in to Unifi. Data: %s", status, data); + return res.status(status).end(data); + } + + const json = JSON.parse(data.toString()); + if (!(json?.meta?.rc === "ok" || json.login_time)) { + logger.error("Error logging in to Unifi: Data: %s", data); + return res.status(401).end(data); + } + + addCookieToJar(url, responseHeaders); + setCookieHeader(url, params); + + logger.debug("Retrying Unifi request after login."); + [status, contentType, data, responseHeaders] = await httpProxy(url, params); + } + + if (status !== 200) { + logger.error("HTTP %d getting data from Unifi endpoint %s. Data: %s", status, url.href, data); + } + + if (contentType) res.setHeader("Content-Type", contentType); + return res.status(status).send(data); +} diff --git a/src/widgets/unifi/widget.js b/src/widgets/unifi/widget.js new file mode 100644 index 000000000..928ebd766 --- /dev/null +++ b/src/widgets/unifi/widget.js @@ -0,0 +1,14 @@ +import unifiProxyHandler from "./proxy"; + +const widget = { + api: "{url}{prefix}/api/{endpoint}", + proxyHandler: unifiProxyHandler, + + mappings: { + "stat/sites": { + endpoint: "stat/sites", + }, + } +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 04665c782..a4cab76b0 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -27,6 +27,7 @@ import strelaysrv from "./strelaysrv/widget"; import tautulli from "./tautulli/widget"; import traefik from "./traefik/widget"; import transmission from "./transmission/widget"; +import unifi from "./unifi/widget"; const widgets = { adguard, @@ -59,6 +60,8 @@ const widgets = { tautulli, traefik, transmission, + unifi, + unifi_console: unifi }; export default widgets;