From 6c945d657377be163550abc72a9d3432a1255cae Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 23 Jan 2026 13:20:56 -0800
Subject: [PATCH] Feature: dockhand service widget (#6229)
---
docs/widgets/services/dockhand.md | 20 +++++
docs/widgets/services/index.md | 1 +
mkdocs.yml | 1 +
public/locales/en/common.json | 14 ++++
src/utils/config/service-helpers.js | 6 ++
src/widgets/components.js | 1 +
src/widgets/dockhand/component.jsx | 123 ++++++++++++++++++++++++++++
src/widgets/dockhand/proxy.js | 70 ++++++++++++++++
src/widgets/dockhand/widget.js | 14 ++++
src/widgets/widgets.js | 2 +
10 files changed, 252 insertions(+)
create mode 100644 docs/widgets/services/dockhand.md
create mode 100644 src/widgets/dockhand/component.jsx
create mode 100644 src/widgets/dockhand/proxy.js
create mode 100644 src/widgets/dockhand/widget.js
diff --git a/docs/widgets/services/dockhand.md b/docs/widgets/services/dockhand.md
new file mode 100644
index 000000000..7267d43bd
--- /dev/null
+++ b/docs/widgets/services/dockhand.md
@@ -0,0 +1,20 @@
+---
+title: Dockhand
+description: Dockhand Widget Configuration
+---
+
+Learn more about [Dockhand](https://dockhand.pro/).
+
+Note: The widget currently supports Dockhand's **local** authentication only.
+
+**Allowed fields:** (max 4): `running`, `stopped`, `paused`, `total`, `cpu`, `memory`, `images`, `volumes`, `events_today`, `pending_updates`, `stacks`.
+**Default fields:** `running`, `total`, `cpu`, `memory`.
+
+```yaml
+widget:
+ type: dockhand
+ url: http://localhost:3001
+ environment: local # optional: name or id; aggregates all when omitted
+ username: your-user # required for local auth
+ password: your-pass # required for local auth
+```
diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index 8110a7fc4..190dddec7 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -32,6 +32,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Deluge](deluge.md)
- [DeveLanCacheUI](develancacheui.md)
- [DiskStation](diskstation.md)
+- [Dockhand](dockhand.md)
- [DownloadStation](downloadstation.md)
- [Emby](emby.md)
- [ESPHome](esphome.md)
diff --git a/mkdocs.yml b/mkdocs.yml
index 2a21cbfc6..a7bc0d87b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -56,6 +56,7 @@ nav:
- widgets/services/deluge.md
- widgets/services/develancacheui.md
- widgets/services/diskstation.md
+ - widgets/services/dockhand.md
- widgets/services/downloadstation.md
- widgets/services/emby.md
- widgets/services/esphome.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 7adcd4c12..8a34ae361 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -1136,5 +1136,19 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
+ },
+ "dockhand": {
+ "running": "Running",
+ "stopped": "Stopped",
+ "cpu": "CPU",
+ "memory": "Memory",
+ "images": "Images",
+ "volumes": "Volumes",
+ "events_today": "Events Today",
+ "pending_updates": "Pending Updates",
+ "stacks": "Stacks",
+ "paused": "Paused",
+ "total": "Total",
+ "environment_not_found": "Environment Not Found"
}
}
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index dc141cd91..d358f9d2f 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -298,6 +298,9 @@ export function cleanServiceGroups(groups) {
container,
server,
+ // dockhand
+ environment,
+
// emby, jellyfin
enableBlocks,
enableNowPlaying,
@@ -605,6 +608,9 @@ export function cleanServiceGroups(groups) {
if (showTime) widget.showTime = showTime;
if (timezone) widget.timezone = timezone;
}
+ if (type === "dockhand") {
+ if (environment) widget.environment = environment;
+ }
if (type === "hdhomerun") {
if (tuner !== undefined) widget.tuner = tuner;
}
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 30bafd50a..7cb304755 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -29,6 +29,7 @@ const components = {
diskstation: dynamic(() => import("./diskstation/component")),
downloadstation: dynamic(() => import("./downloadstation/component")),
docker: dynamic(() => import("./docker/component")),
+ dockhand: dynamic(() => import("./dockhand/component")),
kubernetes: dynamic(() => import("./kubernetes/component")),
emby: dynamic(() => import("./emby/component")),
esphome: dynamic(() => import("./esphome/component")),
diff --git a/src/widgets/dockhand/component.jsx b/src/widgets/dockhand/component.jsx
new file mode 100644
index 000000000..50ccff180
--- /dev/null
+++ b/src/widgets/dockhand/component.jsx
@@ -0,0 +1,123 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import { useTranslation } from "next-i18next";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const MAX_FIELDS = 4;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+
+ if (!widget.fields) {
+ widget.fields = ["running", "total", "cpu", "memory"];
+ } else if (widget.fields.length > MAX_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_FIELDS);
+ }
+
+ const { data: stats, error: statsError } = useWidgetAPI(widget, "dashboard/stats");
+
+ if (statsError) {
+ return ;
+ }
+
+ if (!stats) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ let running;
+ let stopped;
+ let paused;
+ let totalContainers;
+ let pendingUpdates;
+ let cpuPercent;
+ let memoryPercent;
+ let imagesTotal;
+ let volumesTotal;
+ let stacksRunning;
+ let stacksTotal;
+ let eventsToday;
+
+ if (widget?.environment) {
+ const environment = stats.find(
+ (env) =>
+ env?.name?.toString().toLowerCase() === widget?.environment.toString().toLowerCase() ||
+ env?.id?.toString() === widget?.environment.toString(),
+ );
+ if (environment) {
+ running = environment?.containers?.running;
+ stopped = environment?.containers?.stopped ?? (environment?.containers?.total ?? 0) - (running ?? 0);
+ paused = environment?.containers?.paused;
+ pendingUpdates = environment?.containers?.pendingUpdates;
+ totalContainers = environment?.containers?.total;
+ cpuPercent = environment?.metrics?.cpuPercent;
+ memoryPercent = environment?.metrics?.memoryPercent;
+ imagesTotal = environment?.images?.total;
+ volumesTotal = environment?.volumes?.total;
+ stacksRunning = environment?.stacks?.running;
+ stacksTotal = environment?.stacks?.total;
+ eventsToday = environment?.events?.today;
+ } else {
+ return (
+
+ );
+ }
+ }
+
+ if (running === undefined) {
+ // Aggregate across all environments
+ running = stats.reduce((sum, env) => sum + (env?.containers?.running ?? 0), 0);
+ totalContainers = stats.reduce((sum, env) => sum + (env?.containers?.total ?? 0), 0);
+ stopped = totalContainers - running;
+ paused = stats.reduce((sum, env) => sum + (env?.containers?.paused ?? 0), 0);
+ pendingUpdates = stats.reduce((sum, env) => sum + (env?.containers?.pendingUpdates ?? 0), 0);
+ const totalCpu = stats.reduce((sum, env) => sum + (env?.metrics?.cpuPercent ?? 0), 0);
+ const totalMemory = stats.reduce((sum, env) => sum + (env?.metrics?.memoryPercent ?? 0), 0);
+ const envCount = stats.length;
+ cpuPercent = envCount > 0 ? totalCpu / envCount : 0;
+ memoryPercent = envCount > 0 ? totalMemory / envCount : 0;
+ imagesTotal = stats.reduce((sum, env) => sum + (env?.images?.total ?? 0), 0);
+ volumesTotal = stats.reduce((sum, env) => sum + (env?.volumes?.total ?? 0), 0);
+ stacksRunning = stats.reduce((sum, env) => sum + (env?.stacks?.running ?? 0), 0);
+ stacksTotal = stats.reduce((sum, env) => sum + (env?.stacks?.total ?? 0), 0);
+ eventsToday = stats.reduce((sum, env) => sum + (env?.events?.today ?? 0), 0);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/dockhand/proxy.js b/src/widgets/dockhand/proxy.js
new file mode 100644
index 000000000..c93e7bf39
--- /dev/null
+++ b/src/widgets/dockhand/proxy.js
@@ -0,0 +1,70 @@
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+import widgets from "widgets/widgets";
+
+const logger = createLogger("dockhandProxyHandler");
+
+async function login(widget) {
+ if (!widget.username || !widget.password) return false;
+
+ const baseUrl = widget.url?.replace(/\/+$/, "");
+ const [status] = await httpProxy(`${baseUrl}/api/auth/login`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ username: widget.username, password: widget.password }),
+ });
+
+ return status === 200;
+}
+
+export default async function dockhandProxyHandler(req, res) {
+ const { group, service, endpoint, index } = req.query;
+
+ if (!group || !service) {
+ logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const widget = await getServiceWidget(group, service, index);
+
+ 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 url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
+
+ let [status, contentType, data] = await httpProxy(url, {
+ method: req.method,
+ });
+
+ // Attempt login and retrying once
+ if (status === 401) {
+ const loggedIn = await login(widget);
+ if (loggedIn) {
+ [status, contentType, data] = await httpProxy(url, {
+ method: req.method,
+ });
+ }
+ }
+
+ let resultData = data;
+
+ if (status >= 400) {
+ logger.error("HTTP Error %d calling %s", status, url.toString());
+ return res.status(status).json({
+ error: {
+ message: "HTTP Error",
+ url: sanitizeErrorURL(url),
+ data: Buffer.isBuffer(resultData) ? Buffer.from(resultData).toString() : resultData,
+ },
+ });
+ }
+
+ if (contentType) res.setHeader("Content-Type", contentType);
+ return res.status(status).send(resultData);
+}
diff --git a/src/widgets/dockhand/widget.js b/src/widgets/dockhand/widget.js
new file mode 100644
index 000000000..c4279a12a
--- /dev/null
+++ b/src/widgets/dockhand/widget.js
@@ -0,0 +1,14 @@
+import dockhandProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/api/{endpoint}",
+ proxyHandler: dockhandProxyHandler,
+
+ mappings: {
+ "dashboard/stats": {
+ endpoint: "dashboard/stats",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index f7947de78..eeeae0cc1 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -23,6 +23,7 @@ import customapi from "./customapi/widget";
import deluge from "./deluge/widget";
import develancacheui from "./develancacheui/widget";
import diskstation from "./diskstation/widget";
+import dockhand from "./dockhand/widget";
import downloadstation from "./downloadstation/widget";
import emby from "./emby/widget";
import esphome from "./esphome/widget";
@@ -171,6 +172,7 @@ const widgets = {
deluge,
develancacheui,
diskstation,
+ dockhand,
downloadstation,
emby,
esphome,