Features: Add multiple widgets per service

This commit is contained in:
damii 2024-11-27 18:52:18 +11:00
parent 5ee2ea559c
commit 43b08ccd68
11 changed files with 327 additions and 288 deletions

View File

@ -5,7 +5,7 @@ description: Service Widget Configuration
Unless otherwise noted, URLs should not end with a `/` or other API path. Each widget will handle the path on its own. Unless otherwise noted, URLs should not end with a `/` or other API path. Each widget will handle the path on its own.
Each service can have one widget attached to it (often matching the service type, but that's not forced). Each service can have widgets attached to it (often matching the service type, but that's not forced).
In addition to the href of the service, you can also specify the target location in which to open that link. See [Link Target](settings.md#link-target) for more details. In addition to the href of the service, you can also specify the target location in which to open that link. See [Link Target](settings.md#link-target) for more details.
@ -22,6 +22,28 @@ Using Emby as an example, this is how you would attach the Emby service widget.
key: apikeyapikeyapikeyapikeyapikey key: apikeyapikeyapikeyapikeyapikey
``` ```
## Multiple Widgets
Each service can have multiple widgets attached to it. Each widget needs a unique name.
Using Emby as an example, here is how you would attack the Emby service widget alongside an uptime widget.
```yaml
- Emby:
icon: emby.png
href: http://emby.host.or.ip/
description: Movies & TV Shows
widgets:
- Emby:
type: emby
url: http://emby.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
- Uptime:
type: uptimekuma
url: http://uptimekuma.host.or.ip:port
slug: statuspageslug
```
## Field Visibility ## Field Visibility
Each widget can optionally provide a list of which fields should be visible via the `fields` widget property. If no fields are specified, then all fields will be displayed. The `fields` property must be a valid YAML array of strings. As an example, here is the entry for Sonarr showing only a couple of fields. Each widget can optionally provide a list of which fields should be visible via the `fields` widget property. If no fields are specified, then all fields will be displayed. The `fields` property must be a valid YAML array of strings. As an example, here is the entry for Sonarr showing only a couple of fields.

View File

@ -19,10 +19,15 @@ Service widgets are used to display the status of a service, often a web service
description: Watch movies and TV shows. description: Watch movies and TV shows.
server: localhost server: localhost
container: plex container: plex
widget: widgets:
type: tautulli - Tautulli:
url: http://172.16.1.1:8181 type: tautulli
key: aabbccddeeffgghhiijjkkllmmnnoo url: http://172.16.1.1:8181
key: aabbccddeeffgghhiijjkkllmmnnoo
- UptimeKuma:
type: uptimekuma
url: http://172.16.1.2:8080
slug: aaaaaaabbbbb
``` ```
## Info Widgets ## Info Widgets

View File

@ -154,7 +154,9 @@ export default function Item({ service, group, useEqualHeights }) {
</div> </div>
)} )}
{service.widget && <Widget service={service} />} {service.widgets.map((widget) => (
<Widget widget={widget} service={service} />
))}
</div> </div>
</li> </li>
); );

View File

@ -3,22 +3,24 @@ import { useTranslation } from "next-i18next";
import ErrorBoundary from "components/errorboundry"; import ErrorBoundary from "components/errorboundry";
import components from "widgets/components"; import components from "widgets/components";
export default function Widget({ service }) { export default function Widget({ widget, service }) {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const ServiceWidget = components[service.widget.type]; const ServiceWidget = components[widget.type];
const fullService = service;
fullService.widget = widget;
if (ServiceWidget) { if (ServiceWidget) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ServiceWidget service={service} /> <ServiceWidget service={fullService} />
</ErrorBoundary> </ErrorBoundary>
); );
} }
return ( return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1 service-missing"> <div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1 service-missing">
<div className="font-thin text-sm">{t("widget.missing_type", { type: service.widget.type })}</div> <div className="font-thin text-sm">{t("widget.missing_type", { type: widget.type })}</div>
</div> </div>
); );
} }

View File

@ -9,8 +9,8 @@ const logger = createLogger("servicesProxy");
export default async function handler(req, res) { export default async function handler(req, res) {
try { try {
const { service, group } = req.query; const { service, group, name } = req.query;
const serviceWidget = await getServiceWidget(group, service); const serviceWidget = await getServiceWidget(group, service, name);
let type = serviceWidget?.type; let type = serviceWidget?.type;
// exceptions // exceptions

View File

@ -354,311 +354,315 @@ export function cleanServiceGroups(groups) {
if (typeof cleanedService.weight !== "number") { if (typeof cleanedService.weight !== "number") {
cleanedService.weight = 0; cleanedService.weight = 0;
} }
cleanedService.widgets = cleanedService.widgets ? cleanedService.widgets : [];
if (cleanedService.widget != undefined) cleanedService.widgets.push(cleanedService.widget);
if (cleanedService.widgets != []) {
cleanedService.widgets = cleanedService.widgets.map((widget) => {
// whitelisted set of keys to pass to the frontend
// alphabetical, grouped by widget(s)
const {
// all widgets
fields,
hideErrors,
type,
if (cleanedService.widget) { // azuredevops
// whitelisted set of keys to pass to the frontend repositoryId,
// alphabetical, grouped by widget(s) userEmail,
const {
// all widgets
fields,
hideErrors,
type,
// azuredevops // beszel
repositoryId, systemId,
userEmail,
// beszel // calendar
systemId, firstDayInWeek,
integrations,
maxEvents,
showTime,
previousDays,
view,
timezone,
// calendar // coinmarketcap
firstDayInWeek, currency,
integrations, defaultinterval,
maxEvents, slugs,
showTime, symbols,
previousDays,
view,
timezone,
// coinmarketcap // customapi
currency, mappings,
defaultinterval, display,
slugs,
symbols,
// customapi // diskstation
mappings, volume,
display,
// diskstation // docker
volume, container,
server,
// docker // emby, jellyfin
container, enableBlocks,
server, enableNowPlaying,
// emby, jellyfin // emby, jellyfin, tautulli
enableBlocks, enableUser,
enableNowPlaying, expandOneStreamToTwoRows,
showEpisodeNumber,
// emby, jellyfin, tautulli // frigate
enableUser, enableRecentEvents,
expandOneStreamToTwoRows,
showEpisodeNumber,
// frigate // glances, immich, mealie, pihole, pfsense
enableRecentEvents, version,
// glances, immich, mealie, pihole, pfsense // glances
version, chart,
metric,
pointsLimit,
diskUnits,
// glances // glances, customapi, iframe, prometheusmetric
chart, refreshInterval,
metric,
pointsLimit,
diskUnits,
// glances, customapi, iframe, prometheusmetric // hdhomerun
refreshInterval, tuner,
// hdhomerun // healthchecks
tuner, uuid,
// healthchecks // iframe
uuid, allowFullscreen,
allowPolicy,
allowScrolling,
classes,
loadingStrategy,
referrerPolicy,
src,
// iframe // kopia
allowFullscreen, snapshotHost,
allowPolicy, snapshotPath,
allowScrolling,
classes,
loadingStrategy,
referrerPolicy,
src,
// kopia // kubernetes
snapshotHost, app,
snapshotPath, namespace,
podSelector,
// kubernetes // lubelogger
app, vehicleID,
namespace,
podSelector,
// lubelogger // mjpeg
vehicleID, fit,
stream,
// mjpeg // openmediavault
fit, method,
stream,
// openmediavault // openwrt
method, interfaceName,
// openwrt // opnsense, pfsense
interfaceName, wan,
// opnsense, pfsense // prometheusmetric
wan, metrics,
// prometheusmetric // proxmox
metrics, node,
// proxmox // speedtest
node, bitratePrecision,
// speedtest // sonarr, radarr
bitratePrecision, enableQueue,
// sonarr, radarr // stocks
enableQueue, watchlist,
showUSMarketStatus,
// stocks // truenas
watchlist, enablePools,
showUSMarketStatus, nasType,
// truenas // unifi
enablePools, site,
nasType,
// unifi // vikunja
site, enableTaskList,
// vikunja // wgeasy
enableTaskList, threshold,
// wgeasy // technitium
threshold, range,
// technitium // spoolman
range, spoolIds,
} = Object.keys(widget)[0] != "type" ? widget[Object.keys(widget)[0]] : widget;
// spoolman let fieldsList = fields;
spoolIds, if (typeof fields === "string") {
} = cleanedService.widget; try {
fieldsList = JSON.parse(fields);
let fieldsList = fields; } catch (e) {
if (typeof fields === "string") { logger.error("Invalid fields list detected in config for service '%s'", service.name);
try { fieldsList = null;
fieldsList = JSON.parse(fields); }
} catch (e) {
logger.error("Invalid fields list detected in config for service '%s'", service.name);
fieldsList = null;
} }
}
cleanedService.widget = { widget = {
type, type,
fields: fieldsList || null, fields: fieldsList || null,
hide_errors: hideErrors || false, hide_errors: hideErrors || false,
service_name: service.name, service_name: service.name,
service_group: serviceGroup.name, service_group: serviceGroup.name,
}; widget_name: Object.keys(widget)[0] != "type" ? Object.keys(widget)[0] : "",
};
if (type === "azuredevops") { if (type === "azuredevops") {
if (userEmail) cleanedService.widget.userEmail = userEmail; if (userEmail) widget.userEmail = userEmail;
if (repositoryId) cleanedService.widget.repositoryId = repositoryId; if (repositoryId) widget.repositoryId = repositoryId;
}
if (type === "beszel") {
if (systemId) cleanedService.widget.systemId = systemId;
}
if (type === "coinmarketcap") {
if (currency) cleanedService.widget.currency = currency;
if (symbols) cleanedService.widget.symbols = symbols;
if (slugs) cleanedService.widget.slugs = slugs;
if (defaultinterval) cleanedService.widget.defaultinterval = defaultinterval;
}
if (type === "docker") {
if (server) cleanedService.widget.server = server;
if (container) cleanedService.widget.container = container;
}
if (type === "unifi") {
if (site) cleanedService.widget.site = site;
}
if (type === "proxmox") {
if (node) cleanedService.widget.node = node;
}
if (type === "kubernetes") {
if (namespace) cleanedService.widget.namespace = namespace;
if (app) cleanedService.widget.app = app;
if (podSelector) cleanedService.widget.podSelector = podSelector;
}
if (type === "iframe") {
if (src) cleanedService.widget.src = src;
if (classes) cleanedService.widget.classes = classes;
if (referrerPolicy) cleanedService.widget.referrerPolicy = referrerPolicy;
if (allowPolicy) cleanedService.widget.allowPolicy = allowPolicy;
if (allowFullscreen) cleanedService.widget.allowFullscreen = allowFullscreen;
if (loadingStrategy) cleanedService.widget.loadingStrategy = loadingStrategy;
if (allowScrolling) cleanedService.widget.allowScrolling = allowScrolling;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
}
if (["opnsense", "pfsense"].includes(type)) {
if (wan) cleanedService.widget.wan = wan;
}
if (["emby", "jellyfin"].includes(type)) {
if (enableBlocks !== undefined) cleanedService.widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) cleanedService.widget.enableNowPlaying = JSON.parse(enableNowPlaying);
}
if (["emby", "jellyfin", "tautulli"].includes(type)) {
if (expandOneStreamToTwoRows !== undefined)
cleanedService.widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);
if (showEpisodeNumber !== undefined)
cleanedService.widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);
if (enableUser !== undefined) cleanedService.widget.enableUser = !!JSON.parse(enableUser);
}
if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) cleanedService.widget.enableQueue = JSON.parse(enableQueue);
}
if (type === "truenas") {
if (enablePools !== undefined) cleanedService.widget.enablePools = JSON.parse(enablePools);
if (nasType !== undefined) cleanedService.widget.nasType = nasType;
}
if (["diskstation", "qnap"].includes(type)) {
if (volume) cleanedService.widget.volume = volume;
}
if (type === "kopia") {
if (snapshotHost) cleanedService.widget.snapshotHost = snapshotHost;
if (snapshotPath) cleanedService.widget.snapshotPath = snapshotPath;
}
if (["glances", "immich", "mealie", "pfsense", "pihole"].includes(type)) {
if (version) cleanedService.widget.version = parseInt(version, 10);
}
if (type === "glances") {
if (metric) cleanedService.widget.metric = metric;
if (chart !== undefined) {
cleanedService.widget.chart = chart;
} else {
cleanedService.widget.chart = true;
} }
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
if (pointsLimit) cleanedService.widget.pointsLimit = pointsLimit; if (type === "beszel") {
if (diskUnits) cleanedService.widget.diskUnits = diskUnits; if (systemId) widget.systemId = systemId;
}
if (type === "mjpeg") {
if (stream) cleanedService.widget.stream = stream;
if (fit) cleanedService.widget.fit = fit;
}
if (type === "openmediavault") {
if (method) cleanedService.widget.method = method;
}
if (type === "openwrt") {
if (interfaceName) cleanedService.widget.interfaceName = interfaceName;
}
if (type === "customapi") {
if (mappings) cleanedService.widget.mappings = mappings;
if (display) cleanedService.widget.display = display;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
}
if (type === "calendar") {
if (integrations) cleanedService.widget.integrations = integrations;
if (firstDayInWeek) cleanedService.widget.firstDayInWeek = firstDayInWeek;
if (view) cleanedService.widget.view = view;
if (maxEvents) cleanedService.widget.maxEvents = maxEvents;
if (previousDays) cleanedService.widget.previousDays = previousDays;
if (showTime) cleanedService.widget.showTime = showTime;
if (timezone) cleanedService.widget.timezone = timezone;
}
if (type === "hdhomerun") {
if (tuner !== undefined) cleanedService.widget.tuner = tuner;
}
if (type === "healthchecks") {
if (uuid !== undefined) cleanedService.widget.uuid = uuid;
}
if (type === "speedtest") {
if (bitratePrecision !== undefined) {
cleanedService.widget.bitratePrecision = parseInt(bitratePrecision, 10);
} }
}
if (type === "stocks") { if (type === "coinmarketcap") {
if (watchlist) cleanedService.widget.watchlist = watchlist; if (currency) widget.currency = currency;
if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus; if (symbols) widget.symbols = symbols;
} if (slugs) widget.slugs = slugs;
if (type === "wgeasy") { if (defaultinterval) widget.defaultinterval = defaultinterval;
if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10); }
}
if (type === "frigate") { if (type === "docker") {
if (enableRecentEvents !== undefined) cleanedService.widget.enableRecentEvents = enableRecentEvents; if (server) widget.server = server;
} if (container) widget.container = container;
if (type === "technitium") { }
if (range !== undefined) cleanedService.widget.range = range; if (type === "unifi") {
} if (site) widget.site = site;
if (type === "lubelogger") { }
if (vehicleID !== undefined) cleanedService.widget.vehicleID = parseInt(vehicleID, 10); if (type === "proxmox") {
} if (node) widget.node = node;
if (type === "vikunja") { }
if (enableTaskList !== undefined) cleanedService.widget.enableTaskList = !!enableTaskList; if (type === "kubernetes") {
} if (namespace) widget.namespace = namespace;
if (type === "prometheusmetric") { if (app) widget.app = app;
if (metrics) cleanedService.widget.metrics = metrics; if (podSelector) widget.podSelector = podSelector;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; }
} if (type === "iframe") {
if (type === "spoolman") { if (src) widget.src = src;
if (spoolIds !== undefined) cleanedService.widget.spoolIds = spoolIds; if (classes) widget.classes = classes;
} if (referrerPolicy) widget.referrerPolicy = referrerPolicy;
if (allowPolicy) widget.allowPolicy = allowPolicy;
if (allowFullscreen) widget.allowFullscreen = allowFullscreen;
if (loadingStrategy) widget.loadingStrategy = loadingStrategy;
if (allowScrolling) widget.allowScrolling = allowScrolling;
if (refreshInterval) widget.refreshInterval = refreshInterval;
}
if (["opnsense", "pfsense"].includes(type)) {
if (wan) widget.wan = wan;
}
if (["emby", "jellyfin"].includes(type)) {
if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
}
if (["emby", "jellyfin", "tautulli"].includes(type)) {
if (expandOneStreamToTwoRows !== undefined)
widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);
if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);
if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser);
}
if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue);
}
if (type === "truenas") {
if (enablePools !== undefined) widget.enablePools = JSON.parse(enablePools);
if (nasType !== undefined) widget.nasType = nasType;
}
if (["diskstation", "qnap"].includes(type)) {
if (volume) widget.volume = volume;
}
if (type === "kopia") {
if (snapshotHost) widget.snapshotHost = snapshotHost;
if (snapshotPath) widget.snapshotPath = snapshotPath;
}
if (["glances", "immich", "mealie", "pfsense", "pihole"].includes(type)) {
if (version) widget.version = parseInt(version, 10);
}
if (type === "glances") {
if (metric) widget.metric = metric;
if (chart !== undefined) {
widget.chart = chart;
} else {
widget.chart = true;
}
if (refreshInterval) widget.refreshInterval = refreshInterval;
if (pointsLimit) widget.pointsLimit = pointsLimit;
if (diskUnits) widget.diskUnits = diskUnits;
}
if (type === "mjpeg") {
if (stream) widget.stream = stream;
if (fit) widget.fit = fit;
}
if (type === "openmediavault") {
if (method) widget.method = method;
}
if (type === "openwrt") {
if (interfaceName) widget.interfaceName = interfaceName;
}
if (type === "customapi") {
if (mappings) widget.mappings = mappings;
if (display) widget.display = display;
if (refreshInterval) widget.refreshInterval = refreshInterval;
}
if (type === "calendar") {
if (integrations) widget.integrations = integrations;
if (firstDayInWeek) widget.firstDayInWeek = firstDayInWeek;
if (view) widget.view = view;
if (maxEvents) widget.maxEvents = maxEvents;
if (previousDays) widget.previousDays = previousDays;
if (showTime) widget.showTime = showTime;
if (timezone) widget.timezone = timezone;
}
if (type === "hdhomerun") {
if (tuner !== undefined) widget.tuner = tuner;
}
if (type === "healthchecks") {
if (uuid !== undefined) widget.uuid = uuid;
}
if (type === "speedtest") {
if (bitratePrecision !== undefined) {
widget.bitratePrecision = parseInt(bitratePrecision, 10);
}
}
if (type === "stocks") {
if (watchlist) widget.watchlist = watchlist;
if (showUSMarketStatus) widget.showUSMarketStatus = showUSMarketStatus;
}
if (type === "wgeasy") {
if (threshold !== undefined) widget.threshold = parseInt(threshold, 10);
}
if (type === "frigate") {
if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents;
}
if (type === "technitium") {
if (range !== undefined) widget.range = range;
}
if (type === "lubelogger") {
if (vehicleID !== undefined) widget.vehicleID = parseInt(vehicleID, 10);
}
if (type === "vikunja") {
if (enableTaskList !== undefined) widget.enableTaskList = !!enableTaskList;
}
if (type === "prometheusmetric") {
if (metrics) widget.metrics = metrics;
if (refreshInterval) widget.refreshInterval = refreshInterval;
}
if (type === "spoolman") {
if (spoolIds !== undefined) widget.spoolIds = spoolIds;
}
return widget;
});
} }
return cleanedService; return cleanedService;
@ -693,12 +697,15 @@ export async function getServiceItem(group, service) {
return false; return false;
} }
export default async function getServiceWidget(group, service) { export default async function getServiceWidget(group, service, name = null) {
const serviceItem = await getServiceItem(group, service); const serviceItem = await getServiceItem(group, service);
if (serviceItem) { if (serviceItem && (name == null || name === "undefined")) {
const { widget } = serviceItem; const { widget } = serviceItem;
return widget; return widget;
} }
if (serviceItem && name != null && name !== "undefined") {
const { widgets } = serviceItem;
return widgets.filter((widget) => Object.keys(widget)[0] == name)[0][name];
}
return false; return false;
} }

View File

@ -12,6 +12,7 @@ export function getURLSearchParams(widget, endpoint) {
const params = new URLSearchParams({ const params = new URLSearchParams({
group: widget.service_group, group: widget.service_group,
service: widget.service_name, service: widget.service_name,
...(widget.widget_name != "" && { name: widget.widget_name }),
}); });
if (endpoint) { if (endpoint) {
params.append("endpoint", endpoint); params.append("endpoint", endpoint);

View File

@ -9,10 +9,10 @@ import widgets from "widgets/widgets";
const logger = createLogger("credentialedProxyHandler"); const logger = createLogger("credentialedProxyHandler");
export default async function credentialedProxyHandler(req, res, map) { export default async function credentialedProxyHandler(req, res, map) {
const { group, service, endpoint } = req.query; const { group, service, endpoint, name } = req.query;
if (group && service) { if (group && service) {
const widget = await getServiceWidget(group, service); const widget = await getServiceWidget(group, service, name ? name : null);
if (!widgets?.[widget.type]?.api) { if (!widgets?.[widget.type]?.api) {
return res.status(403).json({ error: "Service does not support API calls" }); return res.status(403).json({ error: "Service does not support API calls" });

View File

@ -8,10 +8,10 @@ import widgets from "widgets/widgets";
const logger = createLogger("genericProxyHandler"); const logger = createLogger("genericProxyHandler");
export default async function genericProxyHandler(req, res, map) { export default async function genericProxyHandler(req, res, map) {
const { group, service, endpoint } = req.query; const { group, service, endpoint, name } = req.query;
if (group && service) { if (group && service) {
const widget = await getServiceWidget(group, service); const widget = await getServiceWidget(group, service, name ? name : null);
if (!widgets?.[widget.type]?.api) { if (!widgets?.[widget.type]?.api) {
return res.status(403).json({ error: "Service does not support API calls" }); return res.status(403).json({ error: "Service does not support API calls" });

View File

@ -65,10 +65,10 @@ export async function sendJsonRpcRequest(url, method, params, widget) {
} }
export default async function jsonrpcProxyHandler(req, res) { export default async function jsonrpcProxyHandler(req, res) {
const { group, service, endpoint: method } = req.query; const { group, service, endpoint: method, name } = req.query;
if (group && service) { if (group && service) {
const widget = await getServiceWidget(group, service); const widget = await getServiceWidget(group, service, name ? name : null);
const api = widgets?.[widget.type]?.api; const api = widgets?.[widget.type]?.api;
const [, mapping] = Object.entries(widgets?.[widget.type]?.mappings).find(([, value]) => value.endpoint === method); const [, mapping] = Object.entries(widgets?.[widget.type]?.mappings).find(([, value]) => value.endpoint === method);

View File

@ -131,13 +131,13 @@ function toError(url, synologyError) {
} }
export default async function synologyProxyHandler(req, res) { export default async function synologyProxyHandler(req, res) {
const { group, service, endpoint } = req.query; const { group, service, endpoint, name } = req.query;
if (!group || !service) { if (!group || !service) {
return res.status(400).json({ error: "Invalid proxy service type" }); return res.status(400).json({ error: "Invalid proxy service type" });
} }
const serviceWidget = await getServiceWidget(group, service); const serviceWidget = await getServiceWidget(group, service, name ? name : null);
const widget = widgets?.[serviceWidget.type]; const widget = widgets?.[serviceWidget.type];
const mapping = widget?.mappings?.[endpoint]; const mapping = widget?.mappings?.[endpoint];
if (!widget.api || !mapping) { if (!widget.api || !mapping) {