From 4c6150a545903836c30d0ff64be07dd47700fcc4 Mon Sep 17 00:00:00 2001
From: Bobby Driggs <bobbydriggs@gmail.com>
Date: Thu, 29 Aug 2024 10:51:36 -0700
Subject: [PATCH] Feature: Technitium DNS Widget (#3904)

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
 docs/widgets/services/index.md       |   1 +
 docs/widgets/services/technitium.md  |  26 ++++++
 mkdocs.yml                           |   1 +
 public/locales/en/common.json        |  13 +++
 src/utils/config/service-helpers.js  |   6 ++
 src/widgets/components.js            |   1 +
 src/widgets/technitium/component.jsx | 121 +++++++++++++++++++++++++++
 src/widgets/technitium/widget.js     |  17 ++++
 src/widgets/widgets.js               |   2 +
 9 files changed, 188 insertions(+)
 create mode 100644 docs/widgets/services/technitium.md
 create mode 100644 src/widgets/technitium/component.jsx
 create mode 100644 src/widgets/technitium/widget.js

diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index c39ac0f0..61d11df2 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -113,6 +113,7 @@ You can also find a list of all available service widgets in the sidebar navigat
 - [Syncthing Relay Server](syncthing-relay-server.md)
 - [Tailscale](tailscale.md)
 - [Tandoor](tandoor.md)
+- [Technitium DNS](technitium.md)
 - [TDarr](tdarr.md)
 - [Traefik](traefik.md)
 - [Transmission](transmission.md)
diff --git a/docs/widgets/services/technitium.md b/docs/widgets/services/technitium.md
new file mode 100644
index 00000000..70f5e48f
--- /dev/null
+++ b/docs/widgets/services/technitium.md
@@ -0,0 +1,26 @@
+---
+title: Technitium DNS Server
+description: Technitium DNS Server Widget Configuration
+---
+
+Learn more about [Technitium DNS Server](https://technitium.com/dns/).
+
+Allowed fields (up to 4): `["totalQueries","totalNoError","totalServerFailure","totalNxDomain","totalRefused","totalAuthoritative","totalRecursive","totalCached","totalBlocked","totalDropped","totalClients"]`.
+
+Defaults to: `["totalQueries", "totalAuthoritative", "totalCached", "totalServerFailure"]`
+
+```yaml
+widget:
+  type: technitium
+  url: <url to dns server>
+  key: biglongapitoken
+  range: LastDay # optional, defaults to LastHour
+```
+
+#### API Key
+
+This can be generated via the Technitium DNS Dashboard, and should be generated from a special API specific user.
+
+#### Range
+
+`range` value determines how far back of statistics to pull data for. The value comes directly from Technitium API documentation found [here](https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md#dashboard-api-calls), defined as `"type"`. The value can be one of: `LastHour`, `LastDay`, `LastWeek`, `LastMonth`, `LastYear`.
diff --git a/mkdocs.yml b/mkdocs.yml
index d48cc35e..a8096ad2 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -137,6 +137,7 @@ nav:
           - widgets/services/syncthing-relay-server.md
           - widgets/services/tailscale.md
           - widgets/services/tandoor.md
+          - widgets/services/technitium.md
           - widgets/services/tdarr.md
           - widgets/services/traefik.md
           - widgets/services/transmission.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index fabf5d84..786d98f0 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -323,6 +323,19 @@
         "seconds": "{{number}}s",
         "ago": "{{value}} Ago"
     },
+    "technitium": {
+        "totalQueries": "Queries",
+        "totalNoError": "Success",
+        "totalServerFailure": "Failures",
+        "totalNxDomain": "NX Domains",
+        "totalRefused": "Refused",
+        "totalAuthoritative": "Authoritative",
+        "totalRecursive": "Recursive",
+        "totalCached": "Cached",
+        "totalBlocked": "Blocked",
+        "totalDropped": "Dropped",
+        "totalClients": "Clients"
+    },
     "tdarr": {
         "queue": "Queue",
         "processed": "Processed",
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index 3dde943d..e8070bc7 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -473,6 +473,9 @@ export function cleanServiceGroups(groups) {
 
           // wgeasy
           threshold,
+
+          // technitium
+          range,
         } = cleanedService.widget;
 
         let fieldsList = fields;
@@ -617,6 +620,9 @@ export function cleanServiceGroups(groups) {
         if (type === "frigate") {
           if (enableRecentEvents !== undefined) cleanedService.widget.enableRecentEvents = enableRecentEvents;
         }
+        if (type === "technitium") {
+          if (range !== undefined) cleanedService.widget.range = range;
+        }
       }
 
       return cleanedService;
diff --git a/src/widgets/components.js b/src/widgets/components.js
index b2d6659e..3d800d0e 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -112,6 +112,7 @@ const components = {
   tailscale: dynamic(() => import("./tailscale/component")),
   tandoor: dynamic(() => import("./tandoor/component")),
   tautulli: dynamic(() => import("./tautulli/component")),
+  technitium: dynamic(() => import("./technitium/component")),
   tdarr: dynamic(() => import("./tdarr/component")),
   traefik: dynamic(() => import("./traefik/component")),
   transmission: dynamic(() => import("./transmission/component")),
diff --git a/src/widgets/technitium/component.jsx b/src/widgets/technitium/component.jsx
new file mode 100644
index 00000000..76165763
--- /dev/null
+++ b/src/widgets/technitium/component.jsx
@@ -0,0 +1,121 @@
+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";
+
+const MAX_ALLOWED_FIELDS = 4;
+
+export const technitiumDefaultFields = ["totalQueries", "totalAuthoritative", "totalCached", "totalServerFailure"];
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const { widget } = service;
+
+  const params = {
+    type: widget.range ?? "LastHour",
+  };
+
+  const { data: statsData, error: statsError } = useWidgetAPI(widget, "stats", params);
+
+  // Default fields
+  if (!widget.fields?.length > 0) {
+    widget.fields = technitiumDefaultFields;
+  }
+
+  // Limits max number of displayed fields
+  if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
+    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+  }
+
+  if (statsError) {
+    return <Container service={service} error={statsError} />;
+  }
+
+  if (!statsData) {
+    return (
+      <Container service={service}>
+        <Block label="technitium.totalQueries" />
+        <Block label="technitium.totalNoError" />
+        <Block label="technitium.totalServerFailure" />
+        <Block label="technitium.totalNxDomain" />
+        <Block label="technitium.totalRefused" />
+        <Block label="technitium.totalAuthoritative" />
+        <Block label="technitium.totalRecursive" />
+        <Block label="technitium.totalCached" />
+        <Block label="technitium.totalBlocked" />
+        <Block label="technitium.totalDropped" />
+        <Block label="technitium.totalClients" />
+      </Container>
+    );
+  }
+
+  function toPercent(value, total) {
+    return t("common.percent", {
+      value: !Number.isNaN(value / total) ? value / total : 0,
+      maximumFractionDigits: 2,
+    });
+  }
+
+  return (
+    <Container service={service}>
+      <Block label="technitium.totalQueries" value={`${t("common.number", { value: statsData.totalQueries })}`} />
+      <Block
+        label="technitium.totalNoError"
+        value={`${t("common.number", { value: statsData.totalNoError })} (${toPercent(
+          statsData.totalNoError / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalServerFailure"
+        value={`${t("common.number", { value: statsData.totalServerFailure })} (${toPercent(
+          statsData.totalServerFailure / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalNxDomain"
+        value={`${t("common.number", { value: statsData.totalNxDomain })} (${toPercent(
+          statsData.totalNxDomain / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalRefused"
+        value={`${t("common.number", { value: statsData.totalRefused })} (${toPercent(
+          statsData.totalRefused / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalAuthoritative"
+        value={`${t("common.number", { value: statsData.totalAuthoritative })} (${toPercent(
+          statsData.totalAuthoritative / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalRecursive"
+        value={`${t("common.number", { value: statsData.totalRecursive })} (${toPercent(
+          statsData.totalRecursive / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalCached"
+        value={`${t("common.number", { value: statsData.totalCached })} (${toPercent(
+          statsData.totalCached / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalBlocked"
+        value={`${t("common.number", { value: statsData.totalBlocked })} (${toPercent(
+          statsData.totalBlocked / statsData.totalQueries,
+        )})`}
+      />
+      <Block
+        label="technitium.totalDropped"
+        value={`${t("common.number", { value: statsData.totalDropped })} (${toPercent(
+          statsData.totalDropped / statsData.totalQueries,
+        )})`}
+      />
+      <Block label="technitium.totalClients" value={`${t("common.number", { value: statsData.totalClients })}`} />
+    </Container>
+  );
+}
diff --git a/src/widgets/technitium/widget.js b/src/widgets/technitium/widget.js
new file mode 100644
index 00000000..fb0d42bd
--- /dev/null
+++ b/src/widgets/technitium/widget.js
@@ -0,0 +1,17 @@
+import genericProxyHandler from "utils/proxy/handlers/generic";
+import { asJson } from "utils/proxy/api-helpers";
+
+const widget = {
+  api: "{url}/api/{endpoint}?token={key}&utc=true",
+  proxyHandler: genericProxyHandler,
+  mappings: {
+    stats: {
+      endpoint: "dashboard/stats/get",
+      validate: ["response", "status"],
+      params: ["type"],
+      map: (data) => asJson(data).response.stats,
+    },
+  },
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index f4e55d57..ed468e88 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -103,6 +103,7 @@ import swagdashboard from "./swagdashboard/widget";
 import tailscale from "./tailscale/widget";
 import tandoor from "./tandoor/widget";
 import tautulli from "./tautulli/widget";
+import technitium from "./technitium/widget";
 import tdarr from "./tdarr/widget";
 import traefik from "./traefik/widget";
 import transmission from "./transmission/widget";
@@ -228,6 +229,7 @@ const widgets = {
   tailscale,
   tandoor,
   tautulli,
+  technitium,
   tdarr,
   traefik,
   transmission,