Pārlūkot izejas kodu

widget refactoring

Ben Phelps 2 gadi atpakaļ
vecāks
revīzija
035dd25ece

+ 2 - 5
src/pages/api/services/proxy.js

@@ -1,5 +1,3 @@
-import { URLSearchParams } from "next/dist/compiled/@edge-runtime/primitives/url";
-
 import createLogger from "utils/logger";
 import genericProxyHandler from "utils/proxies/generic";
 import widgets from "widgets/widgets";
@@ -35,10 +33,9 @@ export default async function handler(req, res) {
 
       if (req.query.params) {
         const queryParams = JSON.parse(req.query.params);
-        const query = new URLSearchParams(mappingParams.map(p => [p, queryParams[p]]));
+        const query = new URLSearchParams(mappingParams.map((p) => [p, queryParams[p]]));
         req.query.endpoint = `${endpoint}?${query}`;
-      }
-      else {
+      } else {
         req.query.endpoint = endpoint;
       }
 

+ 11 - 0
src/widgets/components.js

@@ -5,8 +5,19 @@ const components = {
   bazarr: dynamic(() => import("./bazarr/component")),
   coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
   overseerr: dynamic(() => import("./overseerr/component")),
+  portainer: dynamic(() => import("./portainer/component")),
+  prowlarr: dynamic(() => import("./prowlarr/component")),
+  qbittorrent: dynamic(() => import("./qbittorrent/component")),
   radarr: dynamic(() => import("./radarr/component")),
   sonarr: dynamic(() => import("./sonarr/component")),
+  readarr: dynamic(() => import("./readarr/component")),
+  rutorrent: dynamic(() => import("./rutorrent/component")),
+  sabnzbd: dynamic(() => import("./sabnzbd/component")),
+  speedtest: dynamic(() => import("./speedtest/component")),
+  strelaysrv: dynamic(() => import("./strelaysrv/component")),
+  tautulli: dynamic(() => import("./tautulli/component")),
+  traefik: dynamic(() => import("./traefik/component")),
+  transmission: dynamic(() => import("./transmission/component")),
 };
 
 export default components;

+ 48 - 0
src/widgets/portainer/component.jsx

@@ -0,0 +1,48 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: containersData, error: containersError } = useSWR(
+    formatProxyUrl(config, `docker/containers/json`, {
+      all: 1,
+    })
+  );
+
+  if (containersError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!containersData) {
+    return (
+      <Widget>
+        <Block label={t("portainer.running")} />
+        <Block label={t("portainer.stopped")} />
+        <Block label={t("portainer.total")} />
+      </Widget>
+    );
+  }
+
+  if (containersData.error) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  const running = containersData.filter((c) => c.State === "running").length;
+  const stopped = containersData.filter((c) => c.State === "exited").length;
+  const total = containersData.length;
+
+  return (
+    <Widget>
+      <Block label={t("portainer.running")} value={running} />
+      <Block label={t("portainer.stopped")} value={stopped} />
+      <Block label={t("portainer.total")} value={total} />
+    </Widget>
+  );
+}

+ 15 - 0
src/widgets/portainer/widget.js

@@ -0,0 +1,15 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/api/endpoints/{env}/{endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    "docker/containers/json": {
+      endpoint: "docker/containers/json",
+      params: ["all"],
+    },
+  },
+};
+
+export default widget;

+ 54 - 0
src/widgets/prowlarr/component.jsx

@@ -0,0 +1,54 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: indexersData, error: indexersError } = useSWR(formatProxyUrl(config, "indexer"));
+  const { data: grabsData, error: grabsError } = useSWR(formatProxyUrl(config, "indexerstats"));
+
+  if (indexersError || grabsError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!indexersData || !grabsData) {
+    return (
+      <Widget>
+        <Block label={t("prowlarr.enableIndexers")} />
+        <Block label={t("prowlarr.numberOfGrabs")} />
+        <Block label={t("prowlarr.numberOfQueries")} />
+        <Block label={t("prowlarr.numberOfFailGrabs")} />
+        <Block label={t("prowlarr.numberOfFailQueries")} />
+      </Widget>
+    );
+  }
+
+  const indexers = indexersData?.filter((indexer) => indexer.enable === true);
+
+  let numberOfGrabs = 0;
+  let numberOfQueries = 0;
+  let numberOfFailedGrabs = 0;
+  let numberOfFailedQueries = 0;
+  grabsData?.indexers?.forEach((element) => {
+    numberOfGrabs += element.numberOfGrabs;
+    numberOfQueries += element.numberOfQueries;
+    numberOfFailedGrabs += numberOfFailedGrabs + element.numberOfFailedGrabs;
+    numberOfFailedQueries += numberOfFailedQueries + element.numberOfFailedQueries;
+  });
+
+  return (
+    <Widget>
+      <Block label={t("prowlarr.enableIndexers")} value={indexers.length} />
+      <Block label={t("prowlarr.numberOfGrabs")} value={numberOfGrabs} />
+      <Block label={t("prowlarr.numberOfQueries")} value={numberOfQueries} />
+      <Block label={t("prowlarr.numberOfFailGrabs")} value={numberOfFailedGrabs} />
+      <Block label={t("prowlarr.numberOfFailQueries")} value={numberOfFailedQueries} />
+    </Widget>
+  );
+}

+ 17 - 0
src/widgets/prowlarr/widget.js

@@ -0,0 +1,17 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/api/v1/{endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    indexer: {
+      endpoint: "indexer",
+    },
+    indexerstats: {
+      endpoint: "indexerstats",
+    },
+  },
+};
+
+export default widget;

+ 68 - 0
src/widgets/qbittorrent/component.jsx

@@ -0,0 +1,68 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: torrentData, error: torrentError } = useSWR(formatProxyUrl(config, "torrents/info"));
+
+  if (torrentError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!torrentData) {
+    return (
+      <Widget>
+        <Block label={t("qbittorrent.leech")} />
+        <Block label={t("qbittorrent.download")} />
+        <Block label={t("qbittorrent.seed")} />
+        <Block label={t("qbittorrent.upload")} />
+      </Widget>
+    );
+  }
+
+  let rateDl = 0;
+  let rateUl = 0;
+  let completed = 0;
+
+  for (let i = 0; i < torrentData.length; i += 1) {
+    const torrent = torrentData[i];
+    rateDl += torrent.dlspeed;
+    rateUl += torrent.upspeed;
+    if (torrent.progress === 1) {
+      completed += 1;
+    }
+  }
+
+  const leech = torrentData.length - completed;
+
+  let unitsDl = "KB/s";
+  let unitsUl = "KB/s";
+  rateDl /= 1024;
+  rateUl /= 1024;
+
+  if (rateDl > 1024) {
+    rateDl /= 1024;
+    unitsDl = "MB/s";
+  }
+
+  if (rateUl > 1024) {
+    rateUl /= 1024;
+    unitsUl = "MB/s";
+  }
+
+  return (
+    <Widget>
+      <Block label={t("qbittorrent.leech")} value={t("common.number", { value: leech })} />
+      <Block label={t("qbittorrent.download")} value={`${rateDl.toFixed(2)} ${unitsDl}`} />
+      <Block label={t("qbittorrent.seed")} value={t("common.number", { value: completed })} />
+      <Block label={t("qbittorrent.upload")} value={`${rateUl.toFixed(2)} ${unitsUl}`} />
+    </Widget>
+  );
+}

+ 0 - 0
src/utils/proxies/qbittorrent.js → src/widgets/qbittorrent/proxy.js


+ 8 - 0
src/widgets/qbittorrent/widget.js

@@ -0,0 +1,8 @@
+import qbittorrentProxyHandler from "./proxy";
+
+const widget = {
+  api: "{url}/api/v2/{endpoint}",
+  proxyHandler: qbittorrentProxyHandler,
+};
+
+export default widget;

+ 38 - 0
src/widgets/readarr/component.jsx

@@ -0,0 +1,38 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: booksData, error: booksError } = useSWR(formatProxyUrl(config, "book"));
+  const { data: wantedData, error: wantedError } = useSWR(formatProxyUrl(config, "wanted/missing"));
+  const { data: queueData, error: queueError } = useSWR(formatProxyUrl(config, "queue/status"));
+
+  if (booksError || wantedError || queueError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!booksData || !wantedData || !queueData) {
+    return (
+      <Widget>
+        <Block label={t("readarr.wanted")} />
+        <Block label={t("readarr.queued")} />
+        <Block label={t("readarr.books")} />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label={t("readarr.wanted")} value={t("common.number", { value: wantedData.totalRecords })} />
+      <Block label={t("readarr.queued")} value={t("common.number", { value: queueData.totalCount })} />
+      <Block label={t("readarr.books")} value={t("common.number", { value: booksData.have })} />
+    </Widget>
+  );
+}

+ 24 - 0
src/widgets/readarr/widget.js

@@ -0,0 +1,24 @@
+import genericProxyHandler from "utils/proxies/generic";
+import { jsonArrayFilter } from "utils/api-helpers";
+
+const widget = {
+  api: "{url}/api/v1/{endpoint}?apikey={key}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    book: {
+      endpoint: "book",
+      map: (data) => ({
+        have: jsonArrayFilter(data, (item) => item?.statistics?.bookFileCount > 0).length,
+      }),
+    },
+    "queue/status": {
+      endpoint: "queue/status",
+    },
+    "wanted/missing": {
+      endpoint: "wanted/missing",
+    },
+  },
+};
+
+export default widget;

+ 42 - 0
src/widgets/rutorrent/component.jsx

@@ -0,0 +1,42 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: statusData, error: statusError } = useSWR(formatProxyUrl(config));
+
+  if (statusError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!statusData) {
+    return (
+      <Widget>
+        <Block label={t("rutorrent.active")} />
+        <Block label={t("rutorrent.upload")} />
+        <Block label={t("rutorrent.download")} />
+      </Widget>
+    );
+  }
+
+  const upload = statusData.reduce((acc, torrent) => acc + parseInt(torrent["d.get_up_rate"], 10), 0);
+
+  const download = statusData.reduce((acc, torrent) => acc + parseInt(torrent["d.get_down_rate"], 10), 0);
+
+  const active = statusData.filter((torrent) => torrent["d.get_state"] === "1");
+
+  return (
+    <Widget>
+      <Block label={t("rutorrent.active")} value={active.length} />
+      <Block label={t("rutorrent.upload")} value={t("common.bitrate", { value: upload })} />
+      <Block label={t("rutorrent.download")} value={t("common.bitrate", { value: download })} />
+    </Widget>
+  );
+}

+ 0 - 0
src/utils/proxies/rutorrent.js → src/widgets/rutorrent/proxy.js


+ 8 - 0
src/widgets/rutorrent/widget.js

@@ -0,0 +1,8 @@
+import rutorrentProxyHandler from "./proxy";
+
+const widget = {
+  api: "{url}/plugins/httprpc/action.php",
+  proxyHandler: rutorrentProxyHandler,
+};
+
+export default widget;

+ 36 - 0
src/widgets/sabnzbd/component.jsx

@@ -0,0 +1,36 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: queueData, error: queueError } = useSWR(formatProxyUrl(config, "queue"));
+
+  if (queueError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!queueData) {
+    return (
+      <Widget>
+        <Block label={t("sabnzbd.rate")} />
+        <Block label={t("sabnzbd.queue")} />
+        <Block label={t("sabnzbd.timeleft")} />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label={t("sabnzbd.rate")} value={`${queueData.queue.speed}B/s`} />
+      <Block label={t("sabnzbd.queue")} value={t("common.number", { value: queueData.queue.noofslots })} />
+      <Block label={t("sabnzbd.timeleft")} value={queueData.queue.timeleft} />
+    </Widget>
+  );
+}

+ 14 - 0
src/widgets/sabnzbd/widget.js

@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/api/?apikey={key}&output=json&mode={endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    queue: {
+      endpoint: "queue",
+    },
+  },
+};
+
+export default widget;

+ 4 - 4
src/widgets/sonarr/widget.js

@@ -6,19 +6,19 @@ const widget = {
   proxyHandler: genericProxyHandler,
 
   mappings: {
-    "series": {
+    series: {
       endpoint: "series",
       map: (data) => ({
         total: asJson(data).length,
       }),
     },
-    "queue": {
+    queue: {
       endpoint: "queue",
     },
     "wanted/missing": {
-        endpoint: "wanted/missing",
-      },
+      endpoint: "wanted/missing",
     },
+  },
 };
 
 export default widget;

+ 45 - 0
src/widgets/speedtest/component.jsx

@@ -0,0 +1,45 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: speedtestData, error: speedtestError } = useSWR(formatProxyUrl(config, "speedtest/latest"));
+
+  if (speedtestError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!speedtestData) {
+    return (
+      <Widget>
+        <Block label={t("speedtest.download")} />
+        <Block label={t("speedtest.upload")} />
+        <Block label={t("speedtest.ping")} />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block
+        label={t("speedtest.download")}
+        value={t("common.bitrate", { value: speedtestData.data.download * 1024 * 1024 })}
+      />
+      <Block
+        label={t("speedtest.upload")}
+        value={t("common.bitrate", { value: speedtestData.data.upload * 1024 * 1024 })}
+      />
+      <Block
+        label={t("speedtest.ping")}
+        value={t("common.ms", { value: speedtestData.data.ping, style: "unit", unit: "millisecond" })}
+      />
+    </Widget>
+  );
+}

+ 14 - 0
src/widgets/speedtest/widget.js

@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/api/{endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    "speedtest/latest": {
+      endpoint: "speedtest/latest",
+    },
+  },
+};
+
+export default widget;

+ 43 - 0
src/widgets/strelaysrv/component.jsx

@@ -0,0 +1,43 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: statsData, error: statsError } = useSWR(formatProxyUrl(config, `status`));
+
+  if (statsError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!statsData) {
+    return (
+      <Widget>
+        <Block label={t("strelaysrv.numActiveSessions")} />
+        <Block label={t("strelaysrv.numConnections")} />
+        <Block label={t("strelaysrv.bytesProxied")} />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block
+        label={t("strelaysrv.numActiveSessions")}
+        value={t("common.number", { value: statsData.numActiveSessions })}
+      />
+      <Block label={t("strelaysrv.numConnections")} value={t("common.number", { value: statsData.numConnections })} />
+      <Block label={t("strelaysrv.dataRelayed")} value={t("common.bytes", { value: statsData.bytesProxied })} />
+      <Block
+        label={t("strelaysrv.transferRate")}
+        value={t("common.bitrate", { value: statsData.kbps10s1m5m15m30m60m[5] })}
+      />
+    </Widget>
+  );
+}

+ 14 - 0
src/widgets/strelaysrv/widget.js

@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/{endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    status: {
+      endpoint: "status",
+    },
+  },
+};
+
+export default widget;

+ 182 - 0
src/widgets/tautulli/component.jsx

@@ -0,0 +1,182 @@
+/* eslint-disable camelcase */
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+import { BsFillPlayFill, BsPauseFill, BsCpu, BsFillCpuFill } from "react-icons/bs";
+import { MdOutlineSmartDisplay, MdSmartDisplay } from "react-icons/md";
+
+import Widget from "components/services/widgets/widget";
+import { formatProxyUrl } from "utils/api-helpers";
+
+function millisecondsToTime(milliseconds) {
+  const seconds = Math.floor((milliseconds / 1000) % 60);
+  const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
+  const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
+  return { hours, minutes, seconds };
+}
+
+function millisecondsToString(milliseconds) {
+  const { hours, minutes, seconds } = millisecondsToTime(milliseconds);
+  const parts = [];
+  if (hours > 0) {
+    parts.push(hours);
+  }
+  parts.push(minutes);
+  parts.push(seconds);
+
+  return parts.map((part) => part.toString().padStart(2, "0")).join(":");
+}
+
+function SingleSessionEntry({ session }) {
+  const { full_title, duration, view_offset, progress_percent, state, video_decision, audio_decision } = session;
+
+  return (
+    <>
+      <div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
+        <div className="text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2">
+          <div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden">{full_title}</div>
+        </div>
+        <div className="self-center text-xs flex justify-end mr-1.5 pl-1">
+          {video_decision === "direct play" && audio_decision === "direct play" && (
+            <MdSmartDisplay className="opacity-50" />
+          )}
+          {video_decision === "copy" && audio_decision === "copy" && <MdOutlineSmartDisplay className="opacity-50" />}
+          {video_decision !== "copy" &&
+            video_decision !== "direct play" &&
+            (audio_decision !== "copy" || audio_decision !== "direct play") && <BsFillCpuFill className="opacity-50" />}
+          {(video_decision === "copy" || video_decision === "direct play") &&
+            audio_decision !== "copy" &&
+            audio_decision !== "direct play" && <BsCpu className="opacity-50" />}
+        </div>
+      </div>
+
+      <div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
+        <div
+          className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
+          style={{
+            width: `${progress_percent}%`,
+          }}
+        />
+        <div className="text-xs z-10 self-center ml-1">
+          {state === "paused" && (
+            <BsPauseFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
+          )}
+          {state !== "paused" && (
+            <BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
+          )}
+        </div>
+        <div className="grow " />
+        <div className="self-center text-xs flex justify-end mr-2 z-10">
+          {millisecondsToString(view_offset)}
+          <span className="mx-0.5 text-[8px]">/</span>
+          {millisecondsToString(duration)}
+        </div>
+      </div>
+    </>
+  );
+}
+
+function SessionEntry({ session }) {
+  const { full_title, view_offset, progress_percent, state, video_decision, audio_decision } = session;
+
+  return (
+    <div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
+      <div
+        className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
+        style={{
+          width: `${progress_percent}%`,
+        }}
+      />
+      <div className="text-xs z-10 self-center ml-1">
+        {state === "paused" && (
+          <BsPauseFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
+        )}
+        {state !== "paused" && (
+          <BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
+        )}
+      </div>
+      <div className="text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2">
+        <div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden">{full_title}</div>
+      </div>
+      <div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10">
+        {video_decision === "direct play" && audio_decision === "direct play" && (
+          <MdSmartDisplay className="opacity-50" />
+        )}
+        {video_decision === "copy" && audio_decision === "copy" && <MdOutlineSmartDisplay className="opacity-50" />}
+        {video_decision !== "copy" &&
+          video_decision !== "direct play" &&
+          (audio_decision !== "copy" || audio_decision !== "direct play") && <BsFillCpuFill className="opacity-50" />}
+        {(video_decision === "copy" || video_decision === "direct play") &&
+          audio_decision !== "copy" &&
+          audio_decision !== "direct play" && <BsCpu className="opacity-50" />}
+      </div>
+      <div className="self-center text-xs flex justify-end mr-2 z-10">{millisecondsToString(view_offset)}</div>
+    </div>
+  );
+}
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: activityData, error: activityError } = useSWR(formatProxyUrl(config, "get_activity"), {
+    refreshInterval: 5000,
+  });
+
+  if (activityError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!activityData) {
+    return (
+      <div className="flex flex-col pb-1 mx-1">
+        <div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
+          <span className="absolute left-2 text-xs mt-[2px]">-</span>
+        </div>
+        <div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
+          <span className="absolute left-2 text-xs mt-[2px]">-</span>
+        </div>
+      </div>
+    );
+  }
+
+  const playing = activityData.response.data.sessions.sort((a, b) => {
+    if (a.view_offset > b.view_offset) {
+      return 1;
+    }
+    if (a.view_offset < b.view_offset) {
+      return -1;
+    }
+    return 0;
+  });
+
+  if (playing.length === 0) {
+    return (
+      <div className="flex flex-col pb-1 mx-1">
+        <div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
+          <span className="absolute left-2 text-xs mt-[2px]">{t("tautulli.no_active")}</span>
+        </div>
+        <div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
+          <span className="absolute left-2 text-xs mt-[2px]">-</span>
+        </div>
+      </div>
+    );
+  }
+
+  if (playing.length === 1) {
+    const session = playing[0];
+    return (
+      <div className="flex flex-col pb-1 mx-1">
+        <SingleSessionEntry session={session} />
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-col pb-1 mx-1">
+      {playing.map((session) => (
+        <SessionEntry key={session.Id} session={session} />
+      ))}
+    </div>
+  );
+}

+ 14 - 0
src/widgets/tautulli/widget.js

@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/api/v2?apikey={key}&cmd={endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    get_activity: {
+      endpoint: "get_activity",
+    },
+  },
+};
+
+export default widget;

+ 36 - 0
src/widgets/traefik/component.jsx

@@ -0,0 +1,36 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: traefikData, error: traefikError } = useSWR(formatProxyUrl(config, "overview"));
+
+  if (traefikError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!traefikData) {
+    return (
+      <Widget>
+        <Block label={t("traefik.routers")} />
+        <Block label={t("traefik.services")} />
+        <Block label={t("traefik.middleware")} />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label={t("traefik.routers")} value={traefikData.http.routers.total} />
+      <Block label={t("traefik.services")} value={traefikData.http.services.total} />
+      <Block label={t("traefik.middleware")} value={traefikData.http.middlewares.total} />
+    </Widget>
+  );
+}

+ 14 - 0
src/widgets/traefik/widget.js

@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/api/{endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    overview: {
+      endpoint: "overview",
+    },
+  },
+};
+
+export default widget;

+ 69 - 0
src/widgets/transmission/component.jsx

@@ -0,0 +1,69 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: torrentData, error: torrentError } = useSWR(formatProxyUrl(config));
+
+  if (torrentError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!torrentData) {
+    return (
+      <Widget>
+        <Block label={t("transmission.leech")} />
+        <Block label={t("transmission.download")} />
+        <Block label={t("transmission.seed")} />
+        <Block label={t("transmission.upload")} />
+      </Widget>
+    );
+  }
+
+  const { torrents } = torrentData.arguments;
+  let rateDl = 0;
+  let rateUl = 0;
+  let completed = 0;
+
+  for (let i = 0; i < torrents.length; i += 1) {
+    const torrent = torrents[i];
+    rateDl += torrent.rateDownload;
+    rateUl += torrent.rateUpload;
+    if (torrent.percentDone === 1) {
+      completed += 1;
+    }
+  }
+
+  const leech = torrents.length - completed;
+
+  let unitsDl = "KB/s";
+  let unitsUl = "KB/s";
+  rateDl /= 1024;
+  rateUl /= 1024;
+
+  if (rateDl > 1024) {
+    rateDl /= 1024;
+    unitsDl = "MB/s";
+  }
+
+  if (rateUl > 1024) {
+    rateUl /= 1024;
+    unitsUl = "MB/s";
+  }
+
+  return (
+    <Widget>
+      <Block label={t("transmission.leech")} value={t("common.number", { value: leech })} />
+      <Block label={t("transmission.download")} value={`${rateDl.toFixed(2)} ${unitsDl}`} />
+      <Block label={t("transmission.seed")} value={t("common.number", { value: completed })} />
+      <Block label={t("transmission.upload")} value={`${rateUl.toFixed(2)} ${unitsUl}`} />
+    </Widget>
+  );
+}

+ 0 - 0
src/utils/proxies/transmission.js → src/widgets/transmission/proxy.js


+ 8 - 0
src/widgets/transmission/widget.js

@@ -0,0 +1,8 @@
+import transmissionProxyHandler from "./proxy";
+
+const widget = {
+  api: "{url}/transmission/rpc",
+  proxyHandler: transmissionProxyHandler,
+};
+
+export default widget;

+ 23 - 1
src/widgets/widgets.js

@@ -2,16 +2,38 @@ import adguard from "./adguard/widget";
 import bazarr from "./bazarr/widget";
 import coinmarketcap from "./coinmarketcap/widget";
 import overseerr from "./overseerr/widget";
+import portainer from "./portainer/widget";
+import prowlarr from "./prowlarr/widget";
+import qbittorrent from "./qbittorrent/widget";
 import radarr from "./radarr/widget";
-import sonarr from "./sonarr/widget"
+import sonarr from "./sonarr/widget";
+import readarr from "./readarr/widget";
+import rutorrent from "./rutorrent/widget";
+import sabnzbd from "./sabnzbd/widget";
+import speedtest from "./speedtest/widget";
+import strelaysrv from "./strelaysrv/widget";
+import tautulli from "./tautulli/widget";
+import traefik from "./traefik/widget";
+import transmission from "./transmission/widget";
 
 const widgets = {
   adguard,
   bazarr,
   coinmarketcap,
   overseerr,
+  portainer,
+  prowlarr,
+  qbittorrent,
   radarr,
   sonarr,
+  readarr,
+  rutorrent,
+  sabnzbd,
+  speedtest,
+  strelaysrv,
+  tautulli,
+  traefik,
+  transmission,
 };
 
 export default widgets;