+
))}
diff --git a/src/components/widgets/search/search.jsx b/src/components/widgets/search/search.jsx
index 0e439132..6a634308 100644
--- a/src/components/widgets/search/search.jsx
+++ b/src/components/widgets/search/search.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslation } from "next-i18next";
import { FiSearch } from "react-icons/fi";
import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si";
-import { Listbox, Transition } from "@headlessui/react";
+import { Listbox, Transition, Combobox } from "@headlessui/react";
import classNames from "classnames";
import ContainerForm from "../widget/container_form";
@@ -12,26 +12,31 @@ export const searchProviders = {
google: {
name: "Google",
url: "https://www.google.com/search?q=",
+ suggestionUrl: "https://www.google.com/complete/search?client=chrome&q=",
icon: SiGoogle,
},
duckduckgo: {
name: "DuckDuckGo",
url: "https://duckduckgo.com/?q=",
+ suggestionUrl: "https://duckduckgo.com/ac/?type=list&q=",
icon: SiDuckduckgo,
},
bing: {
name: "Bing",
url: "https://www.bing.com/search?q=",
+ suggestionUrl: "https://api.bing.com/osjson.aspx?query=",
icon: SiMicrosoftbing,
},
baidu: {
name: "Baidu",
url: "https://www.baidu.com/s?wd=",
+ suggestionUrl: "http://suggestion.baidu.com/su?&action=opensearch&ie=utf-8&wd=",
icon: SiBaidu,
},
brave: {
name: "Brave",
url: "https://search.brave.com/search?q=",
+ suggestionUrl: "https://search.brave.com/api/suggest?&rich=false&q=",
icon: SiBrave,
},
custom: {
@@ -72,6 +77,7 @@ export default function Search({ options }) {
const [selectedProvider, setSelectedProvider] = useState(
searchProviders[availableProviderIds[0] ?? searchProviders.google],
);
+ const [searchSuggestions, setSearchSuggestions] = useState([]);
useEffect(() => {
const storedProvider = getStoredProvider();
@@ -82,9 +88,40 @@ export default function Search({ options }) {
}
}, [availableProviderIds]);
+ useEffect(() => {
+ const abortController = new AbortController();
+
+ if (
+ options.showSearchSuggestions &&
+ (selectedProvider.suggestionUrl || options.suggestionUrl) && // custom providers pass url via options
+ query.trim() !== searchSuggestions[0]
+ ) {
+ fetch(`/api/search/searchSuggestion?query=${encodeURIComponent(query)}&providerName=${selectedProvider.name}`, {
+ signal: abortController.signal,
+ })
+ .then(async (searchSuggestionResult) => {
+ const newSearchSuggestions = await searchSuggestionResult.json();
+
+ if (newSearchSuggestions) {
+ if (newSearchSuggestions[1].length > 4) {
+ newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);
+ }
+ setSearchSuggestions(newSearchSuggestions);
+ }
+ })
+ .catch(() => {
+ // If there is an error, just ignore it. There just will be no search suggestions.
+ });
+ }
+
+ return () => {
+ abortController.abort();
+ };
+ }, [selectedProvider, options, query, searchSuggestions]);
+
const submitCallback = useCallback(
- (event) => {
- const q = encodeURIComponent(query);
+ (value) => {
+ const q = encodeURIComponent(value);
const { url } = selectedProvider;
if (url) {
window.open(`${url}${q}`, options.target || "_blank");
@@ -92,11 +129,9 @@ export default function Search({ options }) {
window.open(`${options.url}${q}`, options.target || "_blank");
}
- event.preventDefault();
- event.target.reset();
setQuery("");
},
- [options.target, options.url, query, selectedProvider],
+ [selectedProvider, options.url, options.target],
);
if (!availableProviderIds) {
@@ -109,84 +144,111 @@ export default function Search({ options }) {
};
return (
-
+
-
+
-
setQuery(s.currentTarget.value)}
- required
- autoCapitalize="off"
- autoCorrect="off"
- autoComplete="off"
- // eslint-disable-next-line jsx-a11y/no-autofocus
- autoFocus={options.focus}
- />
-
-
-
-
- {t("search.search")}
-
-
-
+ setQuery(event.target.value)}
+ required
+ autoCapitalize="off"
+ autoCorrect="off"
+ autoComplete="off"
+ // eslint-disable-next-line jsx-a11y/no-autofocus
+ autoFocus={options.focus}
+ />
+
-
+
+
+ {t("search.search")}
+
+
+
-
- {availableProviderIds.map((providerId) => {
- const p = searchProviders[providerId];
- return (
-
- {({ active }) => (
-
-
-
- )}
-
- );
- })}
+
+
+ {availableProviderIds.map((providerId) => {
+ const p = searchProviders[providerId];
+ return (
+
+ {({ active }) => (
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ {searchSuggestions[1]?.length > 0 && (
+
+
+
+ {searchSuggestions[1].map((suggestion) => (
+
+ {({ active }) => (
+
+ {suggestion.indexOf(query) === 0 ? query : ""}
+
+ {suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
+
+
+ )}
+
+ ))}
-
-
-
+
+ )}
+
diff --git a/src/pages/api/search/searchSuggestion.js b/src/pages/api/search/searchSuggestion.js
new file mode 100644
index 00000000..c1c936c9
--- /dev/null
+++ b/src/pages/api/search/searchSuggestion.js
@@ -0,0 +1,23 @@
+import { searchProviders } from "components/widgets/search/search";
+import cachedFetch from "utils/proxy/cached-fetch";
+import { widgetsFromConfig } from "utils/config/widget-helpers";
+
+export default async function handler(req, res) {
+ const { query, providerName } = req.query;
+
+ const provider = Object.values(searchProviders).find(({ name }) => name === providerName);
+
+ if (provider.name === "Custom") {
+ const widgets = await widgetsFromConfig();
+ const searchWidget = widgets.find((w) => w.type === "search");
+
+ provider.url = searchWidget.options.url;
+ provider.suggestionUrl = searchWidget.options.suggestionUrl;
+ }
+
+ if (!provider.suggestionUrl) {
+ return res.json([query, []]); // Responde with the same array format but with no suggestions.
+ }
+
+ return res.send(await cachedFetch(`${provider.suggestionUrl}${encodeURIComponent(query)}`, 5));
+}
diff --git a/src/pages/index.jsx b/src/pages/index.jsx
index 92833117..59a2ad12 100644
--- a/src/pages/index.jsx
+++ b/src/pages/index.jsx
@@ -211,12 +211,12 @@ function Home({ initialSettings }) {
// if search provider is a list, try to retrieve from localstorage, fall back to the first
searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]];
} else if (searchWidget.options?.provider === "custom") {
- searchProvider = {
- url: searchWidget.options.url,
- };
+ searchProvider = searchWidget.options;
} else {
searchProvider = searchProviders[searchWidget.options?.provider];
}
+ // to pass to quicklaunch
+ searchProvider.showSearchSuggestions = searchWidget.options?.showSearchSuggestions;
}
const headerStyle = settings?.headerStyle || "underlined";
@@ -224,7 +224,10 @@ function Home({ initialSettings }) {
function handleKeyDown(e) {
if (e.target.tagName === "BODY" || e.target.id === "inner_wrapper") {
if (
- (e.key.length === 1 && e.key.match(/(\w|\s)/g) && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) ||
+ (e.key.length === 1 &&
+ e.key.match(/(\w|\s|[à-ü]|[À-Ü])/g) &&
+ !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) ||
+ e.key.match(/([à-ü]|[À-Ü])/g) || // accented characters may require modifier keys
(e.key === "v" && (e.ctrlKey || e.metaKey))
) {
setSearching(true);
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 402db588..f3bfec78 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -46,6 +46,10 @@ body {
width: 0.75em;
}
+dialog ::-webkit-scrollbar {
+ display: none;
+}
+
::-webkit-scrollbar-track {
background-color: var(--scrollbar-track);
}
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index cfc88fdc..fb6757b6 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -102,7 +102,7 @@ export async function servicesFromDocker() {
}
});
- if (!constructedService.name || !constructedService.group) {
+ if (constructedService && (!constructedService.name || !constructedService.group)) {
logger.error(
`Error constructing service using homepage labels for container '${containerName.replace(
/^\//,
@@ -398,6 +398,9 @@ export function cleanServiceGroups(groups) {
// glances, customapi, iframe
refreshInterval,
+ // hdhomerun
+ tuner,
+
// healthchecks
uuid,
@@ -426,6 +429,9 @@ export function cleanServiceGroups(groups) {
// openmediavault
method,
+ // openwrt
+ interfaceName,
+
// opnsense, pfsense
wan,
@@ -528,6 +534,9 @@ export function cleanServiceGroups(groups) {
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 (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
@@ -541,6 +550,9 @@ export function cleanServiceGroups(groups) {
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;
}
diff --git a/src/utils/proxy/cached-fetch.js b/src/utils/proxy/cached-fetch.js
index 0ed39562..30b00f77 100644
--- a/src/utils/proxy/cached-fetch.js
+++ b/src/utils/proxy/cached-fetch.js
@@ -12,7 +12,8 @@ export default async function cachedFetch(url, duration) {
return cached;
}
- const data = await fetch(url).then((res) => res.json());
+ // wrapping text in JSON.parse to handle utf-8 issues
+ const data = JSON.parse(await fetch(url).then((res) => res.text()));
cache.put(url, data, duration * 1000 * 60);
return data;
}
diff --git a/src/widgets/calendar/integrations/ical.jsx b/src/widgets/calendar/integrations/ical.jsx
index ab3d06f2..ec642791 100644
--- a/src/widgets/calendar/integrations/ical.jsx
+++ b/src/widgets/calendar/integrations/ical.jsx
@@ -7,6 +7,17 @@ import { RRule } from "rrule";
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
import Error from "../../../components/services/widget/error";
+// https://gist.github.com/jlevy/c246006675becc446360a798e2b2d781
+function simpleHash(str) {
+ /* eslint-disable no-plusplus, no-bitwise */
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
+ }
+ return (hash >>> 0).toString(36);
+ /* eslint-disable no-plusplus, no-bitwise */
+}
+
export default function Integration({ config, params, setEvents, hideErrors, timezone }) {
const { t } = useTranslation();
const { data: icalData, error: icalError } = useWidgetAPI(config, config.name, {
@@ -47,7 +58,10 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
const eventDate = timezone ? DateTime.fromJSDate(date, { zone: timezone }) : DateTime.fromJSDate(date);
for (let j = 0; j < days; j += 1) {
- eventsToAdd[`${event?.uid?.value}${i}${j}${type}`] = {
+ // See https://github.com/gethomepage/homepage/issues/2753 uid is not stable
+ // assumption is that the event is the same if the start, end and title are all the same
+ const hash = simpleHash(`${event?.dtstart?.value}${event?.dtend?.value}${title}${i}${j}${type}}`);
+ eventsToAdd[hash] = {
title,
date: eventDate.plus({ days: j }),
color: config?.color ?? "zinc",
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 9b72c33d..86eabc97 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -29,6 +29,7 @@ const components = {
freshrss: dynamic(() => import("./freshrss/component")),
fritzbox: dynamic(() => import("./fritzbox/component")),
gamedig: dynamic(() => import("./gamedig/component")),
+ gatus: dynamic(() => import("./gatus/component")),
ghostfolio: dynamic(() => import("./ghostfolio/component")),
glances: dynamic(() => import("./glances/component")),
gluetun: dynamic(() => import("./gluetun/component")),
@@ -72,6 +73,7 @@ const components = {
opnsense: dynamic(() => import("./opnsense/component")),
overseerr: dynamic(() => import("./overseerr/component")),
openmediavault: dynamic(() => import("./openmediavault/component")),
+ openwrt: dynamic(() => import("./openwrt/component")),
paperlessngx: dynamic(() => import("./paperlessngx/component")),
pfsense: dynamic(() => import("./pfsense/component")),
photoprism: dynamic(() => import("./photoprism/component")),
diff --git a/src/widgets/gatus/component.jsx b/src/widgets/gatus/component.jsx
new file mode 100644
index 00000000..86b85ff3
--- /dev/null
+++ b/src/widgets/gatus/component.jsx
@@ -0,0 +1,51 @@
+import { useTranslation } from "next-i18next";
+
+import Container from "components/services/widget/container";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import Block from "components/services/widget/block";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+
+ const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
+
+ if (statusError) {
+ return ;
+ }
+
+ if (!statusData) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ let sitesUp = 0;
+ let sitesDown = 0;
+ Object.values(statusData).forEach((site) => {
+ const lastResult = site.results[site.results.length - 1];
+ if (lastResult?.success === true) {
+ sitesUp += 1;
+ } else {
+ sitesDown += 1;
+ }
+ });
+
+ // Adapted from https://github.com/bastienwirtz/homer/blob/b7cd8f9482e6836a96b354b11595b03b9c3d67cd/src/components/services/UptimeKuma.vue#L105
+ const resultsList = Object.values(statusData).reduce((a, b) => a.concat(b.results), []);
+ const percent = resultsList.reduce((a, b) => a + (b?.success === true ? 1 : 0), 0) / resultsList.length;
+ const uptime = (percent * 100).toFixed(1);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/gatus/widget.js b/src/widgets/gatus/widget.js
new file mode 100644
index 00000000..8963ac19
--- /dev/null
+++ b/src/widgets/gatus/widget.js
@@ -0,0 +1,15 @@
+// import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/{endpoint}",
+ proxyHandler: genericProxyHandler,
+
+ mappings: {
+ status: {
+ endpoint: "api/v1/endpoints/statuses",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/hdhomerun/component.jsx b/src/widgets/hdhomerun/component.jsx
index 2b2cb24a..a118eafe 100644
--- a/src/widgets/hdhomerun/component.jsx
+++ b/src/widgets/hdhomerun/component.jsx
@@ -4,14 +4,17 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { widget } = service;
+ const { tuner = 0 } = widget;
const { data: channelsData, error: channelsError } = useWidgetAPI(widget, "lineup");
+ const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
- if (channelsError) {
- return ;
+ if (channelsError || statusError) {
+ const finalError = channelsError ?? statusError;
+ return ;
}
- if (!channelsData) {
+ if (!channelsData || !statusData) {
return (
@@ -20,12 +23,30 @@ export default function Component({ service }) {
);
}
- const hdChannels = channelsData?.filter((channel) => channel.HD === 1);
+ // Provide a default if not set in the config
+ if (!widget.fields) {
+ widget.fields = ["channels", "hd"];
+ }
+ // Limit to a maximum of 4 at a time
+ if (widget.fields.length > 4) {
+ widget.fields = widget.fields.slice(0, 4);
+ }
return (
-
-
+
+ channel.HD === 1)?.length} />
+ num.VctNumber != null).length ?? 0} / ${statusData?.length ?? 0}`}
+ />
+
+
+
+
+
+
+
);
}
diff --git a/src/widgets/hdhomerun/widget.js b/src/widgets/hdhomerun/widget.js
index 689fbf0b..e708b4d4 100644
--- a/src/widgets/hdhomerun/widget.js
+++ b/src/widgets/hdhomerun/widget.js
@@ -8,6 +8,9 @@ const widget = {
lineup: {
endpoint: "lineup.json",
},
+ status: {
+ endpoint: "status.json",
+ },
},
};
diff --git a/src/widgets/immich/component.jsx b/src/widgets/immich/component.jsx
index 0f9b104c..66616f78 100644
--- a/src/widgets/immich/component.jsx
+++ b/src/widgets/immich/component.jsx
@@ -30,9 +30,9 @@ export default function Component({ service }) {
return (
-
-
-
+
+
+
;
+ }
+ return ;
+}
diff --git a/src/widgets/openwrt/methods/interface.jsx b/src/widgets/openwrt/methods/interface.jsx
new file mode 100644
index 00000000..91366ec9
--- /dev/null
+++ b/src/widgets/openwrt/methods/interface.jsx
@@ -0,0 +1,37 @@
+import { useTranslation } from "next-i18next";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { data, error } = useWidgetAPI(service.widget);
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return null;
+ }
+
+ const { up, bytesTx, bytesRx } = data;
+
+ return (
+
+ {t("openwrt.up")}
+ ) : (
+ {t("openwrt.down")}
+ )
+ }
+ />
+
+
+
+ );
+}
diff --git a/src/widgets/openwrt/methods/system.jsx b/src/widgets/openwrt/methods/system.jsx
new file mode 100644
index 00000000..7be8aa29
--- /dev/null
+++ b/src/widgets/openwrt/methods/system.jsx
@@ -0,0 +1,27 @@
+import { useTranslation } from "next-i18next";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { data, error } = useWidgetAPI(service.widget);
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return null;
+ }
+
+ const { uptime, cpuLoad } = data;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/widgets/openwrt/proxy.js b/src/widgets/openwrt/proxy.js
new file mode 100644
index 00000000..04c7a503
--- /dev/null
+++ b/src/widgets/openwrt/proxy.js
@@ -0,0 +1,128 @@
+import { sendJsonRpcRequest } from "utils/proxy/handlers/jsonrpc";
+import { formatApiCall } from "utils/proxy/api-helpers";
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+import widgets from "widgets/widgets";
+
+const PROXY_NAME = "OpenWRTProxyHandler";
+const logger = createLogger(PROXY_NAME);
+const LOGIN_PARAMS = ["00000000000000000000000000000000", "session", "login"];
+const RPC_METHOD = "call";
+
+let authToken = "00000000000000000000000000000000";
+
+const PARAMS = {
+ system: ["system", "info", {}],
+ device: ["network.device", "status", {}],
+};
+
+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;
+}
+
+function isUnauthorized(data) {
+ const json = JSON.parse(data.toString());
+ return json?.error?.code === -32002;
+}
+
+async function login(url, username, password) {
+ const response = await sendJsonRpcRequest(url, RPC_METHOD, [...LOGIN_PARAMS, { username, password }]);
+
+ if (response[0] === 200) {
+ const responseData = JSON.parse(response[2]);
+ authToken = responseData[1].ubus_rpc_session;
+ }
+
+ return response;
+}
+
+async function fetchInterface(url, interfaceName) {
+ const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.device]);
+ if (isUnauthorized(data)) {
+ return [401, contentType, data];
+ }
+ const response = JSON.parse(data.toString())[1];
+ const networkInterface = response[interfaceName];
+ if (!networkInterface) {
+ return [404, contentType, { error: "Interface not found" }];
+ }
+
+ const interfaceInfo = {
+ up: networkInterface.up,
+ bytesRx: networkInterface.statistics.rx_bytes,
+ bytesTx: networkInterface.statistics.tx_bytes,
+ };
+ return [200, contentType, interfaceInfo];
+}
+
+async function fetchSystem(url) {
+ const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.system]);
+ if (isUnauthorized(data)) {
+ return [401, contentType, data];
+ }
+ const systemResponse = JSON.parse(data.toString())[1];
+ const response = {
+ uptime: systemResponse.uptime,
+ cpuLoad: systemResponse.load[1],
+ };
+ return [200, contentType, response];
+}
+
+async function fetchData(url, widget) {
+ let response;
+ if (widget.interfaceName) {
+ response = await fetchInterface(url, widget.interfaceName);
+ } else {
+ response = await fetchSystem(url);
+ }
+ return response;
+}
+
+export default async function proxyHandler(req, res) {
+ const { group, service } = 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 getWidget(req);
+
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const api = widgets?.[widget.type]?.api;
+ const url = new URL(formatApiCall(api, { ...widget }));
+
+ let [status, , data] = await fetchData(url, widget);
+
+ if (status === 401) {
+ const [loginStatus, , loginData] = await login(url, widget.username, widget.password);
+ if (loginStatus !== 200) {
+ return res.status(loginStatus).end(loginData);
+ }
+ [status, , data] = await fetchData(url, widget);
+
+ if (status === 401) {
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+ }
+
+ return res.status(200).end(JSON.stringify(data));
+}
diff --git a/src/widgets/openwrt/widget.js b/src/widgets/openwrt/widget.js
new file mode 100644
index 00000000..e639d340
--- /dev/null
+++ b/src/widgets/openwrt/widget.js
@@ -0,0 +1,8 @@
+import proxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/ubus",
+ proxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/unifi/component.jsx b/src/widgets/unifi/component.jsx
index 5ab09999..2d5784b7 100644
--- a/src/widgets/unifi/component.jsx
+++ b/src/widgets/unifi/component.jsx
@@ -20,6 +20,10 @@ export default function Component({ service }) {
: statsData?.data?.find((s) => s.name === "default");
if (!defaultSite) {
+ if (widget.site) {
+ return ;
+ }
+
return (
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index a3ad5629..84164bb4 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -23,6 +23,7 @@ import flood from "./flood/widget";
import freshrss from "./freshrss/widget";
import fritzbox from "./fritzbox/widget";
import gamedig from "./gamedig/widget";
+import gatus from "./gatus/widget";
import ghostfolio from "./ghostfolio/widget";
import glances from "./glances/widget";
import gluetun from "./gluetun/widget";
@@ -63,6 +64,7 @@ import opendtu from "./opendtu/widget";
import opnsense from "./opnsense/widget";
import overseerr from "./overseerr/widget";
import openmediavault from "./openmediavault/widget";
+import openwrt from "./openwrt/widget";
import paperlessngx from "./paperlessngx/widget";
import peanut from "./peanut/widget";
import pfsense from "./pfsense/widget";
@@ -132,6 +134,7 @@ const widgets = {
freshrss,
fritzbox,
gamedig,
+ gatus,
ghostfolio,
glances,
gluetun,
@@ -175,6 +178,7 @@ const widgets = {
opnsense,
overseerr,
openmediavault,
+ openwrt,
paperlessngx,
peanut,
pfsense,