diff --git a/src/widgets/gamedig/component.jsx b/src/widgets/gamedig/component.jsx
new file mode 100644
index 00000000..1acfc6e1
--- /dev/null
+++ b/src/widgets/gamedig/component.jsx
@@ -0,0 +1,62 @@
+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 { widget } = service;
+ const { data: serverData, error: serverError } = useWidgetAPI(widget, "status");
+ const { t } = useTranslation();
+
+ if(serverError){
+ return
;
+ }
+
+ // Default fields
+ if (widget.fields == null || widget.fields.length === 0) {
+ widget.fields = ["map", "currentPlayers", "ping"];
+ }
+ const MAX_ALLOWED_FIELDS = 4;
+ // Limits max number of displayed fields
+ if (widget.fields != null && widget.fields.length > MAX_ALLOWED_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+ }
+
+ if (!serverData) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const status = serverData.online ?
{t("gamedig.online")} :
{t("gamedig.offline")};
+ const name = serverData.online ? serverData.name : "-";
+ const map = serverData.online ? serverData.map : "-";
+ const currentPlayers = serverData.online ? `${serverData.players} / ${serverData.maxplayers}` : "-";
+ const players = serverData.online ? `${serverData.players}` : "-";
+ const maxPlayers = serverData.online ? `${serverData.maxplayers}` : "-";
+ const bots = serverData.online ? `${serverData.bots}` : "-";
+ const ping = serverData.online ? `${t("common.ms", { value: serverData.ping, style: "unit", unit: "millisecond" })}` : "-";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/gamedig/proxy.js b/src/widgets/gamedig/proxy.js
new file mode 100644
index 00000000..f0b2e1d9
--- /dev/null
+++ b/src/widgets/gamedig/proxy.js
@@ -0,0 +1,37 @@
+import createLogger from "utils/logger";
+import getServiceWidget from "utils/config/service-helpers";
+
+const proxyName = "gamedigProxyHandler";
+const logger = createLogger(proxyName);
+const gamedig = require("gamedig");
+
+export default async function gamedigProxyHandler(req, res) {
+ const { group, service } = req.query;
+ const serviceWidget = await getServiceWidget(group, service);
+ const url = new URL(serviceWidget.url);
+
+ try {
+ const serverData = await gamedig.query({
+ type: serviceWidget.serverType,
+ host: url.hostname,
+ port: url.port,
+ givenPortOnly: true,
+ });
+
+ res.status(200).send({
+ online: true,
+ name: serverData.name,
+ map: serverData.map,
+ players: serverData.players.length,
+ maxplayers: serverData.maxplayers,
+ bots: serverData.bots.length,
+ ping: serverData.ping,
+ });
+ } catch (e) {
+ logger.error(e);
+
+ res.status(200).send({
+ online: false
+ });
+ }
+}
diff --git a/src/widgets/gamedig/widget.js b/src/widgets/gamedig/widget.js
new file mode 100644
index 00000000..c84e95bb
--- /dev/null
+++ b/src/widgets/gamedig/widget.js
@@ -0,0 +1,7 @@
+import gamedigProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: gamedigProxyHandler
+}
+
+export default widget;
diff --git a/src/widgets/glances/component.jsx b/src/widgets/glances/component.jsx
new file mode 100644
index 00000000..df916b4b
--- /dev/null
+++ b/src/widgets/glances/component.jsx
@@ -0,0 +1,49 @@
+import Memory from "./metrics/memory";
+import Cpu from "./metrics/cpu";
+import Sensor from "./metrics/sensor";
+import Net from "./metrics/net";
+import Process from "./metrics/process";
+import Disk from "./metrics/disk";
+import GPU from "./metrics/gpu";
+import Info from "./metrics/info";
+import Fs from "./metrics/fs";
+
+export default function Component({ service }) {
+ const { widget } = service;
+
+ if (widget.metric === "info") {
+ return
;
+ }
+
+ if (widget.metric === "memory") {
+ return
;
+ }
+
+ if (widget.metric === "process") {
+ return
;
+ }
+
+ if (widget.metric === "cpu") {
+ return
;
+ }
+
+ if (widget.metric.match(/^network:/)) {
+ return
;
+ }
+
+ if (widget.metric.match(/^sensor:/)) {
+ return
;
+ }
+
+ if (widget.metric.match(/^disk:/)) {
+ return
;
+ }
+
+ if (widget.metric.match(/^gpu:/)) {
+ return
;
+ }
+
+ if (widget.metric.match(/^fs:/)) {
+ return
;
+ }
+}
diff --git a/src/widgets/glances/components/block.jsx b/src/widgets/glances/components/block.jsx
new file mode 100644
index 00000000..1243771a
--- /dev/null
+++ b/src/widgets/glances/components/block.jsx
@@ -0,0 +1,7 @@
+export default function Block({ position, children }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/widgets/glances/components/chart.jsx b/src/widgets/glances/components/chart.jsx
new file mode 100644
index 00000000..2cd01107
--- /dev/null
+++ b/src/widgets/glances/components/chart.jsx
@@ -0,0 +1,48 @@
+import { PureComponent } from "react";
+import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts";
+
+import CustomTooltip from "./custom_tooltip";
+
+class Chart extends PureComponent {
+ render() {
+ const { dataPoints, formatter, label } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ }
+ classNames="rounded-md text-xs p-0.5"
+ contentStyle={{
+ backgroundColor: "rgb(var(--color-800))",
+ color: "rgb(var(--color-100))"
+ }}
+ />
+
+
+
+
+ );
+ }
+}
+
+export default Chart;
diff --git a/src/widgets/glances/components/chart_dual.jsx b/src/widgets/glances/components/chart_dual.jsx
new file mode 100644
index 00000000..e5b7e9f8
--- /dev/null
+++ b/src/widgets/glances/components/chart_dual.jsx
@@ -0,0 +1,63 @@
+import { PureComponent } from "react";
+import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts";
+
+import CustomTooltip from "./custom_tooltip";
+
+class ChartDual extends PureComponent {
+ render() {
+ const { dataPoints, formatter, stack, label, stackOffset } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ classNames="rounded-md text-xs p-0.5"
+ contentStyle={{
+ backgroundColor: "rgb(var(--color-800))",
+ color: "rgb(var(--color-100))"
+ }}
+
+ />
+
+
+
+
+ );
+ }
+}
+
+export default ChartDual;
diff --git a/src/widgets/glances/components/container.jsx b/src/widgets/glances/components/container.jsx
new file mode 100644
index 00000000..fc642986
--- /dev/null
+++ b/src/widgets/glances/components/container.jsx
@@ -0,0 +1,10 @@
+export default function Container({ children, chart = true, className = "" }) {
+ return (
+
+ {children}
+
+ { chart &&
}
+ { !chart &&
}
+
+ );
+}
diff --git a/src/widgets/glances/components/custom_tooltip.jsx b/src/widgets/glances/components/custom_tooltip.jsx
new file mode 100644
index 00000000..ef3881ef
--- /dev/null
+++ b/src/widgets/glances/components/custom_tooltip.jsx
@@ -0,0 +1,15 @@
+export default function Tooltip({ active, payload, formatter }) {
+ if (active && payload && payload.length) {
+ return (
+
+ {payload.map((pld, id) => (
+
+
{formatter(pld.value)} {payload[id].name}
+
+ ))}
+
+ );
+ }
+
+ return null;
+};
diff --git a/src/widgets/glances/components/error.jsx b/src/widgets/glances/components/error.jsx
new file mode 100644
index 00000000..6e3b4da0
--- /dev/null
+++ b/src/widgets/glances/components/error.jsx
@@ -0,0 +1,9 @@
+import { useTranslation } from "next-i18next";
+
+export default function Error() {
+ const { t } = useTranslation();
+
+ return
+ {t("widget.api_error")}
+
;
+}
diff --git a/src/widgets/glances/metrics/cpu.jsx b/src/widgets/glances/metrics/cpu.jsx
new file mode 100644
index 00000000..e996bc7e
--- /dev/null
+++ b/src/widgets/glances/metrics/cpu.jsx
@@ -0,0 +1,106 @@
+import dynamic from "next/dynamic";
+import { useState, useEffect } from "react";
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const Chart = dynamic(() => import("../components/chart"), { ssr: false });
+
+const pointsLimit = 15;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart } = widget;
+
+ const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
+
+ const { data, error } = useWidgetAPI(service.widget, 'cpu', {
+ refreshInterval: 1000,
+ });
+
+ const { data: systemData, error: systemError } = useWidgetAPI(service.widget, 'system');
+
+ useEffect(() => {
+ if (data) {
+ setDataPoints((prevDataPoints) => {
+ const newDataPoints = [...prevDataPoints, { value: data.total }];
+ if (newDataPoints.length > pointsLimit) {
+ newDataPoints.shift();
+ }
+ return newDataPoints;
+ });
+ }
+ }, [data]);
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ return (
+
+ { chart && (
+ t("common.number", {
+ value,
+ style: "unit",
+ unit: "percent",
+ maximumFractionDigits: 0,
+ })}
+ />
+ )}
+
+ { !chart && systemData && !systemError && (
+
+
+ {systemData.linux_distro && `${systemData.linux_distro} - ` }
+ {systemData.os_version && systemData.os_version }
+
+
+ )}
+
+ {systemData && !systemError && (
+
+ {systemData.linux_distro && chart && (
+
+ {systemData.linux_distro}
+
+ )}
+
+ {systemData.os_version && chart && (
+
+ {systemData.os_version}
+
+ )}
+
+ {systemData.hostname && (
+
+ {systemData.hostname}
+
+ )}
+
+ )}
+
+
+
+ {t("common.number", {
+ value: data.total,
+ style: "unit",
+ unit: "percent",
+ maximumFractionDigits: 0,
+ })} {t("resources.used")}
+
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/disk.jsx b/src/widgets/glances/metrics/disk.jsx
new file mode 100644
index 00000000..8a8cf663
--- /dev/null
+++ b/src/widgets/glances/metrics/disk.jsx
@@ -0,0 +1,105 @@
+import dynamic from "next/dynamic";
+import { useState, useEffect } from "react";
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
+
+const pointsLimit = 15;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart } = widget;
+ const [, diskName] = widget.metric.split(':');
+
+ const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ read_bytes: 0, write_bytes: 0, time_since_update: 0 }, 0, pointsLimit));
+ const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
+
+ const { data, error } = useWidgetAPI(service.widget, 'diskio', {
+ refreshInterval: 1000,
+ });
+
+ const calculateRates = (d) => d.map(item => ({
+ a: item.read_bytes / item.time_since_update,
+ b: item.write_bytes / item.time_since_update
+ }));
+
+ useEffect(() => {
+ if (data) {
+ const diskData = data.find((item) => item.disk_name === diskName);
+
+ setDataPoints((prevDataPoints) => {
+ const newDataPoints = [...prevDataPoints, diskData];
+ if (newDataPoints.length > pointsLimit) {
+ newDataPoints.shift();
+ }
+ return newDataPoints;
+ });
+ }
+ }, [data, diskName]);
+
+ useEffect(() => {
+ setRatePoints(calculateRates(dataPoints));
+ }, [dataPoints]);
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ const diskData = data.find((item) => item.disk_name === diskName);
+
+ if (!diskData) {
+ return
-;
+ }
+
+ const diskRates = calculateRates(dataPoints);
+ const currentRate = diskRates[diskRates.length - 1];
+
+ return (
+
+ { chart && (
+ t("common.bitrate", {
+ value,
+ })}
+ />
+ )}
+
+ {currentRate && !error && (
+
+
+ {t("common.bitrate", {
+ value: currentRate.a,
+ })} {t("glances.read")}
+
+
+ {t("common.bitrate", {
+ value: currentRate.b,
+ })} {t("glances.write")}
+
+
+ )}
+
+
+
+ {t("common.bitrate", {
+ value: currentRate.a + currentRate.b,
+ })}
+
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/fs.jsx b/src/widgets/glances/metrics/fs.jsx
new file mode 100644
index 00000000..e4b26c3f
--- /dev/null
+++ b/src/widgets/glances/metrics/fs.jsx
@@ -0,0 +1,84 @@
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart } = widget;
+ const [, fsName] = widget.metric.split(':');
+
+ const { data, error } = useWidgetAPI(widget, 'fs', {
+ refreshInterval: 1000,
+ });
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ const fsData = data.find((item) => item[item.key] === fsName);
+
+ if (!fsData) {
+ return
-;
+ }
+
+ return (
+
+ { chart && (
+
+ )}
+
+
+ { fsData.used && chart && (
+
+ {t("common.bbytes", {
+ value: fsData.used,
+ maximumFractionDigits: 0,
+ })} {t("resources.used")}
+
+ )}
+
+
+ {t("common.bbytes", {
+ value: fsData.free,
+ maximumFractionDigits: 0,
+ })} {t("resources.free")}
+
+
+
+ { !chart && (
+
+ {fsData.used && (
+
+ {t("common.bbytes", {
+ value: fsData.used,
+ maximumFractionDigits: 0,
+ })} {t("resources.used")}
+
+ )}
+
+ )}
+
+
+
+ {t("common.bbytes", {
+ value: fsData.size,
+ maximumFractionDigits: 1,
+ })} {t("resources.total")}
+
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/gpu.jsx b/src/widgets/glances/metrics/gpu.jsx
new file mode 100644
index 00000000..9e64949e
--- /dev/null
+++ b/src/widgets/glances/metrics/gpu.jsx
@@ -0,0 +1,141 @@
+import dynamic from "next/dynamic";
+import { useState, useEffect } from "react";
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
+
+const pointsLimit = 15;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart } = widget;
+ const [, gpuName] = widget.metric.split(':');
+
+ const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
+
+ const { data, error } = useWidgetAPI(widget, 'gpu', {
+ refreshInterval: 1000,
+ });
+
+ useEffect(() => {
+ if (data) {
+ // eslint-disable-next-line eqeqeq
+ const gpuData = data.find((item) => item[item.key] == gpuName);
+
+ if (gpuData) {
+ setDataPoints((prevDataPoints) => {
+ const newDataPoints = [...prevDataPoints, { a: gpuData.mem, b: gpuData.proc }];
+ if (newDataPoints.length > pointsLimit) {
+ newDataPoints.shift();
+ }
+ return newDataPoints;
+ });
+ }
+ }
+ }, [data, gpuName]);
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ // eslint-disable-next-line eqeqeq
+ const gpuData = data.find((item) => item[item.key] == gpuName);
+
+ if (!gpuData) {
+ return
-;
+ }
+
+ return (
+
+ { chart && (
+ t("common.percent", {
+ value,
+ maximumFractionDigits: 1,
+ })}
+ />
+ )}
+
+ { chart && (
+
+ {gpuData && gpuData.name && (
+
+ {gpuData.name}
+
+ )}
+
+
+ {t("common.number", {
+ value: gpuData.mem,
+ maximumFractionDigits: 1,
+ })}% {t("resources.mem")}
+
+
+ )}
+
+ { !chart && (
+
+
+ {t("common.number", {
+ value: gpuData.temperature,
+ maximumFractionDigits: 1,
+ })}° C
+
+
+ )}
+
+
+
+ {!chart && (
+
+ {t("common.number", {
+ value: gpuData.proc,
+ maximumFractionDigits: 1,
+ })}% {t("glances.gpu")}
+
+ )}
+ { !chart && (
+ <>•>
+ )}
+
+ {t("common.number", {
+ value: gpuData.proc,
+ maximumFractionDigits: 1,
+ })}% {t("glances.gpu")}
+
+
+
+
+
+ { chart && (
+
+ {t("common.number", {
+ value: gpuData.temperature,
+ maximumFractionDigits: 1,
+ })}° C
+
+ )}
+
+ {gpuData && gpuData.name && !chart && (
+
+ {gpuData.name}
+
+ )}
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/info.jsx b/src/widgets/glances/metrics/info.jsx
new file mode 100644
index 00000000..cf3fba04
--- /dev/null
+++ b/src/widgets/glances/metrics/info.jsx
@@ -0,0 +1,156 @@
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+
+function Swap({ quicklookData, className = "" }) {
+ const { t } = useTranslation();
+
+ return quicklookData && quicklookData.swap !== 0 && (
+
+
{t("glances.swap")}
+
+ {t("common.number", {
+ value: quicklookData.swap,
+ style: "unit",
+ unit: "percent",
+ maximumFractionDigits: 0,
+ })}
+
+
+ );
+}
+
+function CPU({ quicklookData, className = "" }) {
+ const { t } = useTranslation();
+
+ return quicklookData && quicklookData.cpu && (
+
+
{t("glances.cpu")}
+
+ {t("common.number", {
+ value: quicklookData.cpu,
+ style: "unit",
+ unit: "percent",
+ maximumFractionDigits: 0,
+ })}
+
+
+ );
+}
+
+function Mem({ quicklookData, className = "" }) {
+ const { t } = useTranslation();
+
+ return quicklookData && quicklookData.mem && (
+
+
{t("glances.mem")}
+
+ {t("common.number", {
+ value: quicklookData.mem,
+ style: "unit",
+ unit: "percent",
+ maximumFractionDigits: 0,
+ })}
+
+
+ );
+}
+
+export default function Component({ service }) {
+ const { widget } = service;
+ const { chart } = widget;
+
+ const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, 'quicklook', {
+ refreshInterval: 1000,
+ });
+
+ const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, 'system', {
+ refreshInterval: 30000,
+ });
+
+ if (quicklookError) {
+ return
;
+ }
+
+ if (systemError) {
+ return
;
+ }
+
+ const dataCharts = [];
+
+ if (quicklookData) {
+ quicklookData.percpu.forEach((cpu, index) => {
+ dataCharts.push({
+ name: `CPU ${index}`,
+ cpu: cpu.total,
+ mem: quicklookData.mem,
+ swap: quicklookData.swap,
+ proc: quicklookData.cpu,
+ });
+ });
+ }
+
+
+ return (
+
+
+ {quicklookData && quicklookData.cpu_name && chart && (
+
+ {quicklookData.cpu_name}
+
+ )}
+
+ { !chart && quicklookData?.swap === 0 && (
+
+ {quicklookData.cpu_name}
+
+ )}
+
+
+ { !chart && }
+
+
+
+
+ {chart && (
+
+ {systemData && systemData.linux_distro && (
+
+ {systemData.linux_distro}
+
+ )}
+ {systemData && systemData.os_version && (
+
+ {systemData.os_version}
+
+ )}
+ {systemData && systemData.hostname && (
+
+ {systemData.hostname}
+
+ )}
+
+ )}
+
+ {!chart && (
+
+
+
+ )}
+
+
+ { chart && }
+
+ { chart && }
+ { !chart && }
+
+ { chart && }
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/memory.jsx b/src/widgets/glances/metrics/memory.jsx
new file mode 100644
index 00000000..651065d1
--- /dev/null
+++ b/src/widgets/glances/metrics/memory.jsx
@@ -0,0 +1,111 @@
+import dynamic from "next/dynamic";
+import { useState, useEffect } from "react";
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
+
+const pointsLimit = 15;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart } = widget;
+
+
+ const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
+
+ const { data, error } = useWidgetAPI(service.widget, 'mem', {
+ refreshInterval: chart ? 1000 : 5000,
+ });
+
+ useEffect(() => {
+ if (data) {
+ setDataPoints((prevDataPoints) => {
+ const newDataPoints = [...prevDataPoints, { a: data.used, b: data.free }];
+ if (newDataPoints.length > pointsLimit) {
+ newDataPoints.shift();
+ }
+ return newDataPoints;
+ });
+ }
+ }, [data]);
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ return (
+
+ {chart && (
+ t("common.bytes", {
+ value,
+ maximumFractionDigits: 0,
+ binary: true,
+ })}
+ />
+ )}
+
+ {data && !error && (
+
+ {data.free && chart && (
+
+ {t("common.bytes", {
+ value: data.free,
+ maximumFractionDigits: 0,
+ binary: true,
+ })} {t("resources.free")}
+
+ )}
+
+ {data.total && (
+
+ {t("common.bytes", {
+ value: data.total,
+ maximumFractionDigits: 0,
+ binary: true,
+ })} {t("resources.total")}
+
+ )}
+
+ )}
+
+ { !chart && (
+
+ {data.free && (
+
+ {t("common.bytes", {
+ value: data.free,
+ maximumFractionDigits: 0,
+ binary: true,
+ })} {t("resources.free")}
+
+ )}
+
+ )}
+
+
+
+ {t("common.bytes", {
+ value: data.used,
+ maximumFractionDigits: 0,
+ binary: true,
+ })} {t("resources.used")}
+
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/net.jsx b/src/widgets/glances/metrics/net.jsx
new file mode 100644
index 00000000..e7e34e1c
--- /dev/null
+++ b/src/widgets/glances/metrics/net.jsx
@@ -0,0 +1,105 @@
+import dynamic from "next/dynamic";
+import { useState, useEffect } from "react";
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
+
+const pointsLimit = 15;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart, metric } = widget;
+ const [, interfaceName] = metric.split(':');
+
+ const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
+
+ const { data, error } = useWidgetAPI(widget, 'network', {
+ refreshInterval: chart ? 1000 : 5000,
+ });
+
+ useEffect(() => {
+ if (data) {
+ const interfaceData = data.find((item) => item[item.key] === interfaceName);
+
+ if (interfaceData) {
+ setDataPoints((prevDataPoints) => {
+ const newDataPoints = [...prevDataPoints, { a: interfaceData.tx, b: interfaceData.rx }];
+ if (newDataPoints.length > pointsLimit) {
+ newDataPoints.shift();
+ }
+ return newDataPoints;
+ });
+ }
+ }
+ }, [data, interfaceName]);
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ const interfaceData = data.find((item) => item[item.key] === interfaceName);
+
+ if (!interfaceData) {
+ return
-;
+ }
+
+ return (
+
+ { chart && (
+ t("common.byterate", {
+ value,
+ maximumFractionDigits: 0,
+ })}
+ />
+ )}
+
+
+ {interfaceData && interfaceData.interface_name && chart && (
+
+ {interfaceData.interface_name}
+
+ )}
+
+
+ {t("common.bitrate", {
+ value: interfaceData.tx,
+ maximumFractionDigits: 0,
+ })} {t("docker.tx")}
+
+
+
+ { !chart && (
+
+ {interfaceData && interfaceData.interface_name && (
+
+ {interfaceData.interface_name}
+
+ )}
+
+ )}
+
+
+
+ {t("common.bitrate", {
+ value: interfaceData.rx,
+ maximumFractionDigits: 0,
+ })} {t("docker.rx")}
+
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/process.jsx b/src/widgets/glances/metrics/process.jsx
new file mode 100644
index 00000000..77b9ea61
--- /dev/null
+++ b/src/widgets/glances/metrics/process.jsx
@@ -0,0 +1,68 @@
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import ResolvedIcon from "components/resolvedicon";
+
+const statusMap = {
+ "R":
, // running
+ "S":
, // sleeping
+ "D":
, // disk sleep
+ "Z":
, // zombie
+ "T":
, // traced
+ "t":
, // traced
+ "X":
, // dead
+};
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart } = widget;
+
+ const { data, error } = useWidgetAPI(service.widget, 'processlist', {
+ refreshInterval: 1000,
+ });
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ data.splice(chart ? 5 : 1);
+
+ return (
+
+
+
+
+
{t("resources.cpu")}
+
{t("resources.mem")}
+
+
+
+
+
+ { data.map((item) =>
+
+
+ {statusMap[item.status]}
+
+
{item.name}
+
{item.cpu_percent.toFixed(1)}%
+
{t("common.bytes", {
+ value: item.memory_info[0],
+ maximumFractionDigits: 0,
+ })}
+
+
) }
+
+
+
+ );
+}
diff --git a/src/widgets/glances/metrics/sensor.jsx b/src/widgets/glances/metrics/sensor.jsx
new file mode 100644
index 00000000..b35e2a06
--- /dev/null
+++ b/src/widgets/glances/metrics/sensor.jsx
@@ -0,0 +1,98 @@
+import dynamic from "next/dynamic";
+import { useState, useEffect } from "react";
+import { useTranslation } from "next-i18next";
+
+import Error from "../components/error";
+import Container from "../components/container";
+import Block from "../components/block";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const Chart = dynamic(() => import("../components/chart"), { ssr: false });
+
+const pointsLimit = 15;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { chart } = widget;
+ const [, sensorName] = widget.metric.split(':');
+
+ const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
+
+ const { data, error } = useWidgetAPI(service.widget, 'sensors', {
+ refreshInterval: 1000,
+ });
+
+ useEffect(() => {
+ if (data) {
+ const sensorData = data.find((item) => item.label === sensorName);
+ setDataPoints((prevDataPoints) => {
+ const newDataPoints = [...prevDataPoints, { value: sensorData.value }];
+ if (newDataPoints.length > pointsLimit) {
+ newDataPoints.shift();
+ }
+ return newDataPoints;
+ });
+ }
+ }, [data, sensorName]);
+
+ if (error) {
+ return
;
+ }
+
+ if (!data) {
+ return
-;
+ }
+
+ const sensorData = data.find((item) => item.label === sensorName);
+
+ if (!sensorData) {
+ return
-;
+ }
+
+ return (
+
+ { chart && (
+ t("common.number", {
+ value,
+ })}
+ />
+ )}
+
+ {sensorData && !error && (
+
+ {sensorData.warning && chart && (
+
+ {t("glances.warn")} {sensorData.warning} {sensorData.unit}
+
+ )}
+ {sensorData.critical && (
+
+ {t("glances.crit")} {sensorData.critical} {sensorData.unit}
+
+ )}
+
+ )}
+
+
+
+ {sensorData.warning && !chart && (
+ <>
+ {t("glances.warn")} {sensorData.warning} {sensorData.unit}
+ >
+ )}
+
+
+ {t("glances.temp")} {t("common.number", {
+ value: sensorData.value,
+ })} {sensorData.unit}
+
+
+
+ );
+}
diff --git a/src/widgets/glances/widget.js b/src/widgets/glances/widget.js
new file mode 100644
index 00000000..3da1c6d1
--- /dev/null
+++ b/src/widgets/glances/widget.js
@@ -0,0 +1,8 @@
+import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+
+const widget = {
+ api: "{url}/api/3/{endpoint}",
+ proxyHandler: credentialedProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/jdownloader/proxy.js b/src/widgets/jdownloader/proxy.js
index 116a7a32..cc934a6b 100644
--- a/src/widgets/jdownloader/proxy.js
+++ b/src/widgets/jdownloader/proxy.js
@@ -153,7 +153,7 @@ export default async function jdownloaderProxyHandler(req, res) {
})
const packageStatus = await queryPackages(loginData[4], deviceData[1], loginData[5], {
- "bytesLoaded": false,
+ "bytesLoaded": true,
"bytesTotal": true,
"comment": false,
"enabled": true,
@@ -171,22 +171,20 @@ export default async function jdownloaderProxyHandler(req, res) {
}
)
- let bytesRemaining = 0;
+ let totalLoaded = 0;
let totalBytes = 0;
let totalSpeed = 0;
packageStatus.forEach(file => {
totalBytes += file.bytesTotal;
- if (file.finished !== true) {
- bytesRemaining += file.bytesTotal;
- if (file.speed) {
- totalSpeed += file.speed;
- }
+ totalLoaded += file.bytesLoaded;
+ if (file.finished !== true && file.speed) {
+ totalSpeed += file.speed;
}
});
const data = {
downloadCount: packageStatus.length,
- bytesRemaining,
+ bytesRemaining: totalBytes - totalLoaded,
totalBytes,
totalSpeed
};
diff --git a/src/widgets/kopia/component.jsx b/src/widgets/kopia/component.jsx
index 46690990..9a7a76ac 100755
--- a/src/widgets/kopia/component.jsx
+++ b/src/widgets/kopia/component.jsx
@@ -41,7 +41,12 @@ export default function Component({ service }) {
return
;
}
- const source = statusData?.sources[0];
+ const snapshotHost = service.widget?.snapshotHost;
+ const snapshotPath = service.widget?.snapshotPath;
+
+ const source = statusData?.sources
+ .filter(el => snapshotHost ? el.source.host === snapshotHost : true)
+ .filter(el => snapshotPath ? el.source.path === snapshotPath : true)[0];
if (!statusData || !source) {
return (
diff --git a/src/widgets/mealie/component.jsx b/src/widgets/mealie/component.jsx
new file mode 100644
index 00000000..c8e88cb6
--- /dev/null
+++ b/src/widgets/mealie/component.jsx
@@ -0,0 +1,33 @@
+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 { widget } = service;
+
+ const { data: mealieData, error: mealieError } = useWidgetAPI(widget);
+
+ if (mealieError || mealieData?.statusCode === 401) {
+ return
;
+ }
+
+ if (!mealieData) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/widgets/azurePipelines/widget.js b/src/widgets/mealie/widget.js
similarity index 55%
rename from src/widgets/azurePipelines/widget.js
rename to src/widgets/mealie/widget.js
index 708266d2..b2eac1bc 100644
--- a/src/widgets/azurePipelines/widget.js
+++ b/src/widgets/mealie/widget.js
@@ -1,7 +1,7 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
- api: "https://dev.azure.com/{organization}/{project}/_apis/build/Builds?branchName={branchName}&definitions={definitionId}",
+ api: "{url}/api/groups/statistics",
proxyHandler: credentialedProxyHandler,
};
diff --git a/src/widgets/mjpeg/component.jsx b/src/widgets/mjpeg/component.jsx
new file mode 100644
index 00000000..30907c31
--- /dev/null
+++ b/src/widgets/mjpeg/component.jsx
@@ -0,0 +1,17 @@
+import Image from "next/image";
+
+export default function Component({ service }) {
+ const { widget } = service;
+ const { stream, fit = "contain" } = widget;
+
+ return (
+
+ );
+}
diff --git a/src/widgets/mjpeg/widget.js b/src/widgets/mjpeg/widget.js
new file mode 100644
index 00000000..400c33a6
--- /dev/null
+++ b/src/widgets/mjpeg/widget.js
@@ -0,0 +1,8 @@
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/{endpoint}",
+ proxyHandler: genericProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/octoprint/component.jsx b/src/widgets/octoprint/component.jsx
index 2483e5bf..35983cd0 100644
--- a/src/widgets/octoprint/component.jsx
+++ b/src/widgets/octoprint/component.jsx
@@ -43,7 +43,7 @@ export default function Component({ service }) {
const printingStateFalgs = ["Printing", "Paused", "Pausing", "Resuming"];
if (printingStateFalgs.includes(state)) {
- const { completion } = jobStats.progress;
+ const { completion } = jobStats?.progress ?? undefined;
if (!jobStats || !completion) {
return (
diff --git a/src/widgets/openmediavault/component.jsx b/src/widgets/openmediavault/component.jsx
new file mode 100644
index 00000000..bd34a750
--- /dev/null
+++ b/src/widgets/openmediavault/component.jsx
@@ -0,0 +1,16 @@
+import ServicesGetStatus from "./methods/services_get_status";
+import SmartGetList from "./methods/smart_get_list";
+import DownloaderGetDownloadList from "./methods/downloader_get_downloadlist";
+
+export default function Component({ service }) {
+ switch (service.widget.method) {
+ case "services.getStatus":
+ return
;
+ case "smart.getListBg":
+ return
;
+ case "downloader.getDownloadList":
+ return
;
+ default:
+ return null;
+ }
+}
diff --git a/src/widgets/openmediavault/methods/downloader_get_downloadlist.jsx b/src/widgets/openmediavault/methods/downloader_get_downloadlist.jsx
new file mode 100644
index 00000000..ed776db0
--- /dev/null
+++ b/src/widgets/openmediavault/methods/downloader_get_downloadlist.jsx
@@ -0,0 +1,36 @@
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+
+const downloadReduce = (acc, e) => {
+ if (e.downloading) {
+ return acc + 1;
+ }
+ return acc;
+};
+
+const items = [
+ { label: "openmediavault.downloading", getNumber: (data) => (!data ? null : data.reduce(downloadReduce, 0)) },
+ { label: "openmediavault.total", getNumber: (data) => (!data ? null : data?.length) },
+];
+
+export default function Component({ service }) {
+ const { data, error } = useWidgetAPI(service.widget);
+
+ if (error) {
+ return
;
+ }
+
+ const itemsWithData = items.map((item) => ({
+ ...item,
+ number: item.getNumber(data?.response?.data),
+ }));
+
+ return (
+
+ {itemsWithData.map((e) => (
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/openmediavault/methods/services_get_status.jsx b/src/widgets/openmediavault/methods/services_get_status.jsx
new file mode 100644
index 00000000..3ec66a45
--- /dev/null
+++ b/src/widgets/openmediavault/methods/services_get_status.jsx
@@ -0,0 +1,43 @@
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+
+const isRunningReduce = (acc, e) => {
+ if (e.running) {
+ return acc + 1;
+ }
+ return acc;
+};
+const notRunningReduce = (acc, e) => {
+ if (!e.running) {
+ return acc + 1;
+ }
+ return acc;
+};
+
+const items = [
+ { label: "openmediavault.running", getNumber: (data) => (!data ? null : data.reduce(isRunningReduce, 0)) },
+ { label: "openmediavault.stopped", getNumber: (data) => (!data ? null : data.reduce(notRunningReduce, 0)) },
+ { label: "openmediavault.total", getNumber: (data) => (!data ? null : data?.length) },
+];
+
+export default function Component({ service }) {
+ const { data, error } = useWidgetAPI(service.widget);
+
+ if (error) {
+ return
;
+ }
+
+ const itemsWithData = items.map((item) => ({
+ ...item,
+ number: item.getNumber(data?.response?.data),
+ }));
+
+ return (
+
+ {itemsWithData.map((e) => (
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/openmediavault/methods/smart_get_list.jsx b/src/widgets/openmediavault/methods/smart_get_list.jsx
new file mode 100644
index 00000000..b8ca33ee
--- /dev/null
+++ b/src/widgets/openmediavault/methods/smart_get_list.jsx
@@ -0,0 +1,42 @@
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+
+const passedReduce = (acc, e) => {
+ if (e.monitor && e.overallstatus === "GOOD") {
+ return acc + 1;
+ }
+ return acc;
+};
+const failedReduce = (acc, e) => {
+ if (e.monitor && e.overallstatus !== "GOOD") {
+ return acc + 1;
+ }
+ return acc;
+};
+
+const items = [
+ { label: "openmediavault.passed", getNumber: (data) => (!data ? null : data.reduce(passedReduce, 0)) },
+ { label: "openmediavault.failed", getNumber: (data) => (!data ? null : data.reduce(failedReduce, 0)) },
+];
+
+export default function Component({ service }) {
+ const { data, error } = useWidgetAPI(service.widget);
+
+ if (error) {
+ return
;
+ }
+
+ const itemsWithData = items.map((item) => ({
+ ...item,
+ number: item.getNumber(JSON.parse(data?.response?.output || "{}")?.data),
+ }));
+
+ return (
+
+ {itemsWithData.map((e) => (
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/openmediavault/proxy.js b/src/widgets/openmediavault/proxy.js
new file mode 100644
index 00000000..a9099d24
--- /dev/null
+++ b/src/widgets/openmediavault/proxy.js
@@ -0,0 +1,151 @@
+import { formatApiCall } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+import getServiceWidget from "utils/config/service-helpers";
+import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar";
+import createLogger from "utils/logger";
+import widgets from "widgets/widgets";
+
+const PROXY_NAME = "OMVProxyHandler";
+const BG_MAX_RETRIES = 50;
+const BG_POLL_PERIOD = 500;
+
+const logger = createLogger(PROXY_NAME);
+
+async function getWidget(req) {
+ const { group, service } = req.query;
+
+ if (!group || !service) {
+ logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
+ return null;
+ }
+
+ const 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 rpc(url, request) {
+ const params = {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ };
+ setCookieHeader(url, params);
+ const [status, contentType, data, headers] = await httpProxy(url, params);
+
+ return { status, contentType, data, headers };
+}
+
+async function poll(attemptsLeft, makeReqByPos, pos = 0) {
+ if (attemptsLeft <= 0) {
+ return null;
+ }
+
+ const resp = await makeReqByPos(pos);
+
+ const data = JSON.parse(resp.data.toString()).response;
+ if (data.running === true || data.outputPending) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, BG_POLL_PERIOD);
+ });
+ return poll(attemptsLeft - 1, makeReqByPos, data.pos);
+ }
+ return resp;
+}
+
+async function tryLogin(widget) {
+ const url = new URL(formatApiCall(widgets?.[widget.type]?.api, { ...widget }));
+ const { username, password } = widget;
+ const resp = await rpc(url, {
+ method: "login",
+ service: "session",
+ params: { username, password },
+ });
+
+ if (resp.status !== 200) {
+ logger.error("HTTP %d logging in to OpenMediaVault. Data: %s", resp.status, resp.data);
+ return [false, resp];
+ }
+
+ const json = JSON.parse(resp.data.toString());
+ if (json.response.authenticated !== true) {
+ logger.error("Login error in OpenMediaVault. Data: %s", resp.data);
+ resp.status = 401;
+ return [false, resp];
+ }
+
+ return [true, resp];
+}
+async function processBg(url, filename) {
+ const resp = await poll(BG_MAX_RETRIES, (pos) =>
+ rpc(url, {
+ service: "exec",
+ method: "getOutput",
+ params: { pos, filename },
+ })
+ );
+
+ if (resp == null) {
+ const errText = "The maximum number of attempts to receive a response from Bg data has been exceeded.";
+ logger.error(errText);
+ return errText;
+ }
+ if (resp.status !== 200) {
+ logger.error("HTTP %d getting Bg data from OpenMediaVault RPC. Data: %s", resp.status, resp.data);
+ }
+ return resp;
+}
+
+export default async function proxyHandler(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 RPC calls" });
+ }
+
+ const url = new URL(formatApiCall(api, { ...widget }));
+ const [service, method] = widget.method.split(".");
+ const rpcReq = { params: { limit: -1, start: 0 }, service, method };
+
+ let resp = await rpc(url, rpcReq);
+
+ if (resp.status === 401) {
+ logger.debug("Session not authenticated.");
+ const [success, lResp] = await tryLogin(widget);
+
+ if (success) {
+ addCookieToJar(url, lResp.headers);
+ } else {
+ res.status(lResp.status).json({ error: { message: `HTTP Error ${lResp.status}`, url, data: lResp.data } });
+ }
+
+ logger.debug("Retrying OpenMediaVault request after login.");
+ resp = await rpc(url, rpcReq);
+ }
+
+ if (resp.status !== 200) {
+ logger.error("HTTP %d getting data from OpenMediaVault RPC. Data: %s", resp.status, resp.data);
+ return res.status(resp.status).json({ error: { message: `HTTP Error ${resp.status}`, url, data: resp.data } });
+ }
+
+ if (method.endsWith("Bg")) {
+ const json = JSON.parse(resp.data.toString());
+ const bgResp = await processBg(url, json.response);
+
+ if (typeof bgResp === "string") {
+ return res.status(400).json({ error: bgResp });
+ }
+ return res.status(bgResp.status).send(bgResp.data);
+ }
+
+ return res.status(resp.status).send(resp.data);
+}
diff --git a/src/widgets/openmediavault/widget.js b/src/widgets/openmediavault/widget.js
new file mode 100644
index 00000000..3678ebe8
--- /dev/null
+++ b/src/widgets/openmediavault/widget.js
@@ -0,0 +1,8 @@
+import proxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/rpc.php",
+ proxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/pterodactyl/component.jsx b/src/widgets/pterodactyl/component.jsx
index 83ace637..e67d6f8c 100644
--- a/src/widgets/pterodactyl/component.jsx
+++ b/src/widgets/pterodactyl/component.jsx
@@ -23,7 +23,7 @@ export default function Component({ service }) {
}
const totalServers = nodesData.data.reduce((total, node) =>
- node.attributes?.relationships?.servers?.data?.length ?? 0 + total, 0);
+ (node.attributes?.relationships?.servers?.data?.length ?? 0) + total, 0);
return (
diff --git a/src/widgets/uptimerobot/component.jsx b/src/widgets/uptimerobot/component.jsx
new file mode 100644
index 00000000..6c81b036
--- /dev/null
+++ b/src/widgets/uptimerobot/component.jsx
@@ -0,0 +1,97 @@
+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";
+
+function secondsToDhms(seconds) {
+ const d = Math.floor(seconds / (3600*24));
+ const h = Math.floor(seconds % (3600*24) / 3600);
+ const m = Math.floor(seconds % 3600 / 60);
+ const s = Math.floor(seconds % 60);
+
+ const dDisplay = d > 0 ? d + (d === 1 ? " day, " : " days, ") : "";
+ const hDisplay = h > 0 ? h + (h === 1 ? " hr, " : " hrs, ") : "";
+ let mDisplay = m > 0 && d === 0 ? m + (m === 1 ? " min" : " mins") : "";
+ let sDisplay = "";
+
+ if (d === 0 && h === 0) {
+ mDisplay = m > 0 ? m + (m === 1 ? " min, " : " mins, ") : "";
+ sDisplay = s > 0 ? s + (s === 1 ? " sec" : " secs") : "";
+ }
+ return (dDisplay + hDisplay + mDisplay + sDisplay).replace(/,\s*$/, "");
+}
+
+export default function Component({ service }) {
+ const { widget } = service;
+ const { t } = useTranslation();
+
+ const { data: uptimerobotData, error: uptimerobotError } = useWidgetAPI(widget, "getmonitors");
+
+ if (uptimerobotError) {
+ return ;
+ }
+
+ if (!uptimerobotData) {
+ return (
+
+
+
+
+ );
+ }
+
+ // multiple monitors
+ if (uptimerobotData.pagination?.total > 1) {
+ const sitesUp = uptimerobotData.monitors.filter(m => m.status === 2).length;
+
+ return (
+
+
+
+
+ );
+ }
+
+ // single monitor
+ const monitor = uptimerobotData.monitors[0];
+ let status;
+ let uptime = 0;
+ let logIndex = 0;
+
+ switch (monitor.status) {
+ case 0:
+ status = t("uptimerobot.paused");
+ break;
+ case 1:
+ status = t("uptimerobot.notyetchecked");
+ break;
+ case 2:
+ status = t("uptimerobot.up");
+ uptime = secondsToDhms(monitor.logs[0].duration);
+ logIndex = 1;
+ break;
+ case 8:
+ status = t("uptimerobot.seemsdown");
+ break;
+ case 9:
+ status = t("uptimerobot.down");
+ break;
+ default:
+ status = t("uptimerobot.unknown");
+ break;
+ }
+
+ const lastDown = new Date(monitor.logs[logIndex].datetime * 1000).toLocaleString();
+ const downDuration = secondsToDhms(monitor.logs[logIndex].duration);
+ const hideDown = logIndex === 1 && monitor.logs[logIndex].type !== 1;
+
+ return (
+
+
+
+ {!hideDown && }
+ {!hideDown && }
+
+ );
+}
diff --git a/src/widgets/uptimerobot/widget.js b/src/widgets/uptimerobot/widget.js
new file mode 100644
index 00000000..a56cdc63
--- /dev/null
+++ b/src/widgets/uptimerobot/widget.js
@@ -0,0 +1,20 @@
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/v2/{endpoint}?api_key={key}",
+ proxyHandler: genericProxyHandler,
+
+ mappings: {
+ getmonitors: {
+ method: "POST",
+ endpoint: "getMonitors",
+ body: 'format=json&logs=1',
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "cache-control": "no-cache"
+ },
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/urbackup/component.jsx b/src/widgets/urbackup/component.jsx
new file mode 100644
index 00000000..2e3b5166
--- /dev/null
+++ b/src/widgets/urbackup/component.jsx
@@ -0,0 +1,93 @@
+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 Status = Object.freeze({
+ ok: Symbol("Ok"),
+ errored: Symbol("Errored"),
+ noRecent: Symbol("No Recent Backups")
+});
+
+function hasRecentBackups(client, maxDays){
+ const days = maxDays || 3;
+ const diffTime = days*24*60*60 // 7 days
+ const recentFile = (client.lastbackup > (Date.now() / 1000 - diffTime));
+ const recentImage = client.image_not_supported || client.image_disabled || (client.lastbackup_image > (Date.now() / 1000 - diffTime));
+ return (recentFile && recentImage);
+}
+
+function determineStatuses(urbackupData) {
+ let ok = 0;
+ let errored = 0;
+ let noRecent = 0;
+ let status;
+ urbackupData.clientStatuses.forEach((client) => {
+ status = Status.noRecent;
+ if (hasRecentBackups(client, urbackupData.maxDays)) {
+ status = (client.file_ok && (client.image_ok || client.image_not_supported || client.image_disabled)) ? Status.ok : Status.errored;
+ }
+ switch (status) {
+ case Status.ok:
+ ok += 1;
+ break;
+ case Status.errored:
+ errored += 1;
+ break;
+ case Status.noRecent:
+ noRecent += 1;
+ break;
+ default:
+ break;
+ }
+ });
+
+ let totalUsage = false;
+
+ // calculate total disk space if provided
+ if (urbackupData.diskUsage) {
+ totalUsage = 0.0;
+ urbackupData.diskUsage.forEach((client) => {
+ totalUsage += client.used;
+ });
+ }
+
+ return { ok, errored, noRecent, totalUsage };
+}
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+
+ const showDiskUsage = widget.fields?.includes('totalUsed')
+
+ const { data: urbackupData, error: urbackupError } = useWidgetAPI(widget, "status");
+
+ if (urbackupError) {
+ return ;
+ }
+
+ if (!urbackupData) {
+ return (
+
+
+
+
+ {showDiskUsage && }
+
+ );
+ }
+
+ const statusData = determineStatuses(urbackupData, widget);
+
+ return (
+
+
+
+
+ {showDiskUsage && }
+
+ );
+}
diff --git a/src/widgets/urbackup/proxy.js b/src/widgets/urbackup/proxy.js
new file mode 100644
index 00000000..b0e4a38b
--- /dev/null
+++ b/src/widgets/urbackup/proxy.js
@@ -0,0 +1,33 @@
+import {UrbackupServer} from "urbackup-server-api";
+
+import getServiceWidget from "utils/config/service-helpers";
+
+export default async function urbackupProxyHandler(req, res) {
+ const {group, service} = req.query;
+ const serviceWidget = await getServiceWidget(group, service);
+
+ const server = new UrbackupServer({
+ url: serviceWidget.url,
+ username: serviceWidget.username,
+ password: serviceWidget.password
+ });
+
+await (async () => {
+ try {
+ const allClients = await server.getStatus({includeRemoved: false});
+ let diskUsage = false
+ if (serviceWidget.fields?.includes("totalUsed")) {
+ diskUsage = await server.getUsage();
+ }
+ res.status(200).send({
+ clientStatuses: allClients,
+ diskUsage,
+ maxDays: serviceWidget.maxDays
+ });
+ } catch (error) {
+ res.status(500).json({ error: "Error communicating with UrBackup server" })
+ }
+ })();
+
+
+}
diff --git a/src/widgets/urbackup/widget.js b/src/widgets/urbackup/widget.js
new file mode 100644
index 00000000..5eac66d0
--- /dev/null
+++ b/src/widgets/urbackup/widget.js
@@ -0,0 +1,7 @@
+import urbackupProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: urbackupProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 66971489..7f9836cc 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -1,14 +1,17 @@
import adguard from "./adguard/widget";
+import atsumeru from "./atsumeru/widget";
import audiobookshelf from "./audiobookshelf/widget";
import authentik from "./authentik/widget";
import autobrr from "./autobrr/widget";
-import azurePipelines from "./azurePipelines/widget";
+import azuredevops from "./azuredevops/widget";
import bazarr from "./bazarr/widget";
import caddy from "./caddy/widget";
+import calibreweb from "./calibreweb/widget";
import changedetectionio from "./changedetectionio/widget";
import channelsdvrserver from "./channelsdvrserver/widget";
import cloudflared from "./cloudflared/widget";
import coinmarketcap from "./coinmarketcap/widget";
+import customapi from "./customapi/widget";
import deluge from "./deluge/widget";
import diskstation from "./diskstation/widget";
import downloadstation from "./downloadstation/widget";
@@ -17,7 +20,9 @@ import evcc from "./evcc/widget";
import fileflows from "./fileflows/widget";
import flood from "./flood/widget";
import freshrss from "./freshrss/widget";
+import gamedig from "./gamedig/widget";
import ghostfolio from "./ghostfolio/widget";
+import glances from "./glances/widget";
import gluetun from "./gluetun/widget";
import gotify from "./gotify/widget";
import grafana from "./grafana/widget";
@@ -34,10 +39,12 @@ import komga from "./komga/widget";
import kopia from "./kopia/widget";
import lidarr from "./lidarr/widget";
import mastodon from "./mastodon/widget";
+import mealie from "./mealie/widget";
import medusa from "./medusa/widget";
import minecraft from "./minecraft/widget";
import miniflux from "./miniflux/widget";
import mikrotik from "./mikrotik/widget";
+import mjpeg from "./mjpeg/widget";
import moonraker from "./moonraker/widget";
import mylar from "./mylar/widget";
import navidrome from "./navidrome/widget";
@@ -51,6 +58,7 @@ import omada from "./omada/widget";
import ombi from "./ombi/widget";
import opnsense from "./opnsense/widget";
import overseerr from "./overseerr/widget";
+import openmediavault from "./openmediavault/widget";
import paperlessngx from "./paperlessngx/widget";
import pfsense from "./pfsense/widget";
import photoprism from "./photoprism/widget";
@@ -84,24 +92,29 @@ import truenas from "./truenas/widget";
import unifi from "./unifi/widget";
import unmanic from "./unmanic/widget";
import uptimekuma from "./uptimekuma/widget";
+import uptimerobot from "./uptimerobot/widget";
import watchtower from "./watchtower/widget";
import whatsupdocker from "./whatsupdocker/widget";
import wgeasy from "./wgeasy/widget";
import xteve from "./xteve/widget";
import jdrssdownloader from "./jdrssdownloader/widget";
+import urbackup from "./urbackup/widget";
const widgets = {
adguard,
+ atsumeru,
audiobookshelf,
authentik,
autobrr,
- azurePipelines,
+ azuredevops,
bazarr,
caddy,
+ calibreweb,
changedetectionio,
channelsdvrserver,
cloudflared,
coinmarketcap,
+ customapi,
deluge,
diskstation,
downloadstation,
@@ -110,7 +123,9 @@ const widgets = {
fileflows,
flood,
freshrss,
+ gamedig,
ghostfolio,
+ glances,
gluetun,
gotify,
grafana,
@@ -129,10 +144,12 @@ const widgets = {
kopia,
lidarr,
mastodon,
+ mealie,
medusa,
minecraft,
miniflux,
mikrotik,
+ mjpeg,
moonraker,
mylar,
navidrome,
@@ -146,6 +163,7 @@ const widgets = {
ombi,
opnsense,
overseerr,
+ openmediavault,
paperlessngx,
pfsense,
photoprism,
@@ -180,6 +198,8 @@ const widgets = {
unifi_console: unifi,
unmanic,
uptimekuma,
+ uptimerobot,
+ urbackup,
watchtower,
whatsupdocker,
wgeasy,
diff --git a/tailwind.config.js b/tailwind.config.js
index a075f6e9..b2561700 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -59,5 +59,16 @@ module.exports = {
'backdrop-brightness-125',
'backdrop-brightness-150',
'backdrop-brightness-200',
+ 'grid-cols-1',
+ 'md:grid-cols-1',
+ 'md:grid-cols-2',
+ 'lg:grid-cols-1',
+ 'lg:grid-cols-2',
+ 'lg:grid-cols-3',
+ 'lg:grid-cols-4',
+ 'lg:grid-cols-5',
+ 'lg:grid-cols-6',
+ 'lg:grid-cols-7',
+ 'lg:grid-cols-8',
],
}