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,