diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 52db2cb4..b4716368 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -447,5 +447,13 @@
"photos": "Photos",
"videos": "Videos",
"storage": "Storage"
+ },
+ "uptimekuma": {
+ "status": "status",
+ "uptime": "uptime",
+ "good": "All Systems Operational",
+ "warn": "Partially Degraded Service",
+ "bad": "Degraded Service",
+ "unknown": "Unknown service status"
}
-}
+}
\ No newline at end of file
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 43a46fa9..505807c4 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -63,6 +63,7 @@ const components = {
watchtower: dynamic(() => import("./watchtower/component")),
xteve: dynamic(() => import("./xteve/component")),
immich: dynamic(() => import("./immich/component")),
+ uptimekuma: dynamic(() => import("./uptimekuma/component")),
};
export default components;
diff --git a/src/widgets/uptimekuma/component.jsx b/src/widgets/uptimekuma/component.jsx
new file mode 100644
index 00000000..dd112db4
--- /dev/null
+++ b/src/widgets/uptimekuma/component.jsx
@@ -0,0 +1,45 @@
+import { useTranslation } from "next-i18next";
+
+import Container from "components/services/widget/container";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import Block from "components/services/widget/block";
+
+const Status = {
+ good: "uptimekuma.good",
+ warn: "uptimekuma.warn",
+ bad: "uptimekuma.bad",
+ unknown: "uptimekuma.unknown",
+};
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+
+ const { data: statusData, error: statusError } = useWidgetAPI(widget);
+
+ if (statusError) {
+ return ;
+ }
+
+ if (!statusData) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (statusData.icon) {
+ // eslint-disable-next-line no-param-reassign
+ service.icon = statusData.icon;
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/widgets/uptimekuma/proxy.js b/src/widgets/uptimekuma/proxy.js
new file mode 100644
index 00000000..6722de85
--- /dev/null
+++ b/src/widgets/uptimekuma/proxy.js
@@ -0,0 +1,95 @@
+import { httpProxy } from "utils/proxy/http";
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+
+const logger = createLogger("uptimeKumaProxyHandler");
+
+async function getStatus(widget) {
+ const url = new URL(`${widget.url}/api/status-page/${widget.slug}`).toString();
+ logger.debug("get status %s", url);
+ const params = { method: "GET", headers: {} };
+ const [status, , data] = await httpProxy(url, params);
+ try {
+ return [status, JSON.parse(data)];
+ } catch (e) {
+ logger.error("Error decoding status data. Data: %s", data.toString());
+ return [status, null];
+ }
+}
+
+async function getHeartbeat(widget) {
+ const url = new URL(`${widget.url}/api/status-page/heartbeat/${widget.slug}`).toString();
+ logger.debug("get heartbeat %s", url);
+ const params = { method: "GET", headers: {} };
+ const [status, , data] = await httpProxy(url, params);
+ try {
+ return [status, JSON.parse(data)];
+ } catch (e) {
+ logger.error("Error decoding heartbeat data. Data: %s", data.toString());
+ return [status, null];
+ }
+}
+
+function statusMessage(data) {
+ if (!data || Object.keys(data.heartbeatList) === 0) {
+ return "unknown";
+ }
+
+ let result = "good";
+ let hasUp = false;
+ Object.values(data.heartbeatList).forEach((el) => {
+ const index = el.length - 1;
+ if (el[index].status === 1) {
+ hasUp = true;
+ } else {
+ result = "warn";
+ }
+ });
+
+ if (!hasUp) {
+ result = "bad";
+ }
+ return result;
+}
+
+function uptime(data) {
+ if (!data) {
+ return 0;
+ }
+
+ const uptimeList = Object.values(data.uptimeList);
+ const percent = uptimeList.reduce((a, b) => a + b, 0) / uptimeList.length || 0;
+ return (percent * 100).toFixed(1);
+}
+
+export default async function uptimeKumaProxyHandler(req, res) {
+ const { group, service } = req.query;
+ const widget = await getServiceWidget(group, service);
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const [[statusCode, statusData], [heartbeatCode, heartbeatData]] = await Promise.all([
+ getStatus(widget),
+ getHeartbeat(widget),
+ ]);
+
+ if (statusCode !== 200) {
+ logger.error("HTTP %d getting status data error. Data: %s", statusCode, statusData);
+ return res.status(statusCode).send(statusData);
+ }
+
+ if (heartbeatCode !== 200) {
+ logger.error("HTTP %d getting heartbeat data error. Data: %s", heartbeatCode, heartbeatData);
+ return res.status(heartbeatCode).send(heartbeatData);
+ }
+
+ const icon = statusData?.config ? statusData.config.icon : null;
+ return res.status(200).send({
+ uptime: uptime(heartbeatData),
+ message: statusMessage(heartbeatData),
+ incident: statusData?.incident ? statusData.incident.title : "",
+ icon: `${widget.url}${icon}`,
+ });
+}
diff --git a/src/widgets/uptimekuma/widget.js b/src/widgets/uptimekuma/widget.js
new file mode 100644
index 00000000..9687e1a4
--- /dev/null
+++ b/src/widgets/uptimekuma/widget.js
@@ -0,0 +1,8 @@
+// import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+import uptimeKumaProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: uptimeKumaProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 133903fb..7da77a0a 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -57,6 +57,7 @@ import unifi from "./unifi/widget";
import watchtower from "./watchtower/widget";
import xteve from "./xteve/widget";
import immich from "./immich/widget";
+import uptimekuma from "./uptimekuma/widget";
const widgets = {
adguard,
@@ -121,6 +122,7 @@ const widgets = {
watchtower,
xteve,
immich,
+ uptimekuma,
};
export default widgets;