+
{widgets && (
<>
{widgets
@@ -442,7 +438,7 @@ function Home({ initialSettings }) {
id="information-widgets-right"
className={classNames(
"m-auto flex flex-wrap grow sm:basis-auto justify-between md:justify-end",
- headerStyle === "boxedWidgets" ? "sm:ml-4" : "sm:ml-2",
+ "m-auto flex flex-wrap grow sm:basis-auto justify-between md:justify-end gap-x-2",
)}
>
{widgets
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 402db588b24..f3bfec7860f 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 1c5ee8945c6..aaee636c9ab 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -38,7 +38,7 @@ export async function servicesFromConfig() {
// add default weight to services based on their position in the configuration
servicesArray.forEach((group, groupIndex) => {
group.services.forEach((service, serviceIndex) => {
- if (!service.weight) {
+ if (service.weight === undefined) {
servicesArray[groupIndex].services[serviceIndex].weight = (serviceIndex + 1) * 100;
}
});
@@ -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(
/^\//,
@@ -117,6 +117,8 @@ export async function servicesFromDocker() {
return { server: serverName, services: discovered.filter((filteredService) => filteredService) };
} catch (e) {
+ logger.error("Error getting services from Docker server '%s': %s", serverName, e);
+
// a server failed, but others may succeed
return { server: serverName, services: [] };
}
@@ -325,7 +327,7 @@ export async function servicesFromKubernetes() {
return mappedServiceGroups;
} catch (e) {
- logger.error(e);
+ if (e) logger.error(e);
throw e;
}
}
@@ -368,6 +370,7 @@ export function cleanServiceGroups(groups) {
showTime,
previousDays,
view,
+ timezone,
// coinmarketcap
currency,
@@ -377,6 +380,7 @@ export function cleanServiceGroups(groups) {
// customapi
mappings,
+ display,
// diskstation
volume,
@@ -389,14 +393,29 @@ export function cleanServiceGroups(groups) {
enableBlocks,
enableNowPlaying,
+ // emby, jellyfin, tautulli
+ enableUser,
+ expandOneStreamToTwoRows,
+ showEpisodeNumber,
+
+ // glances, pihole
+ version,
+
// glances
chart,
metric,
pointsLimit,
+ diskUnits,
// glances, customapi, iframe
refreshInterval,
+ // hdhomerun
+ tuner,
+
+ // healthchecks
+ uuid,
+
// iframe
allowFullscreen,
allowPolicy,
@@ -422,15 +441,25 @@ export function cleanServiceGroups(groups) {
// openmediavault
method,
+ // openwrt
+ interfaceName,
+
// opnsense, pfsense
wan,
// proxmox
node,
+ // speedtest
+ bitratePrecision,
+
// sonarr, radarr
enableQueue,
+ // truenas
+ enablePools,
+ nasType,
+
// unifi
site,
} = cleanedService.widget;
@@ -438,7 +467,7 @@ export function cleanServiceGroups(groups) {
let fieldsList = fields;
if (typeof fields === "string") {
try {
- JSON.parse(fields);
+ fieldsList = JSON.parse(fields);
} catch (e) {
logger.error("Invalid fields list detected in config for service '%s'", service.name);
fieldsList = null;
@@ -497,9 +526,20 @@ export function cleanServiceGroups(groups) {
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;
}
@@ -507,6 +547,9 @@ export function cleanServiceGroups(groups) {
if (snapshotHost) cleanedService.widget.snapshotHost = snapshotHost;
if (snapshotPath) cleanedService.widget.snapshotPath = snapshotPath;
}
+ if (["glances", "pihole"].includes(type)) {
+ if (version) cleanedService.widget.version = version;
+ }
if (type === "glances") {
if (metric) cleanedService.widget.metric = metric;
if (chart !== undefined) {
@@ -516,6 +559,7 @@ export function cleanServiceGroups(groups) {
}
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
if (pointsLimit) cleanedService.widget.pointsLimit = pointsLimit;
+ if (diskUnits) cleanedService.widget.diskUnits = diskUnits;
}
if (type === "mjpeg") {
if (stream) cleanedService.widget.stream = stream;
@@ -524,8 +568,12 @@ 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 (display) cleanedService.widget.display = display;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
}
if (type === "calendar") {
@@ -535,6 +583,18 @@ export function cleanServiceGroups(groups) {
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);
+ }
}
}
diff --git a/src/utils/logger.js b/src/utils/logger.js
index cbf84b3b2fb..a3a6ee870de 100644
--- a/src/utils/logger.js
+++ b/src/utils/logger.js
@@ -3,68 +3,89 @@ import { format as utilFormat } from "node:util";
import winston from "winston";
-import checkAndCopyConfig, { getSettings, CONF_DIR } from "utils/config/config";
+import checkAndCopyConfig, { CONF_DIR, getSettings } from "utils/config/config";
let winstonLogger;
-function init() {
- checkAndCopyConfig("settings.yaml");
+function combineMessageAndSplat() {
+ return {
+ // eslint-disable-next-line no-unused-vars
+ transform: (info, opts) => {
+ // combine message and args if any
+ // eslint-disable-next-line no-param-reassign
+ info.message = utilFormat(info.message, ...(info[Symbol.for("splat")] || []));
+ return info;
+ },
+ };
+}
+
+function messageFormatter(logInfo) {
+ if (logInfo.label) {
+ if (logInfo.stack) {
+ return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.stack}`;
+ }
+ return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.message}`;
+ }
+
+ if (logInfo.stack) {
+ return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.stack}`;
+ }
+ return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.message}`;
+}
+
+function getConsoleLogger() {
+ return new winston.transports.Console({
+ format: winston.format.combine(
+ winston.format.errors({ stack: true }),
+ combineMessageAndSplat(),
+ winston.format.timestamp(),
+ winston.format.colorize(),
+ winston.format.printf(messageFormatter),
+ ),
+ handleExceptions: true,
+ handleRejections: true,
+ });
+}
+
+function getFileLogger() {
const settings = getSettings();
const logpath = settings.logpath || CONF_DIR;
- function combineMessageAndSplat() {
- return {
- // eslint-disable-next-line no-unused-vars
- transform: (info, opts) => {
- // combine message and args if any
- // eslint-disable-next-line no-param-reassign
- info.message = utilFormat(info.message, ...(info[Symbol.for("splat")] || []));
- return info;
- },
- };
- }
+ return new winston.transports.File({
+ format: winston.format.combine(
+ winston.format.errors({ stack: true }),
+ combineMessageAndSplat(),
+ winston.format.timestamp(),
+ winston.format.printf(messageFormatter),
+ ),
+ filename: `${logpath}/logs/homepage.log`,
+ handleExceptions: true,
+ handleRejections: true,
+ });
+}
- function messageFormatter(logInfo) {
- if (logInfo.label) {
- if (logInfo.stack) {
- return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.stack}`;
- }
- return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.message}`;
- }
+function init() {
+ checkAndCopyConfig("settings.yaml");
+ const configuredTargets = process.env.LOG_TARGETS || "both";
+ const loggingTransports = [];
- if (logInfo.stack) {
- return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.stack}`;
- }
- return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.message}`;
+ switch (configuredTargets) {
+ case "both":
+ loggingTransports.push(getConsoleLogger(), getFileLogger());
+ break;
+ case "stdout":
+ loggingTransports.push(getConsoleLogger());
+ break;
+ case "file":
+ loggingTransports.push(getFileLogger());
+ break;
+ default:
+ loggingTransports.push(getConsoleLogger(), getFileLogger());
}
winstonLogger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
- transports: [
- new winston.transports.Console({
- format: winston.format.combine(
- winston.format.errors({ stack: true }),
- combineMessageAndSplat(),
- winston.format.timestamp(),
- winston.format.colorize(),
- winston.format.printf(messageFormatter),
- ),
- handleExceptions: true,
- handleRejections: true,
- }),
-
- new winston.transports.File({
- format: winston.format.combine(
- winston.format.errors({ stack: true }),
- combineMessageAndSplat(),
- winston.format.timestamp(),
- winston.format.printf(messageFormatter),
- ),
- filename: `${logpath}/logs/homepage.log`,
- handleExceptions: true,
- handleRejections: true,
- }),
- ],
+ transports: loggingTransports,
});
// patch the console log mechanism to use our logger
diff --git a/src/utils/proxy/api-helpers.js b/src/utils/proxy/api-helpers.js
index cfb4307e6f3..ffd2f63bcb8 100644
--- a/src/utils/proxy/api-helpers.js
+++ b/src/utils/proxy/api-helpers.js
@@ -57,7 +57,7 @@ export function jsonArrayFilter(data, filter) {
export function sanitizeErrorURL(errorURL) {
// Dont display sensitive params on frontend
const url = new URL(errorURL);
- ["apikey", "api_key", "token", "t"].forEach((key) => {
+ ["apikey", "api_key", "token", "t", "access_token", "auth"].forEach((key) => {
if (url.searchParams.has(key)) url.searchParams.set(key, "***");
});
return url.toString();
diff --git a/src/utils/proxy/cached-fetch.js b/src/utils/proxy/cached-fetch.js
index 0ed39562ff4..ae3c4610893 100644
--- a/src/utils/proxy/cached-fetch.js
+++ b/src/utils/proxy/cached-fetch.js
@@ -2,7 +2,7 @@ import cache from "memory-cache";
const defaultDuration = 5;
-export default async function cachedFetch(url, duration) {
+export default async function cachedFetch(url, duration, ua) {
const cached = cache.get(url);
// eslint-disable-next-line no-param-reassign
@@ -12,7 +12,14 @@ 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 options = {};
+ if (ua) {
+ options.headers = {
+ "User-Agent": ua,
+ };
+ }
+ const data = await fetch(url, options).then((res) => res.json());
cache.put(url, data, duration * 1000 * 60);
return data;
}
diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js
index 0795efd5271..de2111b12d6 100644
--- a/src/utils/proxy/handlers/credentialed.js
+++ b/src/utils/proxy/handlers/credentialed.js
@@ -29,11 +29,17 @@ export default async function credentialedProxyHandler(req, res, map) {
} else if (widget.type === "gotify") {
headers["X-gotify-Key"] = `${widget.key}`;
} else if (
- ["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "truenas", "pterodactyl"].includes(
+ ["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "tandoor", "pterodactyl"].includes(
widget.type,
)
) {
headers.Authorization = `Bearer ${widget.key}`;
+ } else if (widget.type === "truenas") {
+ if (widget.key) {
+ headers.Authorization = `Bearer ${widget.key}`;
+ } else {
+ headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
+ }
} else if (widget.type === "proxmox") {
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
} else if (widget.type === "proxmoxbackupserver") {
@@ -61,6 +67,8 @@ export default async function credentialedProxyHandler(req, res, map) {
headers.Authorization = `Basic ${Buffer.from(`$:${widget.key}`).toString("base64")}`;
} else if (widget.type === "glances") {
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
+ } else if (widget.type === "plantit") {
+ headers.Key = `${widget.key}`;
} else {
headers["X-API-Key"] = `${widget.key}`;
}
diff --git a/src/utils/proxy/handlers/generic.js b/src/utils/proxy/handlers/generic.js
index 8b91049fd3c..e4717469ad7 100644
--- a/src/utils/proxy/handlers/generic.js
+++ b/src/utils/proxy/handlers/generic.js
@@ -35,6 +35,12 @@ export default async function genericProxyHandler(req, res, map) {
};
if (req.body) {
params.body = req.body;
+ } else if (widget.requestBody) {
+ if (typeof widget.requestBody === "object") {
+ params.body = JSON.stringify(widget.requestBody);
+ } else {
+ params.body = widget.requestBody;
+ }
}
const [status, contentType, data] = await httpProxy(url, params);
diff --git a/src/utils/proxy/http.js b/src/utils/proxy/http.js
index 1755dd936f1..875bfb4cb78 100644
--- a/src/utils/proxy/http.js
+++ b/src/utils/proxy/http.js
@@ -5,6 +5,7 @@ import { createUnzip, constants as zlibConstants } from "node:zlib";
import { http, https } from "follow-redirects";
import { addCookieToJar, setCookieHeader } from "./cookie-jar";
+import { sanitizeErrorURL } from "./api-helpers";
import createLogger from "utils/logger";
@@ -44,7 +45,7 @@ function handleRequest(requestor, url, params) {
// zlib errors
responseContent.on("error", (e) => {
- logger.error(e);
+ if (e) logger.error(e);
responseContent = response; // fallback
});
response.pipe(responseContent);
@@ -103,7 +104,7 @@ export async function httpProxy(url, params = {}) {
try {
const [status, contentType, data, responseHeaders] = await request;
- return [status, contentType, data, responseHeaders];
+ return [status, contentType, data, responseHeaders, params];
} catch (err) {
logger.error(
"Error calling %s//%s%s%s...",
@@ -112,7 +113,12 @@ export async function httpProxy(url, params = {}) {
constructedUrl.port ? `:${constructedUrl.port}` : "",
constructedUrl.pathname,
);
- logger.error(err);
- return [500, "application/json", { error: { message: err?.message ?? "Unknown error", url, rawError: err } }, null];
+ if (err) logger.error(err);
+ return [
+ 500,
+ "application/json",
+ { error: { message: err?.message ?? "Unknown error", url: sanitizeErrorURL(url), rawError: err } },
+ null,
+ ];
}
}
diff --git a/src/widgets/audiobookshelf/proxy.js b/src/widgets/audiobookshelf/proxy.js
index c4dba5cdb90..9701c1feb3d 100644
--- a/src/widgets/audiobookshelf/proxy.js
+++ b/src/widgets/audiobookshelf/proxy.js
@@ -63,7 +63,7 @@ export default async function audiobookshelfProxyHandler(req, res) {
return res.status(200).send(libraryStats);
} catch (e) {
- logger.error(e.message);
+ if (e) logger.error(e);
return res.status(500).send({ error: { message: e.message } });
}
}
diff --git a/src/widgets/calendar/agenda.jsx b/src/widgets/calendar/agenda.jsx
index 9035926973a..6a3be031996 100644
--- a/src/widgets/calendar/agenda.jsx
+++ b/src/widgets/calendar/agenda.jsx
@@ -2,7 +2,7 @@ import { DateTime } from "luxon";
import classNames from "classnames";
import { useTranslation } from "next-i18next";
-import Event from "./event";
+import Event, { compareDateTimezone } from "./event";
export default function Agenda({ service, colorVariants, events, showDate }) {
const { widget } = service;
@@ -56,7 +56,7 @@ export default function Agenda({ service, colorVariants, events, showDate }) {
event={event}
colorVariants={colorVariants}
showDate={j === 0}
- showTime={widget?.showTime && event.date.startOf("day").ts === showDate.startOf("day").ts}
+ showTime={widget?.showTime && compareDateTimezone(showDate, event)}
/>
))}
diff --git a/src/widgets/calendar/component.jsx b/src/widgets/calendar/component.jsx
index 0e10d0ed392..ff93c41bee8 100644
--- a/src/widgets/calendar/component.jsx
+++ b/src/widgets/calendar/component.jsx
@@ -41,7 +41,8 @@ export default function Component({ service }) {
const { i18n } = useTranslation();
const [showDate, setShowDate] = useState(null);
const [events, setEvents] = useState({});
- const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
+ const nowDate = DateTime.now().setLocale(i18n.language);
+ const currentDate = widget?.timezone ? nowDate.setZone(widget?.timezone).startOf("day") : nowDate;
const { settings } = useContext(SettingsContext);
useEffect(() => {
@@ -93,6 +94,7 @@ export default function Component({ service }) {
params={params}
setEvents={setEvents}
hideErrors={settings.hideErrors}
+ timezone={widget?.timezone}
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
/>
);
@@ -106,6 +108,7 @@ export default function Component({ service }) {
events={events}
showDate={showDate}
setShowDate={setShowDate}
+ currentDate={currentDate}
className="flex"
/>
)}
diff --git a/src/widgets/calendar/event.jsx b/src/widgets/calendar/event.jsx
index 5d3699d7ee1..13e736a36e4 100644
--- a/src/widgets/calendar/event.jsx
+++ b/src/widgets/calendar/event.jsx
@@ -39,3 +39,4 @@ export default function Event({ event, colorVariants, showDate = false, showTime
);
}
+export const compareDateTimezone = (date, event) => date.startOf("day").ts === event.date.startOf("day").ts;
diff --git a/src/widgets/calendar/integrations/ical.jsx b/src/widgets/calendar/integrations/ical.jsx
index f5063331a7e..059adfa2ee2 100644
--- a/src/widgets/calendar/integrations/ical.jsx
+++ b/src/widgets/calendar/integrations/ical.jsx
@@ -7,7 +7,18 @@ import { RRule } from "rrule";
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
import Error from "../../../components/services/widget/error";
-export default function Integration({ config, params, setEvents, hideErrors }) {
+// 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, {
refreshInterval: 300000, // 5 minutes
@@ -32,6 +43,7 @@ export default function Integration({ config, params, setEvents, hideErrors }) {
const eventsToAdd = {};
const events = parsedIcal?.getEventsBetweenDates(startDate.toJSDate(), endDate.toJSDate());
+ const now = timezone ? DateTime.now().setZone(timezone) : DateTime.now();
events?.forEach((event) => {
let title = `${event?.summary?.value}`;
@@ -40,34 +52,45 @@ export default function Integration({ config, params, setEvents, hideErrors }) {
}
const eventToAdd = (date, i, type) => {
- const duration = event.dtend.value - event.dtstart.value;
- const days = duration / (1000 * 60 * 60 * 24);
+ // 'dtend' is null for all-day events
+ const { dtstart, dtend = { value: 0 } } = event;
+ const days = dtend.value === 0 ? 1 : (dtend.value - dtstart.value) / (1000 * 60 * 60 * 24);
+ 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(`${dtstart?.value}${dtend?.value}${title}${i}${j}${type}}`);
+ eventsToAdd[hash] = {
title,
- date: DateTime.fromJSDate(date).plus({ days: j }),
+ date: eventDate.plus({ days: j }),
color: config?.color ?? "zinc",
- isCompleted: DateTime.fromJSDate(date) < DateTime.now(),
+ isCompleted: eventDate < now,
additional: event.location?.value,
type: "ical",
};
}
};
- if (event?.recurrenceRule?.options) {
- const rule = new RRule(event.recurrenceRule.options);
- const recurringEvents = rule.between(startDate.toJSDate(), endDate.toJSDate());
+ const recurrenceOptions = event?.recurrenceRule?.origOptions;
+ if (recurrenceOptions && Object.keys(recurrenceOptions).length !== 0) {
+ try {
+ const rule = new RRule(recurrenceOptions);
+ const recurringEvents = rule.between(startDate.toJSDate(), endDate.toJSDate());
- recurringEvents.forEach((date, i) => eventToAdd(date, i, "recurring"));
- return;
+ recurringEvents.forEach((date, i) => eventToAdd(date, i, "recurring"));
+ return;
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error("Unable to parse recurring events from iCal: %s", e);
+ }
}
event.matchingDates.forEach((date, i) => eventToAdd(date, i, "single"));
});
setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));
- }, [icalData, icalError, config, params, setEvents, t]);
+ }, [icalData, icalError, config, params, setEvents, timezone, t]);
const error = icalError ?? icalData?.error;
return error && !hideErrors &&
@@ -165,6 +161,7 @@ export default function Monthly({ service, colorVariants, events, showDate, setS
colorVariants={colorVariants}
showDate={showDate}
setShowDate={setShowDate}
+ currentDate={currentDate}
/>
)),
)}
@@ -172,7 +169,7 @@ export default function Monthly({ service, colorVariants, events, showDate, setS
{eventsArray
- ?.filter((event) => showDate.startOf("day").ts === event.date?.startOf("day").ts)
+ ?.filter((event) => compareDateTimezone(showDate, event))
.slice(0, widget?.maxEvents ?? 10)
.map((event) => (
))}
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 4209b69a322..500fe0ce70e 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -15,6 +15,7 @@ const components = {
channelsdvrserver: dynamic(() => import("./channelsdvrserver/component")),
cloudflared: dynamic(() => import("./cloudflared/component")),
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
+ crowdsec: dynamic(() => import("./crowdsec/component")),
iframe: dynamic(() => import("./iframe/component")),
customapi: dynamic(() => import("./customapi/component")),
deluge: dynamic(() => import("./deluge/component")),
@@ -23,13 +24,16 @@ const components = {
docker: dynamic(() => import("./docker/component")),
kubernetes: dynamic(() => import("./kubernetes/component")),
emby: dynamic(() => import("./emby/component")),
+ esphome: dynamic(() => import("./esphome/component")),
evcc: dynamic(() => import("./evcc/component")),
fileflows: dynamic(() => import("./fileflows/component")),
flood: dynamic(() => import("./flood/component")),
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")),
+ gitea: dynamic(() => import("./gitea/component")),
glances: dynamic(() => import("./glances/component")),
gluetun: dynamic(() => import("./gluetun/component")),
gotify: dynamic(() => import("./gotify/component")),
@@ -37,6 +41,7 @@ const components = {
hdhomerun: dynamic(() => import("./hdhomerun/component")),
peanut: dynamic(() => import("./peanut/component")),
homeassistant: dynamic(() => import("./homeassistant/component")),
+ homebox: dynamic(() => import("./homebox/component")),
homebridge: dynamic(() => import("./homebridge/component")),
healthchecks: dynamic(() => import("./healthchecks/component")),
immich: dynamic(() => import("./immich/component")),
@@ -58,6 +63,8 @@ const components = {
moonraker: dynamic(() => import("./moonraker/component")),
mylar: dynamic(() => import("./mylar/component")),
navidrome: dynamic(() => import("./navidrome/component")),
+ netalertx: dynamic(() => import("./netalertx/component")),
+ netdata: dynamic(() => import("./netdata/component")),
nextcloud: dynamic(() => import("./nextcloud/component")),
nextdns: dynamic(() => import("./nextdns/component")),
npm: dynamic(() => import("./npm/component")),
@@ -69,12 +76,14 @@ 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")),
proxmoxbackupserver: dynamic(() => import("./proxmoxbackupserver/component")),
- pialert: dynamic(() => import("./pialert/component")),
+ pialert: dynamic(() => import("./netalertx/component")),
pihole: dynamic(() => import("./pihole/component")),
+ plantit: dynamic(() => import("./plantit/component")),
plex: dynamic(() => import("./plex/component")),
portainer: dynamic(() => import("./portainer/component")),
prometheus: dynamic(() => import("./prometheus/component")),
@@ -86,13 +95,16 @@ const components = {
qnap: dynamic(() => import("./qnap/component")),
radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")),
+ romm: dynamic(() => import("./romm/component")),
rutorrent: dynamic(() => import("./rutorrent/component")),
sabnzbd: dynamic(() => import("./sabnzbd/component")),
scrutiny: dynamic(() => import("./scrutiny/component")),
sonarr: dynamic(() => import("./sonarr/component")),
speedtest: dynamic(() => import("./speedtest/component")),
+ stash: dynamic(() => import("./stash/component")),
strelaysrv: dynamic(() => import("./strelaysrv/component")),
tailscale: dynamic(() => import("./tailscale/component")),
+ tandoor: dynamic(() => import("./tandoor/component")),
tautulli: dynamic(() => import("./tautulli/component")),
tdarr: dynamic(() => import("./tdarr/component")),
traefik: dynamic(() => import("./traefik/component")),
diff --git a/src/widgets/crowdsec/component.jsx b/src/widgets/crowdsec/component.jsx
new file mode 100644
index 00000000000..2e98cee9f33
--- /dev/null
+++ b/src/widgets/crowdsec/component.jsx
@@ -0,0 +1,34 @@
+import { useTranslation } from "next-i18next";
+
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+
+ const { data: alerts, error: alertsError } = useWidgetAPI(widget, "alerts");
+ const { data: bans, error: bansError } = useWidgetAPI(widget, "bans");
+
+ if (alertsError || bansError) {
+ return
;
+ }
+
+ if (!alerts && !bans) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/widgets/crowdsec/proxy.js b/src/widgets/crowdsec/proxy.js
new file mode 100644
index 00000000000..e78fbc5ef00
--- /dev/null
+++ b/src/widgets/crowdsec/proxy.js
@@ -0,0 +1,86 @@
+import cache from "memory-cache";
+
+import { httpProxy } from "utils/proxy/http";
+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 proxyName = "crowdsecProxyHandler";
+const logger = createLogger(proxyName);
+const sessionTokenCacheKey = `${proxyName}__sessionToken`;
+
+async function login(widget, service) {
+ const url = formatApiCall(widgets[widget.type].loginURL, widget);
+ const [status, , data] = await httpProxy(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "User-Agent": "Mozilla/5.0", // Crowdsec requires a user-agent
+ },
+ body: JSON.stringify({
+ machine_id: widget.username,
+ password: widget.password,
+ scenarios: [],
+ }),
+ });
+
+ const dataParsed = JSON.parse(data);
+
+ if (!(status === 200) || !dataParsed.token) {
+ logger.error("Failed to login to Crowdsec API, status: %d", status);
+ cache.del(`${sessionTokenCacheKey}.${service}`);
+ }
+ cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, new Date(dataParsed.expire) - new Date());
+}
+
+export default async function crowdsecProxyHandler(req, res) {
+ const { group, service, endpoint } = 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);
+ 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" });
+ }
+
+ if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {
+ await login(widget, service);
+ }
+
+ const token = cache.get(`${sessionTokenCacheKey}.${service}`);
+ if (!token) {
+ return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
+ }
+
+ const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
+
+ try {
+ const params = {
+ method: "GET",
+ headers: {
+ "User-Agent": "Mozilla/5.0", // Crowdsec requires a user-agent
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ };
+
+ logger.debug("Calling Crowdsec API endpoint: %s", endpoint);
+
+ const [status, , data] = await httpProxy(url, params);
+
+ if (status !== 200) {
+ logger.error("Error calling Crowdsec API: %d. Data: %s", status, data);
+ return res.status(status).json({ error: "Crowdsec API Error", data });
+ }
+
+ return res.status(status).send(data);
+ } catch (error) {
+ logger.error("Exception calling Crowdsec API: %s", error.message);
+ return res.status(500).json({ error: "Crowdsec API Error", message: error.message });
+ }
+}
diff --git a/src/widgets/crowdsec/widget.js b/src/widgets/crowdsec/widget.js
new file mode 100644
index 00000000000..d29fa1f160c
--- /dev/null
+++ b/src/widgets/crowdsec/widget.js
@@ -0,0 +1,18 @@
+import crowdsecProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/v1/{endpoint}",
+ loginURL: "{url}/v1/watchers/login",
+ proxyHandler: crowdsecProxyHandler,
+
+ mappings: {
+ alerts: {
+ endpoint: "alerts",
+ },
+ bans: {
+ endpoint: "alerts?decision_type=ban&origin=crowdsec&has_active_decision=1",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/customapi/component.jsx b/src/widgets/customapi/component.jsx
index fcf6e82f912..726dcb60220 100644
--- a/src/widgets/customapi/component.jsx
+++ b/src/widgets/customapi/component.jsx
@@ -70,13 +70,32 @@ function formatValue(t, mapping, rawValue) {
value = t("common.bitrate", { value });
break;
case "date":
- value = t("common.date", { value, dateStyle: mapping?.dateStyle ?? "long", timeStyle: mapping?.timeStyle });
+ value = t("common.date", {
+ value,
+ lng: mapping?.locale,
+ dateStyle: mapping?.dateStyle ?? "long",
+ timeStyle: mapping?.timeStyle,
+ });
+ break;
+ case "relativeDate":
+ value = t("common.relativeDate", {
+ value,
+ lng: mapping?.locale,
+ style: mapping?.style,
+ numeric: mapping?.numeric,
+ });
break;
case "text":
default:
// nothing
}
+ // Apply fixed prefix.
+ const prefix = mapping?.prefix;
+ if (prefix) {
+ value = `${prefix} ${value}`;
+ }
+
// Apply fixed suffix.
const suffix = mapping?.suffix;
if (suffix) {
@@ -86,12 +105,35 @@ function formatValue(t, mapping, rawValue) {
return value;
}
+function getColor(mapping, customData) {
+ const value = getValue(mapping.additionalField.field, customData);
+ const { color } = mapping.additionalField;
+
+ switch (color) {
+ case "adaptive":
+ try {
+ const number = parseFloat(value);
+ return number > 0 ? "text-emerald-300" : "text-rose-300";
+ } catch (e) {
+ return "";
+ }
+ case "black":
+ return `text-black`;
+ case "white":
+ return `text-white`;
+ case "theme":
+ return `text-theme-500`;
+ default:
+ return "";
+ }
+}
+
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
- const { mappings = [], refreshInterval = 10000 } = widget;
+ const { mappings = [], refreshInterval = 10000, display = "block" } = widget;
const { data: customData, error: customError } = useWidgetAPI(widget, null, {
refreshInterval: Math.max(1000, refreshInterval),
});
@@ -101,24 +143,73 @@ export default function Component({ service }) {
}
if (!customData) {
- return (
-
- {mappings.slice(0, 4).map((item) => (
-
- ))}
-
- );
+ switch (display) {
+ case "list":
+ return (
+
+
+ {mappings.map((mapping) => (
+
+ ))}
+
+
+ );
+
+ default:
+ return (
+
+ {mappings.slice(0, 4).map((item) => (
+
+ ))}
+
+ );
+ }
}
- return (
-
- {mappings.slice(0, 4).map((mapping) => (
-
- ))}
-
- );
+ switch (display) {
+ case "list":
+ return (
+
+
+ {mappings.map((mapping) => (
+
+
{mapping.label}
+
+
{formatValue(t, mapping, getValue(mapping.field, customData))}
+ {mapping.additionalField && (
+
+ {formatValue(t, mapping.additionalField, getValue(mapping.additionalField.field, customData))}
+
+ )}
+
+
+ ))}
+
+
+ );
+
+ default:
+ return (
+
+ {mappings.slice(0, 4).map((mapping) => (
+
+ ))}
+
+ );
+ }
}
diff --git a/src/widgets/emby/component.jsx b/src/widgets/emby/component.jsx
index 89fd44c36fa..9084cbac229 100644
--- a/src/widgets/emby/component.jsx
+++ b/src/widgets/emby/component.jsx
@@ -27,9 +27,26 @@ function ticksToString(ticks) {
return parts.map((part) => part.toString().padStart(2, "0")).join(":");
}
-function SingleSessionEntry({ playCommand, session }) {
+function generateStreamTitle(session, enableUser, showEpisodeNumber) {
+ const {
+ NowPlayingItem: { Name, SeriesName, Type, ParentIndexNumber, IndexNumber },
+ UserName,
+ } = session;
+ let streamTitle = "";
+
+ if (Type === "Episode" && showEpisodeNumber) {
+ const seasonStr = `S${ParentIndexNumber.toString().padStart(2, "0")}`;
+ const episodeStr = `E${IndexNumber.toString().padStart(2, "0")}`;
+ streamTitle = `${SeriesName}: ${seasonStr} ยท ${episodeStr} - ${Name}`;
+ } else {
+ streamTitle = `${Name}${SeriesName ? ` - ${SeriesName}` : ""}`;
+ }
+
+ return enableUser ? `${streamTitle} (${UserName})` : streamTitle;
+}
+
+function SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumber }) {
const {
- NowPlayingItem: { Name, SeriesName },
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
@@ -42,13 +59,13 @@ function SingleSessionEntry({ playCommand, session }) {
const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;
+ const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);
return (
<>
-
- {Name}
- {SeriesName && ` - ${SeriesName}`}
+
+ {streamTitle}
@@ -97,9 +114,8 @@ function SingleSessionEntry({ playCommand, session }) {
);
}
-function SessionEntry({ playCommand, session }) {
+function SessionEntry({ playCommand, session, enableUser, showEpisodeNumber }) {
const {
- NowPlayingItem: { Name, SeriesName },
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
@@ -110,6 +126,8 @@ function SessionEntry({ playCommand, session }) {
IsVideoDirect: true,
}; // if no transcodinginfo its videodirect
+ const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);
+
const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;
return (
@@ -139,9 +157,8 @@ function SessionEntry({ playCommand, session }) {
)}
-
- {Name}
- {SeriesName && ` - ${SeriesName}`}
+
+ {streamTitle}
{IsMuted && }
@@ -215,6 +232,9 @@ export default function Component({ service }) {
const enableBlocks = service.widget?.enableBlocks;
const enableNowPlaying = service.widget?.enableNowPlaying ?? true;
+ const enableUser = !!service.widget?.enableUser; // default is false
+ const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true
+ const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false
if (!sessionsData || !countData) {
return (
@@ -225,9 +245,11 @@ export default function Component({ service }) {
-
-
- -
-
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
)}
>
@@ -255,15 +277,17 @@ export default function Component({ service }) {
{t("emby.no_active")}
-
- -
-
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
>
);
}
- if (playing.length === 1) {
+ if (expandOneStreamToTwoRows && playing.length === 1) {
const session = playing[0];
return (
<>
@@ -272,27 +296,30 @@ export default function Component({ service }) {
handlePlayCommand(currentSession, command)}
session={session}
+ enableUser={enableUser}
+ showEpisodeNumber={showEpisodeNumber}
/>
>
);
}
- if (playing.length > 0)
- return (
- <>
- {enableBlocks &&
}
-
- {playing.map((session) => (
- handlePlayCommand(currentSession, command)}
- session={session}
- />
- ))}
-
- >
- );
+ return (
+ <>
+ {enableBlocks &&
}
+
+ {playing.map((session) => (
+ handlePlayCommand(currentSession, command)}
+ session={session}
+ enableUser={enableUser}
+ showEpisodeNumber={showEpisodeNumber}
+ />
+ ))}
+
+ >
+ );
}
if (enableBlocks) {
diff --git a/src/widgets/esphome/component.jsx b/src/widgets/esphome/component.jsx
new file mode 100644
index 00000000000..ea2e5db39f4
--- /dev/null
+++ b/src/widgets/esphome/component.jsx
@@ -0,0 +1,44 @@
+import { useTranslation } from "next-i18next";
+
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+ const { data: resultData, error: resultError } = useWidgetAPI(widget);
+
+ if (resultError) {
+ return
;
+ }
+
+ if (!resultData) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ const total = Object.keys(resultData).length;
+ const online = Object.entries(resultData).filter(([, v]) => v === true).length;
+ const notOnline = Object.entries(resultData).filter(([, v]) => v !== true).length;
+ const offline = Object.entries(resultData).filter(([, v]) => v === false).length;
+ const unknown = Object.entries(resultData).filter(([, v]) => v === null).length;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/esphome/widget.js b/src/widgets/esphome/widget.js
new file mode 100644
index 00000000000..c5a87b682b5
--- /dev/null
+++ b/src/widgets/esphome/widget.js
@@ -0,0 +1,8 @@
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/ping",
+ proxyHandler: genericProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/gamedig/proxy.js b/src/widgets/gamedig/proxy.js
index 0029834c56a..8a7e55c5a59 100644
--- a/src/widgets/gamedig/proxy.js
+++ b/src/widgets/gamedig/proxy.js
@@ -28,7 +28,7 @@ export default async function gamedigProxyHandler(req, res) {
ping: serverData.ping,
});
} catch (e) {
- logger.error(e);
+ if (e) logger.error(e);
res.status(200).send({
online: false,
diff --git a/src/widgets/gatus/component.jsx b/src/widgets/gatus/component.jsx
new file mode 100644
index 00000000000..86b85ff3ba9
--- /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 00000000000..8963ac19941
--- /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/gitea/component.jsx b/src/widgets/gitea/component.jsx
new file mode 100644
index 00000000000..b193efd2e4f
--- /dev/null
+++ b/src/widgets/gitea/component.jsx
@@ -0,0 +1,32 @@
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { widget } = service;
+
+ const { data: giteaNotifications, error: giteaNotificationsError } = useWidgetAPI(widget, "notifications");
+ const { data: giteaIssues, error: giteaIssuesError } = useWidgetAPI(widget, "issues");
+
+ if (giteaNotificationsError || giteaIssuesError) {
+ return
;
+ }
+
+ if (!giteaNotifications || !giteaIssues) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/gitea/widget.js b/src/widgets/gitea/widget.js
new file mode 100644
index 00000000000..32871b006b0
--- /dev/null
+++ b/src/widgets/gitea/widget.js
@@ -0,0 +1,22 @@
+import { asJson } from "utils/proxy/api-helpers";
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/api/v1/{endpoint}?access_token={key}",
+ proxyHandler: genericProxyHandler,
+
+ mappings: {
+ notifications: {
+ endpoint: "notifications",
+ },
+ issues: {
+ endpoint: "repos/issues/search",
+ map: (data) => ({
+ pulls: asJson(data).filter((issue) => issue.pull_request),
+ issues: asJson(data).filter((issue) => !issue.pull_request),
+ }),
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/glances/metrics/cpu.jsx b/src/widgets/glances/metrics/cpu.jsx
index c36aba9d2da..553517bace7 100644
--- a/src/widgets/glances/metrics/cpu.jsx
+++ b/src/widgets/glances/metrics/cpu.jsx
@@ -16,15 +16,15 @@ const defaultInterval = 1000;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
- const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit } = widget;
+ const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
- const { data, error } = useWidgetAPI(service.widget, "cpu", {
+ const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});
- const { data: systemData, error: systemError } = useWidgetAPI(service.widget, "system");
+ const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`);
useEffect(() => {
if (data) {
@@ -71,22 +71,15 @@ export default function Component({ service }) {
/>
)}
- {!chart && systemData && !systemError && (
+ {!chart && quicklookData && !quicklookError && (
-
- {systemData.linux_distro && `${systemData.linux_distro} - `}
- {systemData.os_version && systemData.os_version}
-
+ {quicklookData.cpu_name && quicklookData.cpu_name}
)}
- {systemData && !systemError && (
+ {quicklookData && !quicklookError && (
- {systemData.linux_distro && chart && {systemData.linux_distro}
}
-
- {systemData.os_version && chart && {systemData.os_version}
}
-
- {systemData.hostname && {systemData.hostname}
}
+ {quicklookData.cpu_name && chart && {quicklookData.cpu_name}
}
)}
diff --git a/src/widgets/glances/metrics/disk.jsx b/src/widgets/glances/metrics/disk.jsx
index d5cac47707f..04a5071fafc 100644
--- a/src/widgets/glances/metrics/disk.jsx
+++ b/src/widgets/glances/metrics/disk.jsx
@@ -16,7 +16,7 @@ const defaultInterval = 1000;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
- const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit } = widget;
+ const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const [, diskName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(
@@ -24,7 +24,7 @@ export default function Component({ service }) {
);
const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
- const { data, error } = useWidgetAPI(service.widget, "diskio", {
+ const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});
diff --git a/src/widgets/glances/metrics/fs.jsx b/src/widgets/glances/metrics/fs.jsx
index 9cd0cec6d09..3ec7eb6cea4 100644
--- a/src/widgets/glances/metrics/fs.jsx
+++ b/src/widgets/glances/metrics/fs.jsx
@@ -11,10 +11,11 @@ const defaultInterval = 1000;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
- const { chart, refreshInterval = defaultInterval } = widget;
+ const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const [, fsName] = widget.metric.split("fs:");
+ const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
- const { data, error } = useWidgetAPI(widget, "fs", {
+ const { data, error } = useWidgetAPI(widget, `${version}/fs`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});
@@ -60,7 +61,7 @@ export default function Component({ service }) {
{fsData.used && chart && (
- {t("common.bbytes", {
+ {t(diskUnits, {
value: fsData.used,
maximumFractionDigits: 0,
})}{" "}
@@ -69,7 +70,7 @@ export default function Component({ service }) {
)}
- {t("common.bbytes", {
+ {t(diskUnits, {
value: fsData.free,
maximumFractionDigits: 1,
})}{" "}
@@ -81,7 +82,7 @@ export default function Component({ service }) {
{fsData.used && (
- {t("common.bbytes", {
+ {t(diskUnits, {
value: fsData.used,
maximumFractionDigits: 0,
})}{" "}
@@ -93,7 +94,7 @@ export default function Component({ service }) {
- {t("common.bbytes", {
+ {t(diskUnits, {
value: fsData.size,
maximumFractionDigits: 1,
})}{" "}
diff --git a/src/widgets/glances/metrics/gpu.jsx b/src/widgets/glances/metrics/gpu.jsx
index c33c639686e..174ae2e0786 100644
--- a/src/widgets/glances/metrics/gpu.jsx
+++ b/src/widgets/glances/metrics/gpu.jsx
@@ -16,12 +16,12 @@ const defaultInterval = 1000;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
- const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit } = widget;
+ const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const [, gpuName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
- const { data, error } = useWidgetAPI(widget, "gpu", {
+ const { data, error } = useWidgetAPI(widget, `${version}/gpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});
diff --git a/src/widgets/glances/metrics/info.jsx b/src/widgets/glances/metrics/info.jsx
index e7555bcedcc..1ee47b980d5 100644
--- a/src/widgets/glances/metrics/info.jsx
+++ b/src/widgets/glances/metrics/info.jsx
@@ -74,13 +74,13 @@ const defaultSystemInterval = 30000; // This data (OS, hostname, distribution) i
export default function Component({ service }) {
const { widget } = service;
- const { chart, refreshInterval = defaultInterval } = widget;
+ const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
- const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, "quicklook", {
+ const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, {
refreshInterval,
});
- const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, "system", {
+ const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, {
refreshInterval: defaultSystemInterval,
});
@@ -122,7 +122,10 @@ export default function Component({ service }) {
)}
{!chart && quicklookData?.swap === 0 && (
-
{quicklookData.cpu_name}
+
+ {systemData && systemData.linux_distro && `${systemData.linux_distro} - `}
+ {systemData && systemData.os_version}
+
)}
{!chart && }
@@ -137,7 +140,7 @@ export default function Component({ service }) {
)}
{!chart && (
-
+
)}
diff --git a/src/widgets/glances/metrics/memory.jsx b/src/widgets/glances/metrics/memory.jsx
index d6cc5e6c9fd..49046a5fcc3 100644
--- a/src/widgets/glances/metrics/memory.jsx
+++ b/src/widgets/glances/metrics/memory.jsx
@@ -17,11 +17,11 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart } = widget;
- const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit } = widget;
+ const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
- const { data, error } = useWidgetAPI(service.widget, "mem", {
+ const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
});
diff --git a/src/widgets/glances/metrics/net.jsx b/src/widgets/glances/metrics/net.jsx
index 3bd92c229f0..c1ec937ed47 100644
--- a/src/widgets/glances/metrics/net.jsx
+++ b/src/widgets/glances/metrics/net.jsx
@@ -17,13 +17,16 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { chart, metric } = widget;
- const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit } = widget;
+ const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
+
+ const rxKey = version === 3 ? "rx" : "bytes_recv";
+ const txKey = version === 3 ? "tx" : "bytes_sent";
const [, interfaceName] = metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
- const { data, error } = useWidgetAPI(widget, "network", {
+ const { data, error } = useWidgetAPI(widget, `${version}/network`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
});
@@ -36,8 +39,8 @@ export default function Component({ service }) {
const newDataPoints = [
...prevDataPoints,
{
- a: (interfaceData.rx * 8) / interfaceData.time_since_update,
- b: (interfaceData.tx * 8) / interfaceData.time_since_update,
+ a: (interfaceData[rxKey] * 8) / interfaceData.time_since_update,
+ b: (interfaceData[txKey] * 8) / interfaceData.time_since_update,
},
];
if (newDataPoints.length > pointsLimit) {
@@ -47,7 +50,7 @@ export default function Component({ service }) {
});
}
}
- }, [data, interfaceName, pointsLimit]);
+ }, [data, interfaceName, pointsLimit, rxKey, txKey]);
if (error) {
return (
@@ -97,7 +100,7 @@ export default function Component({ service }) {
{t("common.bitrate", {
- value: (interfaceData.rx * 8) / interfaceData.time_since_update,
+ value: (interfaceData[rxKey] * 8) / interfaceData.time_since_update,
maximumFractionDigits: 0,
})}{" "}
{t("docker.rx")}
@@ -115,7 +118,7 @@ export default function Component({ service }) {
{t("common.bitrate", {
- value: (interfaceData.tx * 8) / interfaceData.time_since_update,
+ value: (interfaceData[txKey] * 8) / interfaceData.time_since_update,
maximumFractionDigits: 0,
})}{" "}
{t("docker.tx")}
diff --git a/src/widgets/glances/metrics/process.jsx b/src/widgets/glances/metrics/process.jsx
index cd21356dc0d..b242535eebf 100644
--- a/src/widgets/glances/metrics/process.jsx
+++ b/src/widgets/glances/metrics/process.jsx
@@ -22,9 +22,11 @@ const defaultInterval = 1000;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
- const { chart, refreshInterval = defaultInterval } = widget;
+ const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
- const { data, error } = useWidgetAPI(service.widget, "processlist", {
+ const memoryInfoKey = version === 3 ? 0 : "data";
+
+ const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});
@@ -62,11 +64,11 @@ export default function Component({ service }) {
{statusMap[item.status]}
-
{item.name}
+
{item.name}
{item.cpu_percent.toFixed(1)}%
{t("common.bytes", {
- value: item.memory_info[0],
+ value: item.memory_info[memoryInfoKey],
maximumFractionDigits: 0,
})}
diff --git a/src/widgets/glances/metrics/sensor.jsx b/src/widgets/glances/metrics/sensor.jsx
index 60ea07c8a55..e0f679c13a3 100644
--- a/src/widgets/glances/metrics/sensor.jsx
+++ b/src/widgets/glances/metrics/sensor.jsx
@@ -16,12 +16,12 @@ const defaultInterval = 1000;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
- const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit } = widget;
+ const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
const [, sensorName] = widget.metric.split(":");
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
- const { data, error } = useWidgetAPI(service.widget, "sensors", {
+ const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
});
diff --git a/src/widgets/glances/widget.js b/src/widgets/glances/widget.js
index 3da1c6d1239..3357cf28e39 100644
--- a/src/widgets/glances/widget.js
+++ b/src/widgets/glances/widget.js
@@ -1,7 +1,7 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
- api: "{url}/api/3/{endpoint}",
+ api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
};
diff --git a/src/widgets/hdhomerun/component.jsx b/src/widgets/hdhomerun/component.jsx
index 2b2cb24a40b..a118eafeded 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 689fbf0bcb3..e708b4d4d45 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/healthchecks/component.jsx b/src/widgets/healthchecks/component.jsx
index 12ec726eb41..21fb7cb6f30 100644
--- a/src/widgets/healthchecks/component.jsx
+++ b/src/widgets/healthchecks/component.jsx
@@ -27,6 +27,23 @@ function formatDate(dateString) {
return new Intl.DateTimeFormat(i18n.language, dateOptions).format(date);
}
+function countStatus(data) {
+ let upCount = 0;
+ let downCount = 0;
+
+ if (data.checks) {
+ data.checks.forEach((check) => {
+ if (check.status === "up") {
+ upCount += 1;
+ } else if (check.status === "down") {
+ downCount += 1;
+ }
+ });
+ }
+
+ return { upCount, downCount };
+}
+
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
@@ -46,7 +63,11 @@ export default function Component({ service }) {
);
}
- return (
+ const hasUuid = !!widget?.uuid;
+
+ const { upCount, downCount } = countStatus(data);
+
+ return hasUuid ? (
+ ) : (
+
+
+
+
);
}
diff --git a/src/widgets/healthchecks/widget.js b/src/widgets/healthchecks/widget.js
index 02ae9acf5b4..50324dd5192 100644
--- a/src/widgets/healthchecks/widget.js
+++ b/src/widgets/healthchecks/widget.js
@@ -1,13 +1,12 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
- api: "{url}/api/v2/{endpoint}/{uuid}",
+ api: "{url}/api/v3/{endpoint}/{uuid}",
proxyHandler: credentialedProxyHandler,
mappings: {
checks: {
endpoint: "checks",
- validate: ["status", "last_ping"],
},
},
};
diff --git a/src/widgets/homebox/component.jsx b/src/widgets/homebox/component.jsx
new file mode 100644
index 00000000000..18ea520e0d8
--- /dev/null
+++ b/src/widgets/homebox/component.jsx
@@ -0,0 +1,58 @@
+import { useTranslation } from "next-i18next";
+
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export const homeboxDefaultFields = ["items", "locations", "totalValue"];
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { data: homeboxData, error: homeboxError } = useWidgetAPI(widget);
+
+ if (homeboxError) {
+ return ;
+ }
+
+ // Default fields
+ if (!widget.fields?.length > 0) {
+ widget.fields = homeboxDefaultFields;
+ }
+ const MAX_ALLOWED_FIELDS = 4;
+ // Limits max number of displayed fields
+ if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+ }
+
+ if (!homeboxData) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/homebox/proxy.js b/src/widgets/homebox/proxy.js
new file mode 100644
index 00000000000..0d6fdf13c66
--- /dev/null
+++ b/src/widgets/homebox/proxy.js
@@ -0,0 +1,103 @@
+import cache from "memory-cache";
+
+import { formatApiCall } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+
+const proxyName = "homeboxProxyHandler";
+const sessionTokenCacheKey = `${proxyName}__sessionToken`;
+const logger = createLogger(proxyName);
+
+async function login(widget, service) {
+ logger.debug("Homebox is rejecting the request, logging in.");
+
+ const loginUrl = new URL(`${widget.url}/api/v1/users/login`).toString();
+ const loginBody = `username=${encodeURIComponent(widget.username)}&password=${encodeURIComponent(widget.password)}`;
+ const loginParams = {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: loginBody,
+ };
+
+ const [, , data] = await httpProxy(loginUrl, loginParams);
+
+ try {
+ const { token, expiresAt } = JSON.parse(data.toString());
+ const expiresAtDate = new Date(expiresAt).getTime();
+ cache.put(`${sessionTokenCacheKey}.${service}`, token, expiresAtDate - Date.now());
+ return { token };
+ } catch (e) {
+ logger.error("Unable to login to Homebox API: %s", e);
+ }
+
+ return { token: false };
+}
+
+async function apiCall(widget, endpoint, service) {
+ const key = `${sessionTokenCacheKey}.${service}`;
+ const url = new URL(formatApiCall("{url}/api/v1/{endpoint}", { endpoint, ...widget }));
+ const headers = {
+ "Content-Type": "application/json",
+ Authorization: `${cache.get(key)}`,
+ };
+ const params = { method: "GET", headers };
+
+ let [status, contentType, data, responseHeaders] = await httpProxy(url, params);
+
+ if (status === 401 || status === 403) {
+ logger.debug("Homebox API rejected the request, attempting to obtain new access token");
+ const { token } = await login(widget, service);
+ headers.Authorization = `${token}`;
+
+ // retry request with new token
+ [status, contentType, data, responseHeaders] = await httpProxy(url, params);
+
+ if (status !== 200) {
+ logger.error("HTTP %d logging in to Homebox, data: %s", status, data);
+ return { status, contentType, data: null, responseHeaders };
+ }
+ }
+
+ if (status !== 200) {
+ logger.error("HTTP %d getting data from Homebox, data: %s", status, data);
+ return { status, contentType, data: null, responseHeaders };
+ }
+
+ return { status, contentType, data: JSON.parse(data.toString()), responseHeaders };
+}
+
+export default async function homeboxProxyHandler(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 getServiceWidget(group, service);
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {
+ await login(widget, service);
+ }
+
+ // Get stats for the main blocks
+ const { data: groupStats } = await apiCall(widget, "groups/statistics", service);
+
+ // Get group info for currency
+ const { data: groupData } = await apiCall(widget, "groups", service);
+
+ return res.status(200).send({
+ items: groupStats?.totalItems,
+ locations: groupStats?.totalLocations,
+ labels: groupStats?.totalLabels,
+ totalWithWarranty: groupStats?.totalWithWarranty,
+ totalValue: groupStats?.totalItemPrice,
+ users: groupStats?.totalUsers,
+ currencyCode: groupData?.currency,
+ });
+}
diff --git a/src/widgets/homebox/widget.js b/src/widgets/homebox/widget.js
new file mode 100644
index 00000000000..37b06a4f3c9
--- /dev/null
+++ b/src/widgets/homebox/widget.js
@@ -0,0 +1,7 @@
+import homeboxProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: homeboxProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/homebridge/proxy.js b/src/widgets/homebridge/proxy.js
index 2803415ae18..17dc8635c71 100644
--- a/src/widgets/homebridge/proxy.js
+++ b/src/widgets/homebridge/proxy.js
@@ -14,7 +14,7 @@ async function login(widget, service) {
const endpoint = "auth/login";
const api = widgets?.[widget.type]?.api;
const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget }));
- const loginBody = { username: widget.username, password: widget.password };
+ const loginBody = { username: widget.username.toString(), password: widget.password.toString() };
const headers = { "Content-Type": "application/json" };
// eslint-disable-next-line no-unused-vars
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
diff --git a/src/widgets/immich/component.jsx b/src/widgets/immich/component.jsx
index 0f9b104c209..66616f78448 100644
--- a/src/widgets/immich/component.jsx
+++ b/src/widgets/immich/component.jsx
@@ -30,9 +30,9 @@ export default function Component({ service }) {
return (
-
-
-
+
+
+
;
+ }
+
+ if (!netalertxData) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/pialert/widget.js b/src/widgets/netalertx/widget.js
similarity index 100%
rename from src/widgets/pialert/widget.js
rename to src/widgets/netalertx/widget.js
diff --git a/src/widgets/netdata/component.jsx b/src/widgets/netdata/component.jsx
new file mode 100644
index 00000000000..9d7f2469b53
--- /dev/null
+++ b/src/widgets/netdata/component.jsx
@@ -0,0 +1,33 @@
+import { useTranslation } from "next-i18next";
+
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+
+ const { data: netdataData, error: netdataError } = useWidgetAPI(widget, "info");
+
+ if (netdataError) {
+ return ;
+ }
+
+ if (!netdataData) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/widgets/netdata/widget.js b/src/widgets/netdata/widget.js
new file mode 100644
index 00000000000..9c7b2457c76
--- /dev/null
+++ b/src/widgets/netdata/widget.js
@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/api/v1/{endpoint}",
+ proxyHandler: genericProxyHandler,
+
+ mappings: {
+ info: {
+ endpoint: "info",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/omada/component.jsx b/src/widgets/omada/component.jsx
index d0565e43cc3..c4da6067736 100644
--- a/src/widgets/omada/component.jsx
+++ b/src/widgets/omada/component.jsx
@@ -17,12 +17,20 @@ export default function Component({ service }) {
return ;
}
+ if (!widget.fields) {
+ widget.fields = ["connectedAp", "activeUser", "alerts", "connectedGateway"];
+ } else if (widget.fields?.length > 4) {
+ widget.fields = widget.fields.slice(0, 4);
+ }
+
if (!omadaData) {
return (
+
+
);
}
@@ -32,9 +40,8 @@ export default function Component({ service }) {
- {omadaData.connectedGateways > 0 && (
-
- )}
+
+
);
}
diff --git a/src/widgets/openwrt/component.jsx b/src/widgets/openwrt/component.jsx
new file mode 100644
index 00000000000..a8dbd540996
--- /dev/null
+++ b/src/widgets/openwrt/component.jsx
@@ -0,0 +1,9 @@
+import Interface from "./methods/interface";
+import System from "./methods/system";
+
+export default function Component({ service }) {
+ if (service.widget.interfaceName) {
+ return ;
+ }
+ return ;
+}
diff --git a/src/widgets/openwrt/methods/interface.jsx b/src/widgets/openwrt/methods/interface.jsx
new file mode 100644
index 00000000000..91366ec9531
--- /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 00000000000..7be8aa29b98
--- /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 00000000000..04c7a5039aa
--- /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 00000000000..e639d34048e
--- /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/pialert/component.jsx b/src/widgets/pialert/component.jsx
deleted file mode 100644
index 49bef89727b..00000000000
--- a/src/widgets/pialert/component.jsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useTranslation } from "next-i18next";
-
-import Container from "components/services/widget/container";
-import Block from "components/services/widget/block";
-import useWidgetAPI from "utils/proxy/use-widget-api";
-
-export default function Component({ service }) {
- const { t } = useTranslation();
-
- const { widget } = service;
-
- const { data: pialertData, error: pialertError } = useWidgetAPI(widget, "data");
-
- if (pialertError) {
- return ;
- }
-
- if (!pialertData) {
- return (
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/src/widgets/pihole/component.jsx b/src/widgets/pihole/component.jsx
index c9b03610759..7aa706e43d4 100644
--- a/src/widgets/pihole/component.jsx
+++ b/src/widgets/pihole/component.jsx
@@ -9,12 +9,16 @@ export default function Component({ service }) {
const { widget } = service;
- const { data: piholeData, error: piholeError } = useWidgetAPI(widget, "summaryRaw");
+ const { data: piholeData, error: piholeError } = useWidgetAPI(widget);
if (piholeError) {
return ;
}
+ if (!widget.fields) {
+ widget.fields = ["queries", "blocked", "gravity"];
+ }
+
if (!piholeData) {
return (
@@ -26,13 +30,18 @@ export default function Component({ service }) {
);
}
+ let blockedValue = `${t("common.number", { value: parseInt(piholeData.ads_blocked_today, 10) })}`;
+ if (!widget.fields.includes("blocked_percent")) {
+ blockedValue += ` (${t("common.percent", { value: parseFloat(piholeData.ads_percentage_today).toPrecision(3) })})`;
+ }
+
return (
-
+
;
+ }
+
+ if (!plantitData) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/plantit/widget.js b/src/widgets/plantit/widget.js
new file mode 100644
index 00000000000..5a4bebc15ff
--- /dev/null
+++ b/src/widgets/plantit/widget.js
@@ -0,0 +1,21 @@
+import { asJson } from "utils/proxy/api-helpers";
+import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+
+const widget = {
+ api: "{url}/api/{endpoint}",
+ proxyHandler: credentialedProxyHandler,
+
+ mappings: {
+ plantit: {
+ endpoint: "stats",
+ },
+ map: (data) => ({
+ events: Object.values(asJson(data).diaryEntryCount).reduce((acc, i) => acc + i, 0),
+ plants: Object.values(asJson(data).plantCount).reduce((acc, i) => acc + i, 0),
+ photos: Object.values(asJson(data).imageCount).reduce((acc, i) => acc + i, 0),
+ species: Object.values(asJson(data).botanicalInfoCount).reduce((acc, i) => acc + i, 0),
+ }),
+ },
+};
+
+export default widget;
diff --git a/src/widgets/pyload/proxy.js b/src/widgets/pyload/proxy.js
index 802a67c6c06..4d7cd1168cd 100644
--- a/src/widgets/pyload/proxy.js
+++ b/src/widgets/pyload/proxy.js
@@ -103,7 +103,7 @@ export default async function pyloadProxyHandler(req, res) {
}
}
} catch (e) {
- logger.error(e);
+ if (e) logger.error(e);
return res.status(500).send({ error: { message: `Error communicating with Pyload API: ${e.toString()}` } });
}
diff --git a/src/widgets/romm/component.jsx b/src/widgets/romm/component.jsx
new file mode 100644
index 00000000000..44b114b030e
--- /dev/null
+++ b/src/widgets/romm/component.jsx
@@ -0,0 +1,35 @@
+import { useTranslation } from "next-i18next";
+
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { widget } = service;
+ const { t } = useTranslation();
+
+ const { data: response, error: responseError } = useWidgetAPI(widget, "statistics");
+
+ if (responseError) {
+ return (
+
+
+
+ );
+ }
+
+ if (responseError) {
+ return ;
+ }
+
+ if (response) {
+ const platforms = response.filter((x) => x.rom_count !== 0).length;
+ const totalRoms = response.reduce((total, stat) => total + stat.rom_count, 0);
+ return (
+
+
+
+
+ );
+ }
+}
diff --git a/src/widgets/romm/widget.js b/src/widgets/romm/widget.js
new file mode 100644
index 00000000000..a7bb60fd69c
--- /dev/null
+++ b/src/widgets/romm/widget.js
@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/api/{endpoint}",
+ proxyHandler: genericProxyHandler,
+
+ mappings: {
+ statistics: {
+ endpoint: "platforms",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/sabnzbd/component.jsx b/src/widgets/sabnzbd/component.jsx
index d7fde734690..260375a4441 100644
--- a/src/widgets/sabnzbd/component.jsx
+++ b/src/widgets/sabnzbd/component.jsx
@@ -37,7 +37,7 @@ export default function Component({ service }) {
return (
-
+
diff --git a/src/widgets/speedtest/component.jsx b/src/widgets/speedtest/component.jsx
index 0102025bacd..9826f7767bc 100644
--- a/src/widgets/speedtest/component.jsx
+++ b/src/widgets/speedtest/component.jsx
@@ -11,6 +11,11 @@ export default function Component({ service }) {
const { data: speedtestData, error: speedtestError } = useWidgetAPI(widget, "speedtest/latest");
+ const bitratePrecision =
+ !widget?.bitratePrecision || Number.isNaN(widget?.bitratePrecision) || widget?.bitratePrecision < 0
+ ? 0
+ : widget.bitratePrecision;
+
if (speedtestError) {
return ;
}
@@ -29,9 +34,18 @@ export default function Component({ service }) {
+
-
;
+ }
+
+ if (!stats) {
+ return (
+
+
+
+
+ );
+ }
+
+ // Provide a default if not set in the config
+ if (!widget.fields) {
+ widget.fields = ["scenes", "images"];
+ }
+
+ // Limit to a maximum of 4 at a time
+ if (widget.fields.length > 4) {
+ widget.fields = widget.fields.slice(0, 4);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/stash/widget.js b/src/widgets/stash/widget.js
new file mode 100644
index 00000000000..82803c72a1e
--- /dev/null
+++ b/src/widgets/stash/widget.js
@@ -0,0 +1,40 @@
+import { asJson } from "utils/proxy/api-helpers";
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/{endpoint}?apikey={key}",
+ proxyHandler: genericProxyHandler,
+
+ mappings: {
+ stats: {
+ method: "POST",
+ endpoint: "graphql",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ query: `{
+ stats {
+ scene_count
+ scenes_size
+ scenes_duration
+ image_count
+ images_size
+ gallery_count
+ performer_count
+ studio_count
+ movie_count
+ tag_count
+ total_o_count
+ total_play_duration
+ total_play_count
+ scenes_played
+ }
+ }`,
+ }),
+ map: (data) => asJson(data).data.stats,
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/tandoor/component.jsx b/src/widgets/tandoor/component.jsx
new file mode 100644
index 00000000000..40d2b88e1a9
--- /dev/null
+++ b/src/widgets/tandoor/component.jsx
@@ -0,0 +1,32 @@
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { widget } = service;
+
+ const { data: spaceData, error: spaceError } = useWidgetAPI(widget, "space");
+ const { data: keywordData, error: keywordError } = useWidgetAPI(widget, "keyword");
+
+ if (spaceError || keywordError) {
+ const finalError = spaceError ?? keywordError;
+ return ;
+ }
+
+ if (!spaceData || !keywordData) {
+ return (
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/tandoor/widget.js b/src/widgets/tandoor/widget.js
new file mode 100644
index 00000000000..90eaa6d33f0
--- /dev/null
+++ b/src/widgets/tandoor/widget.js
@@ -0,0 +1,17 @@
+import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+
+const widget = {
+ api: "{url}/api/{endpoint}/",
+ proxyHandler: credentialedProxyHandler,
+
+ mappings: {
+ space: {
+ endpoint: "space",
+ },
+ keyword: {
+ endpoint: "keyword",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/tautulli/component.jsx b/src/widgets/tautulli/component.jsx
index e1a4df00da0..b540c6d700a 100644
--- a/src/widgets/tautulli/component.jsx
+++ b/src/widgets/tautulli/component.jsx
@@ -25,14 +25,32 @@ function millisecondsToString(milliseconds) {
return parts.map((part) => part.toString().padStart(2, "0")).join(":");
}
-function SingleSessionEntry({ session }) {
- const { full_title, duration, view_offset, progress_percent, state, video_decision, audio_decision } = session;
+function generateStreamTitle(session, enableUser, showEpisodeNumber) {
+ let stream_title = "";
+ const { media_type, parent_media_index, media_index, title, grandparent_title, full_title, friendly_name } = session;
+ if (media_type === "episode" && showEpisodeNumber) {
+ const season_str = `S${parent_media_index.toString().padStart(2, "0")}`;
+ const episode_str = `E${media_index.toString().padStart(2, "0")}`;
+ stream_title = `${grandparent_title}: ${season_str} ยท ${episode_str} - ${title}`;
+ } else {
+ stream_title = full_title;
+ }
+
+ return enableUser ? `${stream_title} (${friendly_name})` : stream_title;
+}
+
+function SingleSessionEntry({ session, enableUser, showEpisodeNumber }) {
+ const { duration, view_offset, progress_percent, state, video_decision, audio_decision } = session;
+
+ const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);
return (
<>
-
{full_title}
+
+ {stream_title}
+
{video_decision === "direct play" && audio_decision === "direct play" && (
@@ -74,8 +92,10 @@ function SingleSessionEntry({ session }) {
);
}
-function SessionEntry({ session }) {
- const { full_title, view_offset, progress_percent, state, video_decision, audio_decision } = session;
+function SessionEntry({ session, enableUser, showEpisodeNumber }) {
+ const { view_offset, progress_percent, state, video_decision, audio_decision } = session;
+
+ const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);
return (
@@ -94,7 +114,9 @@ function SessionEntry({ session }) {
)}
-
{full_title}
+
+ {stream_title}
+
{video_decision === "direct play" && audio_decision === "direct play" && (
@@ -122,6 +144,10 @@ export default function Component({ service }) {
refreshInterval: 5000,
});
+ const enableUser = !!service.widget?.enableUser; // default is false
+ const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true
+ const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false
+
if (activityError || (activityData && Object.keys(activityData.response.data).length === 0)) {
return
;
}
@@ -132,9 +158,11 @@ export default function Component({ service }) {
-
-
- -
-
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
);
}
@@ -155,18 +183,20 @@ export default function Component({ service }) {
{t("tautulli.no_active")}
-
- -
-
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
);
}
- if (playing.length === 1) {
+ if (expandOneStreamToTwoRows && playing.length === 1) {
const session = playing[0];
return (
-
+
);
}
@@ -174,7 +204,12 @@ export default function Component({ service }) {
return (
{playing.map((session) => (
-
+
))}
);
diff --git a/src/widgets/truenas/component.jsx b/src/widgets/truenas/component.jsx
index c1fc5c53a0f..10d45bf61e1 100644
--- a/src/widgets/truenas/component.jsx
+++ b/src/widgets/truenas/component.jsx
@@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
+import Pool from "widgets/truenas/pool";
export default function Component({ service }) {
const { t } = useTranslation();
@@ -11,9 +12,10 @@ export default function Component({ service }) {
const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts");
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
+ const { data: poolsData, error: poolsError } = useWidgetAPI(widget, "pools");
- if (alertError || statusError) {
- const finalError = alertError ?? statusError;
+ if (alertError || statusError || poolsError) {
+ const finalError = alertError ?? statusError ?? poolsError;
return
;
}
@@ -27,11 +29,27 @@ export default function Component({ service }) {
);
}
+ const enablePools = widget?.enablePools && Array.isArray(poolsData) && poolsData.length > 0;
+
return (
-
-
-
-
-
+ <>
+
+
+
+
+
+ {enablePools &&
+ poolsData.map((pool) => (
+
+ ))}
+ >
);
}
diff --git a/src/widgets/truenas/pool.jsx b/src/widgets/truenas/pool.jsx
new file mode 100644
index 00000000000..b92ecb685e5
--- /dev/null
+++ b/src/widgets/truenas/pool.jsx
@@ -0,0 +1,41 @@
+import classNames from "classnames";
+import prettyBytes from "pretty-bytes";
+
+export default function Pool({ name, free, allocated, healthy, data, nasType }) {
+ let total = 0;
+ if (nasType === "scale") {
+ total = free + allocated;
+ } else {
+ allocated = 0; // eslint-disable-line no-param-reassign
+ for (let i = 0; i < data.length; i += 1) {
+ total += data[i].stats.size;
+ allocated += data[i].stats.allocated; // eslint-disable-line no-param-reassign
+ }
+ }
+
+ const usedPercent = Math.round((allocated / total) * 100);
+ const statusColor = healthy ? "bg-green-500" : "bg-yellow-500";
+
+ return (
+
+
+
+
+
+
+
+
+ {prettyBytes(allocated)} / {prettyBytes(total)}
+
+ ({usedPercent}%)
+
+
+ );
+}
diff --git a/src/widgets/truenas/widget.js b/src/widgets/truenas/widget.js
index 6c0f3622cfd..5f8a38df336 100644
--- a/src/widgets/truenas/widget.js
+++ b/src/widgets/truenas/widget.js
@@ -1,32 +1,9 @@
-import { jsonArrayFilter } from "utils/proxy/api-helpers";
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
-import genericProxyHandler from "utils/proxy/handlers/generic";
-import getServiceWidget from "utils/config/service-helpers";
+import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers";
const widget = {
api: "{url}/api/v2.0/{endpoint}",
- proxyHandler: async (req, res, map) => {
- // choose proxy handler based on widget settings
- const { group, service } = req.query;
-
- if (group && service) {
- const widgetOpts = await getServiceWidget(group, service);
- let handler;
- if (widgetOpts.username && widgetOpts.password) {
- handler = genericProxyHandler;
- } else if (widgetOpts.key) {
- handler = credentialedProxyHandler;
- }
-
- if (handler) {
- return handler(req, res, map);
- }
-
- return res.status(500).json({ error: "Username / password or API key required" });
- }
-
- return res.status(500).json({ error: "Error parsing widget request" });
- },
+ proxyHandler: credentialedProxyHandler,
mappings: {
alerts: {
@@ -39,6 +16,18 @@ const widget = {
endpoint: "system/info",
validate: ["loadavg", "uptime_seconds"],
},
+ pools: {
+ endpoint: "pool",
+ map: (data) =>
+ asJson(data).map((entry) => ({
+ id: entry.name,
+ name: entry.name,
+ healthy: entry.healthy,
+ allocated: entry.allocated,
+ free: entry.free,
+ data: entry.topology.data,
+ })),
+ },
},
};
diff --git a/src/widgets/unifi/component.jsx b/src/widgets/unifi/component.jsx
index 5ab099992b0..2d5784b7744 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 904bd701f2c..7ed98bfb97e 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -12,24 +12,29 @@ import changedetectionio from "./changedetectionio/widget";
import channelsdvrserver from "./channelsdvrserver/widget";
import cloudflared from "./cloudflared/widget";
import coinmarketcap from "./coinmarketcap/widget";
+import crowdsec from "./crowdsec/widget";
import customapi from "./customapi/widget";
import deluge from "./deluge/widget";
import diskstation from "./diskstation/widget";
import downloadstation from "./downloadstation/widget";
import emby from "./emby/widget";
+import esphome from "./esphome/widget";
import evcc from "./evcc/widget";
import fileflows from "./fileflows/widget";
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 gitea from "./gitea/widget";
import glances from "./glances/widget";
import gluetun from "./gluetun/widget";
import gotify from "./gotify/widget";
import grafana from "./grafana/widget";
import hdhomerun from "./hdhomerun/widget";
import homeassistant from "./homeassistant/widget";
+import homebox from "./homebox/widget";
import homebridge from "./homebridge/widget";
import healthchecks from "./healthchecks/widget";
import immich from "./immich/widget";
@@ -50,6 +55,8 @@ import mjpeg from "./mjpeg/widget";
import moonraker from "./moonraker/widget";
import mylar from "./mylar/widget";
import navidrome from "./navidrome/widget";
+import netalertx from "./netalertx/widget";
+import netdata from "./netdata/widget";
import nextcloud from "./nextcloud/widget";
import nextdns from "./nextdns/widget";
import npm from "./npm/widget";
@@ -61,13 +68,14 @@ 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";
import photoprism from "./photoprism/widget";
import proxmoxbackupserver from "./proxmoxbackupserver/widget";
-import pialert from "./pialert/widget";
import pihole from "./pihole/widget";
+import plantit from "./plantit/widget";
import plex from "./plex/widget";
import portainer from "./portainer/widget";
import prometheus from "./prometheus/widget";
@@ -84,8 +92,10 @@ import sabnzbd from "./sabnzbd/widget";
import scrutiny from "./scrutiny/widget";
import sonarr from "./sonarr/widget";
import speedtest from "./speedtest/widget";
+import stash from "./stash/widget";
import strelaysrv from "./strelaysrv/widget";
import tailscale from "./tailscale/widget";
+import tandoor from "./tandoor/widget";
import tautulli from "./tautulli/widget";
import tdarr from "./tdarr/widget";
import traefik from "./traefik/widget";
@@ -100,6 +110,7 @@ import watchtower from "./watchtower/widget";
import whatsupdocker from "./whatsupdocker/widget";
import xteve from "./xteve/widget";
import urbackup from "./urbackup/widget";
+import romm from "./romm/widget";
const widgets = {
adguard,
@@ -115,24 +126,29 @@ const widgets = {
channelsdvrserver,
cloudflared,
coinmarketcap,
+ crowdsec,
customapi,
deluge,
diskstation,
downloadstation,
emby,
+ esphome,
evcc,
fileflows,
flood,
freshrss,
fritzbox,
gamedig,
+ gatus,
ghostfolio,
+ gitea,
glances,
gluetun,
gotify,
grafana,
hdhomerun,
homeassistant,
+ homebox,
homebridge,
healthchecks,
ical: calendar,
@@ -155,6 +171,8 @@ const widgets = {
moonraker,
mylar,
navidrome,
+ netalertx,
+ netdata,
nextcloud,
nextdns,
npm,
@@ -166,13 +184,15 @@ const widgets = {
opnsense,
overseerr,
openmediavault,
+ openwrt,
paperlessngx,
peanut,
pfsense,
photoprism,
proxmoxbackupserver,
- pialert,
+ pialert: netalertx,
pihole,
+ plantit,
plex,
portainer,
prometheus,
@@ -184,13 +204,16 @@ const widgets = {
qnap,
radarr,
readarr,
+ romm,
rutorrent,
sabnzbd,
scrutiny,
sonarr,
speedtest,
+ stash,
strelaysrv,
tailscale,
+ tandoor,
tautulli,
tdarr,
traefik,