diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 7adcd4c12..8d6feaf5d 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1136,5 +1136,11 @@ "songs": "Songs", "time": "Time", "artists": "Artists" + }, + "dockhand": { + "running": "Running", + "stopped": "Stopped", + "cpu": "CPU", + "memory": "Memory" } } 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..235130b31 --- /dev/null +++ b/src/widgets/dockhand/component.jsx @@ -0,0 +1,62 @@ +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"; + +export default function Component({ service }) { + const { t } = useTranslation(); + const { widget } = service; + + const { data: stats, error: statsError } = useWidgetAPI(widget, "dashboard/stats"); + + if (statsError) { + return ; + } + + if (!stats) { + return ( + + + + + + + ); + } + + let running; + let stopped; + let cpuPercent; + let memoryPercent; + + if (widget?.environment) { + // Filter by environment if set + const environment = stats.find((env) => env.name === widget.environment); + if (environment) { + running = environment?.containers?.running; + stopped = environment?.containers?.stopped ?? (environment?.containers?.total ?? 0) - (running ?? 0); + cpuPercent = environment?.metrics?.cpuPercent; + memoryPercent = environment?.metrics?.memoryPercent; + } + } else { + // Aggregate across all environments + running = stats.reduce((sum, env) => sum + (env?.containers?.running ?? 0), 0); + const total = stats.reduce((sum, env) => sum + (env?.containers?.total ?? 0), 0); + stopped = total - running; + 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; + } + + return ( + + + + + + + ); +} diff --git a/src/widgets/dockhand/proxy.js b/src/widgets/dockhand/proxy.js new file mode 100644 index 000000000..528e91002 --- /dev/null +++ b/src/widgets/dockhand/proxy.js @@ -0,0 +1,94 @@ +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 validateWidgetData from "utils/proxy/validate-widget-data"; +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(/\/+$/, ""); + if (!baseUrl) return false; + + const loginUrl = new URL(`${baseUrl}/api/auth/login`); + + const [status] = await httpProxy(loginUrl, { + 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, map) { + 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" }); + } + + if (!widgets?.[widget.type]?.api) { + return res.status(403).json({ error: "Service does not support API calls" }); + } + + 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 (resultData?.error?.url) { + resultData.error.url = sanitizeErrorURL(url); + } + + if (status === 204 || status === 304) { + return res.status(status).end(); + } + + 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 (status === 200) { + if (!validateWidgetData(widget, endpoint, resultData)) { + return res.status(500).json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } }); + } + if (map) resultData = map(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,