Fix linting issues

This commit is contained in:
damii
2024-11-28 14:39:06 +11:00
54 changed files with 330 additions and 224 deletions

View File

@@ -153,6 +153,18 @@ labels:
- homepage.widget.fields=["field1","field2"] # optional
```
Multiple widgets can be specified by incrementing the index, e.g.
```yaml
labels: ...
- homepage.widget[0].type=emby
- homepage.widget[0].url=http://emby.home
- homepage.widget[0].key=yourembyapikeyhere
- homepage.widget[1].type=uptimekuma
- homepage.widget[1].url=http://uptimekuma.home
- homepage.widget[1].slug=youreventslughere
```
You can add specify fields for e.g. the [CustomAPI](../widgets/services/customapi.md) widget by using array-style dot notation:
```yaml

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.
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.
@@ -22,6 +22,24 @@ Using Emby as an example, this is how you would attach the Emby service widget.
key: apikeyapikeyapikeyapikeyapikey
```
## Multiple Widgets
Each service can have multiple widgets attached to it, for example:
```yaml
- Emby:
icon: emby.png
href: http://emby.host.or.ip/
description: Movies & TV Shows
widgets:
- type: emby
url: http://emby.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
- type: uptimekuma
url: http://uptimekuma.host.or.ip:port
slug: statuspageslug
```
## 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.

View File

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

View File

@@ -3,12 +3,13 @@ import classNames from "classnames";
import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md";
import { columnMap } from "../../utils/layout/columns";
import List from "components/services/list";
import ResolvedIcon from "components/resolvedicon";
export default function ServicesGroup({
group,
services,
layout,
fiveColumns,
disableCollapse,
@@ -23,7 +24,7 @@ export default function ServicesGroup({
return (
<div
key={services.name}
key={group.name}
className={classNames(
"services-group",
layout?.style === "row" ? "basis-full" : "basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4",
@@ -42,7 +43,7 @@ export default function ServicesGroup({
</div>
)}
<h2 className="flex text-theme-800 dark:text-theme-300 text-xl font-medium service-group-name">
{services.name}
{group.name}
</h2>
<MdKeyboardArrowDown
className={classNames(
@@ -74,7 +75,31 @@ export default function ServicesGroup({
}}
>
<Disclosure.Panel className="transition-all overflow-hidden duration-300 ease-out" ref={panel} static>
<List group={group} services={services.services} layout={layout} useEqualHeights={useEqualHeights} />
<List
groupName={group.name}
services={group.services}
layout={layout}
useEqualHeights={useEqualHeights}
/>
{group.groups?.length > 0 && (
<div
className={`grid ${
layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col"
} gap-2`}
>
{group.groups.map((subgroup) => (
<ServicesGroup
key={subgroup.name}
group={subgroup}
layout={layout?.[subgroup.name]}
fiveColumns={fiveColumns}
disableCollapse={disableCollapse}
useEqualHeights={useEqualHeights}
groupsInitiallyCollapsed={groupsInitiallyCollapsed}
/>
))}
</div>
)}
</Disclosure.Panel>
</Transition>
</>

View File

@@ -12,7 +12,7 @@ import Kubernetes from "widgets/kubernetes/component";
import { SettingsContext } from "utils/contexts/settings";
import ResolvedIcon from "components/resolvedicon";
export default function Item({ service, group, useEqualHeights }) {
export default function Item({ service, groupName, useEqualHeights }) {
const hasLink = service.href && service.href !== "#";
const { settings } = useContext(SettingsContext);
const showStats = service.showStats === false ? false : settings.showStats;
@@ -90,14 +90,14 @@ export default function Item({ service, group, useEqualHeights }) {
>
{service.ping && (
<div className="flex-shrink-0 flex items-center justify-center service-tag service-ping">
<Ping group={group} service={service.name} style={statusStyle} />
<Ping groupName={groupName} serviceName={service.name} style={statusStyle} />
<span className="sr-only">Ping status</span>
</div>
)}
{service.siteMonitor && (
<div className="flex-shrink-0 flex items-center justify-center service-tag service-site-monitor">
<SiteMonitor group={group} service={service.name} style={statusStyle} />
<SiteMonitor groupName={groupName} serviceName={service.name} style={statusStyle} />
<span className="sr-only">Site monitor status</span>
</div>
)}
@@ -154,7 +154,9 @@ export default function Item({ service, group, useEqualHeights }) {
</div>
)}
{service.widget && <Widget service={service} />}
{service.widgets.map((widget) => (
<Widget widget={widget} service={service} key={widget.index} />
))}
</div>
</li>
);

View File

@@ -4,7 +4,7 @@ import { columnMap } from "../../utils/layout/columns";
import Item from "components/services/item";
export default function List({ group, services, layout, useEqualHeights }) {
export default function List({ groupName, services, layout, useEqualHeights }) {
return (
<ul
className={classNames(
@@ -16,7 +16,7 @@ export default function List({ group, services, layout, useEqualHeights }) {
<Item
key={[service.container, service.app, service.name].filter((s) => s).join("-")}
service={service}
group={group}
groupName={groupName}
useEqualHeights={layout?.useEqualHeights ?? useEqualHeights}
/>
))}

View File

@@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next";
import useSWR from "swr";
export default function Ping({ group, service, style }) {
export default function Ping({ groupName, serviceName, style }) {
const { t } = useTranslation();
const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ group, service }).toString()}`, {
const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ groupName, serviceName }).toString()}`, {
refreshInterval: 30000,
});

View File

@@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next";
import useSWR from "swr";
export default function SiteMonitor({ group, service, style }) {
export default function SiteMonitor({ groupName, serviceName, style }) {
const { t } = useTranslation();
const { data, error } = useSWR(`/api/siteMonitor?${new URLSearchParams({ group, service }).toString()}`, {
const { data, error } = useSWR(`/api/siteMonitor?${new URLSearchParams({ groupName, serviceName }).toString()}`, {
refreshInterval: 30000,
});

View File

@@ -3,22 +3,24 @@ import { useTranslation } from "next-i18next";
import ErrorBoundary from "components/errorboundry";
import components from "widgets/components";
export default function Widget({ service }) {
export default function Widget({ widget, service }) {
const { t } = useTranslation("common");
const ServiceWidget = components[service.widget.type];
const ServiceWidget = components[widget.type];
const fullService = Object.apply({}, service);
fullService.widget = widget;
if (ServiceWidget) {
return (
<ErrorBoundary>
<ServiceWidget service={service} />
<ServiceWidget service={fullService} />
</ErrorBoundary>
);
}
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="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>
);
}

View File

@@ -6,10 +6,10 @@ import createLogger from "utils/logger";
const logger = createLogger("ping");
export default async function handler(req, res) {
const { group, service } = req.query;
const serviceItem = await getServiceItem(group, service);
const { groupName, serviceName } = req.query;
const serviceItem = await getServiceItem(groupName, serviceName);
if (!serviceItem) {
logger.debug(`No service item found for group ${group} named ${service}`);
logger.debug(`No service item found for group ${groupName} named ${serviceName}`);
return res.status(400).send({
error: "Unable to find service, see log for details.",
});

View File

@@ -9,8 +9,8 @@ const logger = createLogger("servicesProxy");
export default async function handler(req, res) {
try {
const { service, group } = req.query;
const serviceWidget = await getServiceWidget(group, service);
const { service, group, index } = req.query;
const serviceWidget = await getServiceWidget(group, service, index);
let type = serviceWidget?.type;
// exceptions
@@ -41,7 +41,7 @@ export default async function handler(req, res) {
const endpoint = mapping?.endpoint;
const endpointProxy = mapping?.proxyHandler || serviceProxyHandler;
if (mapping.method && mapping.method !== req.method) {
if (mapping?.method && mapping.method !== req.method) {
logger.debug("Unsupported method: %s", req.method);
return res.status(403).json({ error: "Unsupported method" });
}

View File

@@ -7,10 +7,10 @@ import { httpProxy } from "utils/proxy/http";
const logger = createLogger("siteMonitor");
export default async function handler(req, res) {
const { group, service } = req.query;
const serviceItem = await getServiceItem(group, service);
const { groupName, serviceName } = req.query;
const serviceItem = await getServiceItem(groupName, serviceName);
if (!serviceItem) {
logger.debug(`No service item found for group ${group} named ${service}`);
logger.debug(`No service item found for group ${groupName} named ${serviceName}`);
return res.status(400).send({
error: "Unable to find service, see log for details.",
});

View File

@@ -291,8 +291,7 @@ function Home({ initialSettings }) {
group.services ? (
<ServicesGroup
key={group.name}
group={group.name}
services={group}
group={group}
layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse}
@@ -316,8 +315,7 @@ function Home({ initialSettings }) {
{serviceGroups.map((group) => (
<ServicesGroup
key={group.name}
group={group.name}
services={group}
group={group}
layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse}

View File

@@ -10,6 +10,7 @@ import {
servicesFromDocker,
cleanServiceGroups,
servicesFromKubernetes,
findGroupByName,
} from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
@@ -84,6 +85,17 @@ export async function widgetsResponse() {
return configuredWidgets;
}
function mergeSubgroups(configuredGroups, mergedGroup) {
configuredGroups.forEach((group) => {
if (group.name === mergedGroup.name) {
// eslint-disable-next-line no-param-reassign
group.services = mergedGroup.services;
} else if (group.groups) {
mergeSubgroups(group.groups, mergedGroup);
}
});
}
export async function servicesResponse() {
let discoveredDockerServices;
let discoveredKubernetesServices;
@@ -140,25 +152,29 @@ export async function servicesResponse() {
const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;
mergedGroupsNames.forEach((groupName) => {
const discoveredDockerGroup = discoveredDockerServices.find((group) => group.name === groupName) || {
const discoveredDockerGroup = findGroupByName(discoveredDockerServices, groupName) || {
services: [],
};
const discoveredKubernetesGroup = discoveredKubernetesServices.find((group) => group.name === groupName) || {
const discoveredKubernetesGroup = findGroupByName(discoveredKubernetesServices, groupName) || {
services: [],
};
const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] };
const configuredGroup = findGroupByName(configuredServices, groupName) || { services: [] };
const mergedGroup = {
name: groupName,
services: [...discoveredDockerGroup.services, ...discoveredKubernetesGroup.services, ...configuredGroup.services]
.filter((service) => service)
.sort(compareServices),
groups: [...configuredGroup.groups],
};
if (definedLayouts) {
const layoutIndex = definedLayouts.findIndex((layout) => layout === mergedGroup.name);
if (layoutIndex > -1) sortedGroups[layoutIndex] = mergedGroup;
else unsortedGroups.push(mergedGroup);
else if (configuredGroup.name) {
// this is a nested group, so find the parent group and merge the services
mergeSubgroups(configuredServices, mergedGroup);
} else unsortedGroups.push(mergedGroup);
} else {
unsortedGroups.push(mergedGroup);
}

View File

@@ -13,6 +13,38 @@ import * as shvl from "utils/config/shvl";
const logger = createLogger("service-helpers");
function parseServicesToGroups(services) {
if (!services) {
return [];
}
// map easy to write YAML objects into easy to consume JS arrays
return services.map((serviceGroup) => {
const name = Object.keys(serviceGroup)[0];
let groups = [];
const serviceGroupServices = [];
serviceGroup[name].forEach((entries) => {
const entryName = Object.keys(entries)[0];
if (Array.isArray(entries[entryName])) {
groups = groups.concat(parseServicesToGroups([{ [entryName]: entries[entryName] }]));
} else {
serviceGroupServices.push({
name: entryName,
...entries[entryName],
weight: entries[entryName].weight || serviceGroupServices.length * 100, // default weight
type: "service",
});
}
});
return {
name,
type: "group",
services: serviceGroupServices,
groups,
};
});
}
export async function servicesFromConfig() {
checkAndCopyConfig("services.yaml");
@@ -20,31 +52,7 @@ export async function servicesFromConfig() {
const rawFileContents = await fs.readFile(servicesYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents);
const services = yaml.load(fileContents);
if (!services) {
return [];
}
// map easy to write YAML objects into easy to consume JS arrays
const servicesArray = services.map((servicesGroup) => ({
name: Object.keys(servicesGroup)[0],
services: servicesGroup[Object.keys(servicesGroup)[0]].map((entries) => ({
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]],
type: "service",
})),
}));
// add default weight to services based on their position in the configuration
servicesArray.forEach((group, groupIndex) => {
group.services.forEach((service, serviceIndex) => {
if (service.weight === undefined) {
servicesArray[groupIndex].services[serviceIndex].weight = (serviceIndex + 1) * 100;
}
});
});
return servicesArray;
return parseServicesToGroups(services);
}
export async function servicesFromDocker() {
@@ -354,8 +362,12 @@ export function cleanServiceGroups(groups) {
if (typeof cleanedService.weight !== "number") {
cleanedService.weight = 0;
}
if (!cleanedService.widgets) cleanedService.widgets = [];
if (cleanedService.widget) {
cleanedService.widgets.push(cleanedService.widget);
delete cleanedService.widget;
}
cleanedService.widgets = cleanedService.widgets.map((widgetData, index) => {
// whitelisted set of keys to pass to the frontend
// alphabetical, grouped by widget(s)
const {
@@ -495,7 +507,7 @@ export function cleanServiceGroups(groups) {
// spoolman
spoolIds,
} = cleanedService.widget;
} = widgetData;
let fieldsList = fields;
if (typeof fields === "string") {
@@ -507,169 +519,187 @@ export function cleanServiceGroups(groups) {
}
}
cleanedService.widget = {
const widget = {
type,
fields: fieldsList || null,
hide_errors: hideErrors || false,
service_name: service.name,
service_group: serviceGroup.name,
index,
};
if (type === "azuredevops") {
if (userEmail) cleanedService.widget.userEmail = userEmail;
if (repositoryId) cleanedService.widget.repositoryId = repositoryId;
if (userEmail) widget.userEmail = userEmail;
if (repositoryId) widget.repositoryId = repositoryId;
}
if (type === "beszel") {
if (systemId) cleanedService.widget.systemId = systemId;
if (systemId) 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 (currency) widget.currency = currency;
if (symbols) widget.symbols = symbols;
if (slugs) widget.slugs = slugs;
if (defaultinterval) widget.defaultinterval = defaultinterval;
}
if (type === "docker") {
if (server) cleanedService.widget.server = server;
if (container) cleanedService.widget.container = container;
if (server) widget.server = server;
if (container) widget.container = container;
}
if (type === "unifi") {
if (site) cleanedService.widget.site = site;
if (site) widget.site = site;
}
if (type === "proxmox") {
if (node) cleanedService.widget.node = node;
if (node) 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 (namespace) widget.namespace = namespace;
if (app) widget.app = app;
if (podSelector) 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 (src) widget.src = src;
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) cleanedService.widget.wan = wan;
if (wan) 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 (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) 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);
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) cleanedService.widget.enableQueue = JSON.parse(enableQueue);
if (enableQueue !== undefined) 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 (enablePools !== undefined) widget.enablePools = JSON.parse(enablePools);
if (nasType !== undefined) widget.nasType = nasType;
}
if (["diskstation", "qnap"].includes(type)) {
if (volume) cleanedService.widget.volume = volume;
if (volume) widget.volume = volume;
}
if (type === "kopia") {
if (snapshotHost) cleanedService.widget.snapshotHost = snapshotHost;
if (snapshotPath) cleanedService.widget.snapshotPath = snapshotPath;
if (snapshotHost) widget.snapshotHost = snapshotHost;
if (snapshotPath) widget.snapshotPath = snapshotPath;
}
if (["glances", "immich", "mealie", "pfsense", "pihole"].includes(type)) {
if (version) cleanedService.widget.version = parseInt(version, 10);
if (version) widget.version = parseInt(version, 10);
}
if (type === "glances") {
if (metric) cleanedService.widget.metric = metric;
if (metric) widget.metric = metric;
if (chart !== undefined) {
cleanedService.widget.chart = chart;
widget.chart = chart;
} else {
cleanedService.widget.chart = true;
widget.chart = true;
}
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
if (pointsLimit) cleanedService.widget.pointsLimit = pointsLimit;
if (diskUnits) cleanedService.widget.diskUnits = diskUnits;
if (refreshInterval) widget.refreshInterval = refreshInterval;
if (pointsLimit) widget.pointsLimit = pointsLimit;
if (diskUnits) widget.diskUnits = diskUnits;
}
if (type === "mjpeg") {
if (stream) cleanedService.widget.stream = stream;
if (fit) cleanedService.widget.fit = fit;
if (stream) widget.stream = stream;
if (fit) widget.fit = fit;
}
if (type === "openmediavault") {
if (method) cleanedService.widget.method = method;
if (method) widget.method = method;
}
if (type === "openwrt") {
if (interfaceName) cleanedService.widget.interfaceName = interfaceName;
if (interfaceName) 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 (mappings) widget.mappings = mappings;
if (display) widget.display = display;
if (refreshInterval) 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 (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) cleanedService.widget.tuner = tuner;
if (tuner !== undefined) widget.tuner = tuner;
}
if (type === "healthchecks") {
if (uuid !== undefined) cleanedService.widget.uuid = uuid;
if (uuid !== undefined) widget.uuid = uuid;
}
if (type === "speedtest") {
if (bitratePrecision !== undefined) {
cleanedService.widget.bitratePrecision = parseInt(bitratePrecision, 10);
widget.bitratePrecision = parseInt(bitratePrecision, 10);
}
}
if (type === "stocks") {
if (watchlist) cleanedService.widget.watchlist = watchlist;
if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus;
if (watchlist) widget.watchlist = watchlist;
if (showUSMarketStatus) widget.showUSMarketStatus = showUSMarketStatus;
}
if (type === "wgeasy") {
if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10);
if (threshold !== undefined) widget.threshold = parseInt(threshold, 10);
}
if (type === "frigate") {
if (enableRecentEvents !== undefined) cleanedService.widget.enableRecentEvents = enableRecentEvents;
if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents;
}
if (type === "technitium") {
if (range !== undefined) cleanedService.widget.range = range;
if (range !== undefined) widget.range = range;
}
if (type === "lubelogger") {
if (vehicleID !== undefined) cleanedService.widget.vehicleID = parseInt(vehicleID, 10);
if (vehicleID !== undefined) widget.vehicleID = parseInt(vehicleID, 10);
}
if (type === "vikunja") {
if (enableTaskList !== undefined) cleanedService.widget.enableTaskList = !!enableTaskList;
if (enableTaskList !== undefined) widget.enableTaskList = !!enableTaskList;
}
if (type === "prometheusmetric") {
if (metrics) cleanedService.widget.metrics = metrics;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
if (metrics) widget.metrics = metrics;
if (refreshInterval) widget.refreshInterval = refreshInterval;
}
if (type === "spoolman") {
if (spoolIds !== undefined) cleanedService.widget.spoolIds = spoolIds;
if (spoolIds !== undefined) widget.spoolIds = spoolIds;
}
}
return widget;
});
return cleanedService;
}),
type: serviceGroup.type || "group",
groups: serviceGroup.groups ? cleanServiceGroups(serviceGroup.groups) : [],
}));
}
export function findGroupByName(groups, name) {
// Deep search for a group by name. Using for loop allows for early return
for (let i = 0; i < groups.length; i += 1) {
const group = groups[i];
if (group.name === name) {
return group;
} else if (group.groups) {
const foundGroup = findGroupByName(group.groups, name);
if (foundGroup) {
return foundGroup;
}
}
}
return null;
}
export async function getServiceItem(group, service) {
const configuredServices = await servicesFromConfig();
const serviceGroup = configuredServices.find((g) => g.name === group);
const serviceGroup = findGroupByName(configuredServices, group);
if (serviceGroup) {
const serviceEntry = serviceGroup.services.find((s) => s.name === service);
if (serviceEntry) return serviceEntry;
@@ -677,14 +707,14 @@ export async function getServiceItem(group, service) {
const discoveredServices = await servicesFromDocker();
const dockerServiceGroup = discoveredServices.find((g) => g.name === group);
const dockerServiceGroup = findGroupByName(discoveredServices, group);
if (dockerServiceGroup) {
const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service);
if (dockerServiceEntry) return dockerServiceEntry;
}
const kubernetesServices = await servicesFromKubernetes();
const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group);
const kubernetesServiceGroup = findGroupByName(kubernetesServices, group);
if (kubernetesServiceGroup) {
const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service);
if (kubernetesServiceEntry) return kubernetesServiceEntry;
@@ -693,12 +723,11 @@ export async function getServiceItem(group, service) {
return false;
}
export default async function getServiceWidget(group, service) {
export default async function getServiceWidget(group, service, index) {
const serviceItem = await getServiceItem(group, service);
if (serviceItem) {
const { widget } = serviceItem;
return widget;
const { widget, widgets } = serviceItem;
return index > -1 && widgets ? widgets[index] : widget;
}
return false;
}

View File

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

View File

@@ -9,10 +9,10 @@ import widgets from "widgets/widgets";
const logger = createLogger("credentialedProxyHandler");
export default async function credentialedProxyHandler(req, res, map) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (!widgets?.[widget.type]?.api) {
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");
export default async function genericProxyHandler(req, res, map) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (!widgets?.[widget.type]?.api) {
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) {
const { group, service, endpoint: method } = req.query;
const { group, service, endpoint: method, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
const api = widgets?.[widget.type]?.api;
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) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (!group || !service) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
const serviceWidget = await getServiceWidget(group, service);
const serviceWidget = await getServiceWidget(group, service, index);
const widget = widgets?.[serviceWidget.type];
const mapping = widget?.mappings?.[endpoint];
if (!widget.api || !mapping) {

View File

@@ -23,14 +23,14 @@ async function retrieveFromAPI(url, key) {
}
export default async function audiobookshelfProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -34,10 +34,10 @@ async function login(loginUrl, username, password, service) {
}
export default async function beszelProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (!widgets?.[widget.type]?.api) {
return res.status(403).json({ error: "Service does not support API calls" });

View File

@@ -5,10 +5,10 @@ import createLogger from "utils/logger";
const logger = createLogger("calendarProxyHandler");
export default async function calendarProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
const integration = widget.integrations?.find((i) => i.name === endpoint);
if (integration) {

View File

@@ -35,14 +35,14 @@ async function login(widget, service) {
}
export default async function crowdsecProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (!group || !service) {
logger.error("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);
const widget = await getServiceWidget(group, service, index);
if (!widget || !widgets[widget.type].api) {
logger.error("Invalid or missing widget for service '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid widget configuration" });

View File

@@ -40,14 +40,14 @@ function login(url, password) {
}
export default async function delugeProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -28,14 +28,14 @@ async function login(widget) {
}
export default async function floodProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -74,14 +74,14 @@ async function apiCall(widget, endpoint, service) {
}
export default async function freshrssProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
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" });

View File

@@ -46,8 +46,8 @@ async function requestEndpoint(apiBaseUrl, service, action) {
}
export default async function fritzboxProxyHandler(req, res) {
const { group, service } = req.query;
const serviceWidget = await getServiceWidget(group, service);
const { group, service, index } = req.query;
const serviceWidget = await getServiceWidget(group, service, index);
if (!serviceWidget) {
res.status(500).json({ error: { message: "Service widget not found" } });

View File

@@ -7,8 +7,8 @@ const proxyName = "gamedigProxyHandler";
const logger = createLogger(proxyName);
export default async function gamedigProxyHandler(req, res) {
const { group, service } = req.query;
const serviceWidget = await getServiceWidget(group, service);
const { group, service, index } = req.query;
const serviceWidget = await getServiceWidget(group, service, index);
const url = new URL(serviceWidget.url);
try {

View File

@@ -62,14 +62,14 @@ async function getQuery(query, { url, key }) {
}
export default async function homeassistantProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
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" });

View File

@@ -68,14 +68,14 @@ async function apiCall(widget, endpoint, service) {
}
export default async function homeboxProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
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" });

View File

@@ -71,14 +71,14 @@ async function apiCall(widget, endpoint, service) {
}
export default async function homebridgeProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -25,14 +25,14 @@ async function fetchJackettCookie(widget, loginURL) {
}
export default async function jackettProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (!group || !service) {
logger.error("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);
const widget = await getServiceWidget(group, service, index);
if (!widget || !widgets[widget.type].api) {
logger.error("Invalid or missing widget for service '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid widget configuration" });

View File

@@ -12,12 +12,12 @@ const proxyName = "jdownloaderProxyHandler";
const logger = createLogger(proxyName);
async function getWidget(req) {
const { group, service } = req.query;
const { group, service, index } = 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
return null;

View File

@@ -70,14 +70,14 @@ async function apiCall(widget, endpoint, service) {
}
export default async function KavitaProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
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" });

View File

@@ -7,8 +7,8 @@ const proxyName = "minecraftProxyHandler";
const logger = createLogger(proxyName);
export default async function minecraftProxyHandler(req, res) {
const { group, service } = req.query;
const serviceWidget = await getServiceWidget(group, service);
const { group, service, index } = req.query;
const serviceWidget = await getServiceWidget(group, service, index);
const url = new URL(serviceWidget.url);
try {
const pingResponse = await pingWithPromise(url.hostname, url.port || 25565);

View File

@@ -36,10 +36,10 @@ async function login(loginUrl, username, password, service) {
}
export default async function npmProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (!widgets?.[widget.type]?.api) {
return res.status(403).json({ error: "Service does not support API calls" });

View File

@@ -33,10 +33,10 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
}
export default async function omadaProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (widget) {
const { url } = widget;

View File

@@ -12,14 +12,14 @@ const BG_POLL_PERIOD = 500;
const logger = createLogger(PROXY_NAME);
async function getWidget(req) {
const { group, service } = req.query;
const { group, service, index } = 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -17,14 +17,14 @@ const PARAMS = {
};
async function getWidget(req) {
const { group, service } = req.query;
const { group, service, index } = 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -6,14 +6,14 @@ import createLogger from "utils/logger";
const logger = createLogger("photoprismProxyHandler");
export default async function photoprismProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -33,7 +33,7 @@ async function login(widget, service) {
}
export default async function piholeProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, index } = req.query;
let endpoint = "stats/summary";
if (!group || !service) {
@@ -41,7 +41,7 @@ export default async function piholeProxyHandler(req, res) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.error("Invalid or missing widget for service '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid widget configuration" });

View File

@@ -16,14 +16,14 @@ const tvCacheKey = `${proxyName}__tv`;
const logger = createLogger(proxyName);
async function getWidget(req) {
const { group, service } = req.query;
const { group, service, index } = 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -67,11 +67,11 @@ async function login(loginUrl, service, username, password = "") {
}
export default async function pyloadProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
const { group, service, endpoint, index } = req.query;
try {
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (widget) {
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));

View File

@@ -21,14 +21,14 @@ async function login(widget) {
}
export default async function qbittorrentProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -77,14 +77,14 @@ async function apiCall(widget, endpoint, service) {
}
export default async function qnapProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
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" });

View File

@@ -45,10 +45,10 @@ const getTorrentInfo = (data) => ({
});
export default async function rutorrentProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
if (widget) {
const api = widgets?.[widget.type]?.api;

View File

@@ -114,14 +114,14 @@ function extractCounts(responseJSON, fields) {
}
export default async function suwayomiProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -8,14 +8,14 @@ const proxyName = "tdarrProxyHandler";
const logger = createLogger(proxyName);
export default async function tdarrProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -11,14 +11,14 @@ const headerCacheKey = `${proxyName}__headers`;
const logger = createLogger(proxyName);
export default async function transmissionProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, 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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -14,13 +14,13 @@ const prefixCacheKey = `${proxyName}__prefix`;
const logger = createLogger(proxyName);
async function getWidget(req) {
const { group, service } = req.query;
const { group, service, index } = req.query;
let widget = null;
if (group === "unifi_console" && service === "unifi_console") {
// info widget
const index = req.query?.query ? JSON.parse(req.query.query).index : undefined;
widget = await getPrivateWidgetOptions("unifi_console", index);
const infowidgetIndex = req.query?.query ? JSON.parse(req.query.query).index : undefined;
widget = await getPrivateWidgetOptions("unifi_console", infowidgetIndex);
if (!widget) {
logger.debug("Error retrieving settings for this Unifi widget");
return null;
@@ -32,7 +32,7 @@ async function getWidget(req) {
return null;
}
widget = await getServiceWidget(group, service);
widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -3,8 +3,8 @@ import { UrbackupServer } from "urbackup-server-api";
import getServiceWidget from "utils/config/service-helpers";
export default async function urbackupProxyHandler(req, res) {
const { group, service } = req.query;
const serviceWidget = await getServiceWidget(group, service);
const { group, service, index } = req.query;
const serviceWidget = await getServiceWidget(group, service, index);
const server = new UrbackupServer({
url: serviceWidget.url,

View File

@@ -8,14 +8,14 @@ const proxyName = "watchtowerProxyHandler";
const logger = createLogger(proxyName);
export default async function watchtowerProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
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);
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);

View File

@@ -7,13 +7,13 @@ import getServiceWidget from "utils/config/service-helpers";
const logger = createLogger("xteveProxyHandler");
export default async function xteveProxyHandler(req, res) {
const { group, service } = req.query;
const { group, service, index } = req.query;
if (!group || !service) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
const widget = await getServiceWidget(group, service);
const widget = await getServiceWidget(group, service, index);
const api = widgets?.[widget.type]?.api;
if (!api) {
return res.status(403).json({ error: "Service does not support API calls" });