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 (
+
+
+
+
+
+
+ {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;