diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 53e76bd98..dc4fcd00d 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -701,5 +701,13 @@ "errored": "Errors", "noRecent": "Out of Date", "totalUsed": "Used Storage" + }, + "openmediavault": { + "downloading": "Downloading", + "total": "Total", + "running": "Running", + "stopped": "Stopped", + "passed": "Passed", + "failed": "Failed" } } diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 83b4b07bc..4488277ad 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -298,6 +298,7 @@ export function cleanServiceGroups(groups) { metric, // glances stream, // mjpeg fit, + method, // openmediavault widget } = cleanedService.widget; let fieldsList = fields; @@ -368,6 +369,9 @@ export function cleanServiceGroups(groups) { if (stream) cleanedService.widget.stream = stream; if (fit) cleanedService.widget.fit = fit; } + if (type === "openmediavault") { + if (method) cleanedService.widget.method = method; + } } return cleanedService; diff --git a/src/widgets/components.js b/src/widgets/components.js index 0db4878c9..d6785490c 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -59,6 +59,7 @@ const components = { ombi: dynamic(() => import("./ombi/component")), opnsense: dynamic(() => import("./opnsense/component")), overseerr: dynamic(() => import("./overseerr/component")), + openmediavault: dynamic(() => import("./openmediavault/component")), paperlessngx: dynamic(() => import("./paperlessngx/component")), pfsense: dynamic(() => import("./pfsense/component")), photoprism: dynamic(() => import("./photoprism/component")), diff --git a/src/widgets/openmediavault/component.jsx b/src/widgets/openmediavault/component.jsx new file mode 100644 index 000000000..bd34a7502 --- /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 000000000..ed776db0f --- /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 000000000..3ec66a45c --- /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 000000000..55a76db67 --- /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.overallstatus === "GOOD") { + return acc + 1; + } + return acc; +}; +const failedReduce = (acc, e) => { + if (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 000000000..a9099d244 --- /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 000000000..3678ebe8e --- /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/widgets.js b/src/widgets/widgets.js index d28f3b38c..aaf555efb 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -53,6 +53,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"; @@ -148,6 +149,7 @@ const widgets = { ombi, opnsense, overseerr, + openmediavault, paperlessngx, pfsense, photoprism,