Browse Source

Merge pull request #341 from shamoon/main

Feature: UniFi Console Info & Service Widgets
Jason Fischer 2 years ago
parent
commit
85df467fdb

+ 11 - 0
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",

+ 119 - 0
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 (
+      <div className="flex flex-col justify-center first:ml-0 ml-4">
+        <div className="flex flex-row items-center justify-end">
+          <div className="flex flex-col items-center">
+            <BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
+            <div className="flex flex-col ml-3 text-left">
+              <span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
+              <span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  const defaultSite = statsData?.data?.find(s => s.name === "default");
+
+  if (!defaultSite) {
+    return (
+      <div className="flex flex-col justify-center first:ml-0 ml-4">
+        <div className="flex flex-row items-center justify-end">
+          <div className="flex flex-col items-center">
+            <SiUbiquiti className="w-5 h-5 text-theme-800 dark:text-theme-200" />
+          </div>
+          <div className="flex flex-col ml-3 text-left">
+            <span className="text-theme-800 dark:text-theme-200 text-xs">{t("unifi.wait")}</span>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  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 (
+    <div className="flex-none flex flex-row items-center mr-3 py-1.5">
+      <div className="flex flex-col">
+        <div className="flex flex-row ml-3">
+          <SiUbiquiti className="text-theme-800 dark:text-theme-200 w-3 h-3 mr-1" />
+          <div className="text-theme-800 dark:text-theme-200 text-xs font-bold flex flex-row justify-between">
+            {data.name}
+          </div>
+        </div>
+        <div className="flex flex-row ml-3 text-[10px] justify-between">
+          <div className="flex flex-row" title={t("unifi.uptime")}>
+            <div className="pr-0.5 text-theme-800 dark:text-theme-200">
+              {t("common.number", {
+                value: data.uptime / 86400,
+                maximumFractionDigits: 1,
+              })}
+            </div>
+            <div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.days")}</div>
+          </div>
+          <div className="flex flex-row">
+            <div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.wan")}</div>
+            { data.up
+              ? <BiCheckCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
+              : <BiXCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
+            }
+          </div>
+        </div>
+      </div>
+      <div className="flex flex-col">
+        <div className="flex flex-row ml-3 py-0.5">
+          <BiWifi className="text-theme-800 dark:text-theme-200 w-4 h-4 mr-1" />
+          <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between" title={t("unifi.users")}>
+            <div className="pr-0.5">
+              {t("common.number", {
+                value: data.wlan.users,
+                maximumFractionDigits: 0,
+              })}
+            </div>
+          </div>
+        </div>
+        <div className="flex flex-row ml-3 pb-0.5">
+          <MdSettingsEthernet className="text-theme-800 dark:text-theme-200 w-4 h-4 mr-1" />
+          <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between" title={t("unifi.users")}>
+            <div className="pr-0.5">
+              {t("common.number", {
+                value: data.lan.users,
+                maximumFractionDigits: 0,
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 1 - 0
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 }) {

+ 1 - 1
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);

+ 1 - 0
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;

+ 61 - 0
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 <Container error={t("widget.api_error")} />;
+    }
+
+    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 (
+        <Container service={service}>
+            <Block label="unifi.uptime" />
+            <Block label="unifi.wan" />
+            <Block label={ lanLabel } />
+            <Block label={ wlanLabel } />
+        </Container>
+        );
+    }
+
+    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 (
+        <Container service={service}>
+            <Block label="unifi.uptime" value={ uptime } />
+            <Block label="unifi.wan" value={ data.up ? t("unifi.up") : t("unifi.down") } />
+            <Block label={ lanLabel } value={t("common.number", { value: data.lan.users })} />
+            <Block label={ wlanLabel } value={t("common.number", { value: data.wlan.users })} />
+        </Container>
+    );
+}

+ 119 - 0
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);
+}

+ 14 - 0
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;

+ 3 - 0
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;