Ben Phelps 2 роки тому
батько
коміт
94e9d66bec

+ 2 - 3
src/components/services/item.jsx

@@ -2,12 +2,11 @@ import Image from "next/future/image";
 import { useState } from "react";
 import { Disclosure, Transition } from "@headlessui/react";
 
-import StatsList from "./stats/list";
 import Status from "./status";
 import Widget from "./widget";
+import Docker from "./widgets/service/docker";
 
 export default function Item({ service }) {
-  const [statsOpen, setStatsOpen] = useState(false);
   return (
     <li key={service.name} className="">
       <Disclosure>
@@ -46,7 +45,7 @@ export default function Item({ service }) {
 
           <Disclosure.Panel>
             <div className="w-full">
-              <StatsList service={service} />
+              <Docker service={{ widget: { container: service.container, server: service.server } }} />
             </div>
           </Disclosure.Panel>
 

+ 0 - 70
src/components/services/stats/list.jsx

@@ -1,70 +0,0 @@
-import useSWR from "swr";
-import { calculateCPUPercent, formatBytes } from "utils/stats-helpers";
-import Stat from "./stat";
-
-export default function Stats({ service }) {
-  // fast
-  const { data: statusData, error: statusError } = useSWR(
-    `/api/docker/status/${service.container}/${service.server || ""}`,
-    {
-      refreshInterval: 1500,
-    }
-  );
-
-  // takes a full second to collect stats
-  const { data: statsData, error: statsError } = useSWR(
-    `/api/docker/stats/${service.container}/${service.server || ""}`,
-    {
-      refreshInterval: 1500,
-    }
-  );
-
-  // handle errors first
-  if (statsError || statusError) {
-    return (
-      <div className="flex flex-row w-full">
-        <Stat label="STATUS" value="Error Fetching Data" />
-      </div>
-    );
-  }
-
-  // handle the case where we get a docker error
-  if (statusData.status !== "running") {
-    return (
-      <div className="flex flex-row w-full">
-        <Stat label="STATUS" value="Error Fetching Data" />
-      </div>
-    );
-  }
-
-  // handle the case where the container is offline
-  if (statusData.status !== "running") {
-    return (
-      <div className="flex flex-row w-full">
-        <Stat label="STATUS" value="Offline" />
-      </div>
-    );
-  }
-
-  // handle the case where we don't have anything yet
-  if (!statsData || !statusData) {
-    return (
-      <div className="flex flex-row w-full">
-        <Stat label="CPU" value="-" />
-        <Stat label="MEM" value="-" />
-        <Stat label="RX" value="-" />
-        <Stat label="TX" value="-" />
-      </div>
-    );
-  }
-
-  // we have stats and the container is running
-  return (
-    <div className="flex flex-row w-full">
-      <Stat label="CPU" value={calculateCPUPercent(statsData.stats) + "%"} />
-      <Stat label="MEM" value={formatBytes(statsData.stats.memory_stats.usage, 0)} />
-      <Stat label="RX" value={formatBytes(statsData.stats.networks.eth0.rx_bytes, 0)} />
-      <Stat label="TX" value={formatBytes(statsData.stats.networks.eth0.tx_bytes, 0)} />
-    </div>
-  );
-}

+ 0 - 8
src/components/services/stats/stat.jsx

@@ -1,8 +0,0 @@
-export default function Stat({ value, label }) {
-  return (
-    <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-      <div className="font-thin text-sm">{value}</div>
-      <div className="font-bold text-xs">{label}</div>
-    </div>
-  );
-}

+ 8 - 6
src/components/services/widget.jsx

@@ -1,11 +1,13 @@
-import Sonarr from "./widgets/sonarr";
-import Radarr from "./widgets/radarr";
-import Ombi from "./widgets/ombi";
-import Portainer from "./widgets/portainer";
-import Emby from "./widgets/emby";
-import Nzbget from "./widgets/nzbget";
+import Sonarr from "./widgets/service/sonarr";
+import Radarr from "./widgets/service/radarr";
+import Ombi from "./widgets/service/ombi";
+import Portainer from "./widgets/service/portainer";
+import Emby from "./widgets/service/emby";
+import Nzbget from "./widgets/service/nzbget";
+import Docker from "./widgets/service/docker";
 
 const widgetMappings = {
+  docker: Docker,
   sonarr: Sonarr,
   radarr: Radarr,
   ombi: Ombi,

+ 8 - 0
src/components/services/widgets/block.jsx

@@ -0,0 +1,8 @@
+export default function Block({ value, label }) {
+  return (
+    <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
+      <div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div>
+      <div className="font-bold text-xs uppercase">{label}</div>
+    </div>
+  );
+}

+ 0 - 64
src/components/services/widgets/emby.jsx

@@ -1,64 +0,0 @@
-import useSWR from "swr";
-
-export default function Emby({ service }) {
-  const config = service.widget;
-
-  function buildApiUrl(endpoint) {
-    const { url, key } = config;
-    return `${url}/emby/${endpoint}?api_key=${key}`;
-  }
-
-  const { data: sessionsData, error: sessionsError } = useSWR(buildApiUrl(`Sessions`), {
-    refreshInterval: 1000,
-  });
-
-  if (sessionsError) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Emby API Error</div>
-      </div>
-    );
-  }
-
-  if (!sessionsData) {
-    return (
-      <div className="flex flex-row w-full">
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">PLAYING</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">TRANSCODE</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">BITRATE</div>
-        </div>
-      </div>
-    );
-  }
-
-  const playing = sessionsData.filter((session) => session.hasOwnProperty("NowPlayingItem"));
-  const transcoding = sessionsData.filter(
-    (session) => session.hasOwnProperty("PlayState") && session.PlayState.PlayMethod === "Transcode"
-  );
-  const bitrate = playing.reduce((acc, session) => acc + session.NowPlayingItem.Bitrate, 0);
-
-  return (
-    <div className="flex flex-row w-full">
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{playing.length}</div>
-        <div className="font-bold text-xs">PLAYING</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{transcoding.length}</div>
-        <div className="font-bold text-xs">TRANSCODE</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{Math.round((bitrate / 1024 / 1024) * 100) / 100} Mbps</div>
-        <div className="font-bold text-xs">BITRATE</div>
-      </div>
-    </div>
-  );
-}

+ 0 - 83
src/components/services/widgets/nzbget.jsx

@@ -1,83 +0,0 @@
-import useSWR from "swr";
-import { JSONRPCClient } from "json-rpc-2.0";
-
-import { formatBytes } from "utils/stats-helpers";
-
-export default function Nzbget({ service }) {
-  const config = service.widget;
-
-  const constructedUrl = new URL(config.url);
-  constructedUrl.pathname = "jsonrpc";
-
-  const client = new JSONRPCClient((jsonRPCRequest) =>
-    fetch(constructedUrl.toString(), {
-      method: "POST",
-      headers: {
-        "content-type": "application/json",
-        authorization: `Basic ${btoa(`${config.username}:${config.password}`)}`,
-      },
-      body: JSON.stringify(jsonRPCRequest),
-    }).then(async (response) => {
-      if (response.status === 200) {
-        const jsonRPCResponse = await response.json();
-        return client.receive(jsonRPCResponse);
-      } else if (jsonRPCRequest.id !== undefined) {
-        return Promise.reject(new Error(response.statusText));
-      }
-    })
-  );
-
-  const { data: statusData, error: statusError } = useSWR(
-    "status",
-    (resource) => {
-      return client.request(resource).then((response) => response);
-    },
-    {
-      refreshInterval: 1000,
-    }
-  );
-
-  if (statusError) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Nzbget API Error</div>
-      </div>
-    );
-  }
-
-  if (!statusData) {
-    return (
-      <div className="flex flex-row w-full">
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">RATE</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">REMAINING</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">DOWNLOADED</div>
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <div className="flex flex-row w-full">
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{formatBytes(statusData.DownloadRate)}/s</div>
-        <div className="font-bold text-xs">RATE</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{Math.round((statusData.RemainingSizeMB / 1024) * 100) / 100} GB</div>
-        <div className="font-bold text-xs">REMAINING</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{Math.round((statusData.DownloadedSizeMB / 1024) * 100) / 100} GB</div>
-        <div className="font-bold text-xs">DOWNLOADED</div>
-      </div>
-    </div>
-  );
-}

+ 0 - 71
src/components/services/widgets/ombi.jsx

@@ -1,71 +0,0 @@
-import useSWR from "swr";
-
-export default function Ombi({ service }) {
-  const config = service.widget;
-
-  function buildApiUrl(endpoint) {
-    const { url } = config;
-    return `${url}/api/v1/${endpoint}`;
-  }
-
-  const fetcher = (url) => {
-    return fetch(url, {
-      method: "GET",
-      withCredentials: true,
-      credentials: "include",
-      headers: {
-        ApiKey: `${config.key}`,
-        "Content-Type": "application/json",
-      },
-    }).then((res) => res.json());
-  };
-
-  const { data: statsData, error: statsError } = useSWR(
-    buildApiUrl(`Request/count`),
-    fetcher
-  );
-
-  if (statsError) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Ombi API Error</div>
-      </div>
-    );
-  }
-
-  if (!statsData) {
-    return (
-      <div className="flex flex-row w-full">
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">COMPLETED</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">QUEUED</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">TOTAL</div>
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <div className="flex flex-row w-full">
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{statsData.pending}</div>
-        <div className="font-bold text-xs">PENDING</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{statsData.approved}</div>
-        <div className="font-bold text-xs">APPROVED</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{statsData.available}</div>
-        <div className="font-bold text-xs">AVAILABLE</div>
-      </div>
-    </div>
-  );
-}

+ 0 - 81
src/components/services/widgets/portainer.jsx

@@ -1,81 +0,0 @@
-import useSWR from "swr";
-
-export default function Portainer({ service }) {
-  const config = service.widget;
-
-  function buildApiUrl(endpoint) {
-    const { url, env } = config;
-    const reqUrl = new URL(`/api/endpoints/${env}/${endpoint}`, url);
-    return `/api/proxy?url=${encodeURIComponent(reqUrl)}`;
-  }
-
-  const fetcher = (url) => {
-    return fetch(url, {
-      method: "GET",
-      withCredentials: true,
-      credentials: "include",
-      headers: {
-        "X-API-Key": `${config.key}`,
-        "Content-Type": "application/json",
-      },
-    }).then((res) => res.json());
-  };
-
-  const { data: containersData, error: containersError } = useSWR(buildApiUrl(`docker/containers/json`), fetcher);
-
-  if (containersError) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Portainer API Error</div>
-      </div>
-    );
-  }
-
-  if (!containersData) {
-    return (
-      <div className="flex flex-row w-full">
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">RUNNING</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">STOPPED</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">TOTAL</div>
-        </div>
-      </div>
-    );
-  }
-
-  if (containersData.error) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Portainer API Error</div>
-      </div>
-    );
-  }
-
-  const running = containersData.filter((c) => c.State === "running").length;
-  const stopped = containersData.filter((c) => c.State === "exited").length;
-  const total = containersData.length;
-
-  return (
-    <div className="flex flex-row w-full">
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{running}</div>
-        <div className="font-bold text-xs">RUNNING</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{stopped}</div>
-        <div className="font-bold text-xs">STOPPED</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{total}</div>
-        <div className="font-bold text-xs">TOTAL</div>
-      </div>
-    </div>
-  );
-}

+ 0 - 63
src/components/services/widgets/radarr.jsx

@@ -1,63 +0,0 @@
-import useSWR from "swr";
-
-export default function Radarr({ service }) {
-  const config = service.widget;
-
-  function buildApiUrl(endpoint) {
-    const { url, key } = config;
-    return `${url}/api/v3/${endpoint}?apikey=${key}`;
-  }
-
-  const { data: moviesData, error: moviesError } = useSWR(buildApiUrl("movie"));
-
-  const { data: queuedData, error: queuedError } = useSWR(
-    buildApiUrl("queue/status")
-  );
-
-  if (moviesError || queuedError) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Radarr API Error</div>
-      </div>
-    );
-  }
-
-  if (!moviesData || !queuedData) {
-    return (
-      <div className="flex flex-row w-full">
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">WANTED</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">QUEUED</div>
-        </div>
-        <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-          <div className="font-thin text-sm">-</div>
-          <div className="font-bold text-xs">MOVIES</div>
-        </div>
-      </div>
-    );
-  }
-
-  const wanted = moviesData.filter((movie) => movie.isAvailable === false);
-  const have = moviesData.filter((movie) => movie.isAvailable === true);
-
-  return (
-    <div className="flex flex-row w-full">
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{wanted.length}</div>
-        <div className="font-bold text-xs">WANTED</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{queuedData.totalCount}</div>
-        <div className="font-bold text-xs">QUEUED</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{moviesData.length}</div>
-        <div className="font-bold text-xs">MOVIES</div>
-      </div>
-    </div>
-  );
-}

+ 56 - 0
src/components/services/widgets/service/docker.jsx

@@ -0,0 +1,56 @@
+import useSWR from "swr";
+
+import { calculateCPUPercent, formatBytes } from "utils/stats-helpers";
+
+import Widget from "../widget";
+import Block from "../block";
+
+export default function Docker({ service }) {
+  const config = service.widget;
+
+  const { data: statusData, error: statusError } = useSWR(
+    `/api/docker/status/${config.container}/${config.server || ""}`,
+    {
+      refreshInterval: 1500,
+    }
+  );
+
+  const { data: statsData, error: statsError } = useSWR(
+    `/api/docker/stats/${config.container}/${config.server || ""}`,
+    {
+      refreshInterval: 1500,
+    }
+  );
+
+  if (statsError || statusError) {
+    return <Widget error="Error Fetching Data" />;
+  }
+
+  if (statusData && statusData.status !== "running") {
+    return (
+      <Widget>
+        <Block label="Status" value="Offline" />
+      </Widget>
+    );
+  }
+
+  if (!statsData || !statusData) {
+    return (
+      <Widget>
+        <Block label="CPU" />
+        <Block label="MEM" />
+        <Block label="RX" />
+        <Block label="TX" />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label="CPU" value={`${calculateCPUPercent(statsData.stats)}%`} />
+      <Block label="MEM" value={formatBytes(statsData.stats.memory_stats.usage, 0)} />
+      <Block label="RX" value={formatBytes(statsData.stats.networks.eth0.rx_bytes, 0)} />
+      <Block label="TX" value={formatBytes(statsData.stats.networks.eth0.tx_bytes, 0)} />
+    </Widget>
+  );
+}

+ 45 - 0
src/components/services/widgets/service/emby.jsx

@@ -0,0 +1,45 @@
+import useSWR from "swr";
+
+import Widget from "../widget";
+import Block from "../block";
+
+export default function Emby({ service }) {
+  const config = service.widget;
+
+  function buildApiUrl(endpoint) {
+    const { url, key } = config;
+    return `${url}/emby/${endpoint}?api_key=${key}`;
+  }
+
+  const { data: sessionsData, error: sessionsError } = useSWR(buildApiUrl(`Sessions`), {
+    refreshInterval: 1000,
+  });
+
+  if (sessionsError) {
+    return <Widget error="Emby API Error" />;
+  }
+
+  if (!sessionsData) {
+    return (
+      <Widget>
+        <Block label="Playing" />
+        <Block label="Transcoding" />
+        <Block label="Bitrate" />
+      </Widget>
+    );
+  }
+
+  const playing = sessionsData.filter((session) => session.hasOwnProperty("NowPlayingItem"));
+  const transcoding = sessionsData.filter(
+    (session) => session.hasOwnProperty("PlayState") && session.PlayState.PlayMethod === "Transcode"
+  );
+  const bitrate = playing.reduce((acc, session) => acc + session.NowPlayingItem.Bitrate, 0);
+
+  return (
+    <Widget>
+      <Block label="Playing" value={playing.length} />
+      <Block label="Transcoding" value={transcoding.length} />
+      <Block label="Bitrate" value={`${Math.round((bitrate / 1024 / 1024) * 100) / 100} Mbps`} />
+    </Widget>
+  );
+}

+ 64 - 0
src/components/services/widgets/service/nzbget.jsx

@@ -0,0 +1,64 @@
+import useSWR from "swr";
+import { JSONRPCClient } from "json-rpc-2.0";
+
+import { formatBytes } from "utils/stats-helpers";
+
+import Widget from "../widget";
+import Block from "../block";
+
+export default function Nzbget({ service }) {
+  const config = service.widget;
+
+  const constructedUrl = new URL(config.url);
+  constructedUrl.pathname = "jsonrpc";
+
+  const client = new JSONRPCClient((jsonRPCRequest) =>
+    fetch(constructedUrl.toString(), {
+      method: "POST",
+      headers: {
+        "content-type": "application/json",
+        authorization: `Basic ${btoa(`${config.username}:${config.password}`)}`,
+      },
+      body: JSON.stringify(jsonRPCRequest),
+    }).then(async (response) => {
+      if (response.status === 200) {
+        const jsonRPCResponse = await response.json();
+        return client.receive(jsonRPCResponse);
+      } else if (jsonRPCRequest.id !== undefined) {
+        return Promise.reject(new Error(response.statusText));
+      }
+    })
+  );
+
+  const { data: statusData, error: statusError } = useSWR(
+    "status",
+    (resource) => {
+      return client.request(resource).then((response) => response);
+    },
+    {
+      refreshInterval: 1000,
+    }
+  );
+
+  if (statusError) {
+    return <Widget error="Nzbget API Error" />;
+  }
+
+  if (!statusData) {
+    return (
+      <Widget>
+        <Block label="Rate" />
+        <Block label="Remaining" />
+        <Block label="Downloaded" />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label="Rate" value={`${formatBytes(statusData.DownloadRate)}/s`} />
+      <Block label="Remaining" value={`${Math.round((statusData.RemainingSizeMB / 1024) * 100) / 100} GB`} />
+      <Block label="Downloaded" value={`${Math.round((statusData.DownloadedSizeMB / 1024) * 100) / 100} GB`} />
+    </Widget>
+  );
+}

+ 49 - 0
src/components/services/widgets/service/ombi.jsx

@@ -0,0 +1,49 @@
+import useSWR from "swr";
+
+import Widget from "../widget";
+import Block from "../block";
+
+export default function Ombi({ service }) {
+  const config = service.widget;
+
+  function buildApiUrl(endpoint) {
+    const { url } = config;
+    return `${url}/api/v1/${endpoint}`;
+  }
+
+  const fetcher = (url) => {
+    return fetch(url, {
+      method: "GET",
+      withCredentials: true,
+      credentials: "include",
+      headers: {
+        ApiKey: `${config.key}`,
+        "Content-Type": "application/json",
+      },
+    }).then((res) => res.json());
+  };
+
+  const { data: statsData, error: statsError } = useSWR(buildApiUrl(`Request/count`), fetcher);
+
+  if (statsError) {
+    return <Widget error="Ombi API Error" />;
+  }
+
+  if (!statsData) {
+    return (
+      <Widget>
+        <Block label="Pending" />
+        <Block label="Approved" />
+        <Block label="Available" />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label="Pending" value={statsData.pending} />
+      <Block label="Approved" value={statsData.approved} />
+      <Block label="Available" value={statsData.available} />
+    </Widget>
+  );
+}

+ 59 - 0
src/components/services/widgets/service/portainer.jsx

@@ -0,0 +1,59 @@
+import useSWR from "swr";
+
+import Widget from "../widget";
+import Block from "../block";
+
+export default function Portainer({ service }) {
+  const config = service.widget;
+
+  function buildApiUrl(endpoint) {
+    const { url, env } = config;
+    const reqUrl = new URL(`/api/endpoints/${env}/${endpoint}`, url);
+    return `/api/proxy?url=${encodeURIComponent(reqUrl)}`;
+  }
+
+  const fetcher = async (url) => {
+    const res = await fetch(url, {
+      method: "GET",
+      withCredentials: true,
+      credentials: "include",
+      headers: {
+        "X-API-Key": `${config.key}`,
+        "Content-Type": "application/json",
+      },
+    });
+    return await res.json();
+  };
+
+  const { data: containersData, error: containersError } = useSWR(buildApiUrl(`docker/containers/json`), fetcher);
+
+  if (containersError) {
+    return <Widget error="Portainer API Error" />;
+  }
+
+  if (!containersData) {
+    return (
+      <Widget>
+        <Block label="Running" />
+        <Block label="Stopped" />
+        <Block label="Total" />
+      </Widget>
+    );
+  }
+
+  if (containersData.error) {
+    return <Widget error="Portainer 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="Running" value={running} />
+      <Block label="Stopped" value={stopped} />
+      <Block label="Total" value={total} />
+    </Widget>
+  );
+}

+ 41 - 0
src/components/services/widgets/service/radarr.jsx

@@ -0,0 +1,41 @@
+import useSWR from "swr";
+
+import Widget from "../widget";
+import Block from "../block";
+
+export default function Radarr({ service }) {
+  const config = service.widget;
+
+  function buildApiUrl(endpoint) {
+    const { url, key } = config;
+    return `${url}/api/v3/${endpoint}?apikey=${key}`;
+  }
+
+  const { data: moviesData, error: moviesError } = useSWR(buildApiUrl("movie"));
+  const { data: queuedData, error: queuedError } = useSWR(buildApiUrl("queue/status"));
+
+  if (moviesError || queuedError) {
+    return <Widget error="Radarr API Error" />;
+  }
+
+  if (!moviesData || !queuedData) {
+    return (
+      <Widget>
+        <Block label="Wanted" />
+        <Block label="Queued" />
+        <Block label="Movies" />
+      </Widget>
+    );
+  }
+
+  const wanted = moviesData.filter((movie) => movie.isAvailable === false);
+  const have = moviesData.filter((movie) => movie.isAvailable === true);
+
+  return (
+    <Widget>
+      <Block label="Wanted" value={wanted.length} />
+      <Block label="Queued" value={queuedData.totalCount} />
+      <Block label="Movies" value={moviesData.length} />
+    </Widget>
+  );
+}

+ 39 - 0
src/components/services/widgets/service/sonarr.jsx

@@ -0,0 +1,39 @@
+import useSWR from "swr";
+
+import Widget from "../widget";
+import Block from "../block";
+
+export default function Sonarr({ service }) {
+  const config = service.widget;
+
+  function buildApiUrl(endpoint) {
+    const { url, key } = config;
+    return `${url}/api/v3/${endpoint}?apikey=${key}`;
+  }
+
+  const { data: wantedData, error: wantedError } = useSWR(buildApiUrl("wanted/missing"));
+  const { data: queuedData, error: queuedError } = useSWR(buildApiUrl("queue"));
+  const { data: seriesData, error: seriesError } = useSWR(buildApiUrl("series"));
+
+  if (wantedError || queuedError || seriesError) {
+    return <Widget error="Sonar API Error" />;
+  }
+
+  if (!wantedData || !queuedData || !seriesData) {
+    return (
+      <Widget>
+        <Block label="Wanted" />
+        <Block label="Queued" />
+        <Block label="Series" />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label="Wanted" value={wantedData.totalRecords} />
+      <Block label="Queued" value={queuedData.totalRecords} />
+      <Block label="Series" value={seriesData.length} />
+    </Widget>
+  );
+}

+ 0 - 53
src/components/services/widgets/sonarr.jsx

@@ -1,53 +0,0 @@
-import useSWR from "swr";
-
-export default function Sonarr({ service }) {
-  const config = service.widget;
-
-  function buildApiUrl(endpoint) {
-    const { url, key } = config;
-    return `${url}/api/v3/${endpoint}?apikey=${key}`;
-  }
-
-  const { data: wantedData, error: wantedError } = useSWR(
-    buildApiUrl("wanted/missing")
-  );
-
-  const { data: queuedData, error: queuedError } = useSWR(buildApiUrl("queue"));
-
-  const { data: seriesData, error: seriesError } = useSWR(
-    buildApiUrl("series")
-  );
-
-  if (wantedError || queuedError || seriesError) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Sonarr API Error</div>
-      </div>
-    );
-  }
-
-  if (!wantedData || !queuedData || !seriesData) {
-    return (
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">Loading</div>
-      </div>
-    );
-  }
-
-  return (
-    <div className="flex flex-row w-full">
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{wantedData.totalRecords}</div>
-        <div className="font-bold text-xs">WANTED</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{queuedData.totalRecords}</div>
-        <div className="font-bold text-xs">QUEUED</div>
-      </div>
-      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
-        <div className="font-thin text-sm">{seriesData.length}</div>
-        <div className="font-bold text-xs">SERIES</div>
-      </div>
-    </div>
-  );
-}

+ 11 - 0
src/components/services/widgets/widget.jsx

@@ -0,0 +1,11 @@
+export default function Widget({ error = false, children }) {
+  if (error) {
+    return (
+      <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
+        <div className="font-thin text-sm">{error}</div>
+      </div>
+    );
+  }
+
+  return <div className="flex flex-row w-full">{children}</div>;
+}