Merge branch 'main' into kubernetes

This commit is contained in:
James Wynn
2022-12-08 09:57:51 -06:00
81 changed files with 2717 additions and 342 deletions

View File

@@ -107,18 +107,19 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
function highlightText(text) {
const parts = text.split(new RegExp(`(${searchString})`, 'gi'));
return <span>{parts.map(part => part.toLowerCase() === searchString.toLowerCase() ? <span className="bg-theme-300/10">{part}</span> : part)}</span>;
// eslint-disable-next-line react/no-array-index-key
return <span>{parts.map((part, i) => part.toLowerCase() === searchString.toLowerCase() ? <span key={`${searchString}_${i}`} className="bg-theme-300/10">{part}</span> : part)}</span>;
}
return (
<div className={classNames(
"relative z-10 ease-in-out duration-300 transition-opacity",
"relative z-20 ease-in-out duration-300 transition-opacity",
hidden && !isOpen && "hidden",
!hidden && isOpen && "opacity-100",
!isOpen && "opacity-0",
)} role="dialog" aria-modal="true">
<div className="fixed inset-0 bg-gray-500 bg-opacity-50" />
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full min-w-full items-start justify-center text-center">
<dialog className="mt-[10%] min-w-[80%] max-w-[90%] md:min-w-[40%] rounded-md p-0 block font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-50 dark:bg-theme-800">
<input placeholder="Search" className={classNames(
@@ -147,7 +148,7 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
}
</div>
</div>
<div className="text-xs text-theme-600 font-bold pointer-events-none">{r.abbr ? t("quicklaunch.bookmark") : t("quicklaunch.service")}</div>
<div className="text-xs text-theme-600 font-bold pointer-events-none">{r.type === 'service' ? t("quicklaunch.service") : t("quicklaunch.bookmark")}</div>
</button>
</li>
))}

View File

@@ -3,6 +3,7 @@ import { useContext, useState } from "react";
import Status from "./status";
import Widget from "./widget";
import Ping from "./ping";
import KubernetesStatus from "./kubernetes-status";
import Docker from "widgets/docker/component";
@@ -32,7 +33,7 @@ export default function Item({ service }) {
<div
className={`${
hasLink ? "cursor-pointer " : " "
}transition-all h-15 mb-3 p-1 rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10`}
}transition-all h-15 mb-3 p-1 rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10 relative`}
>
<div className="flex select-none">
{service.icon &&
@@ -72,26 +73,35 @@ export default function Item({ service }) {
</div>
)}
{service.container && (
<button
type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
className="flex-shrink-0 flex items-center justify-center w-12 cursor-pointer"
>
<Status service={service} />
<span className="sr-only">View container stats</span>
</button>
)}
{service.app && (
<button
type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
className="flex-shrink-0 flex items-center justify-center w-12 cursor-pointer"
>
<KubernetesStatus service={service} />
<span className="sr-only">View container stats</span>
</button>
)}
<div className="absolute top-0 right-0 w-1/2 flex flex-row justify-end gap-2 mr-2">
{service.ping && (
<div className="flex-shrink-0 flex items-center justify-center cursor-pointer">
<Ping service={service} />
<span className="sr-only">Ping status</span>
</div>
)}
{service.container && (
<button
type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
className="flex-shrink-0 flex items-center justify-center cursor-pointer"
>
<Status service={service} />
<span className="sr-only">View container stats</span>
</button>
)}
{service.app && (
<button
type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
className="flex-shrink-0 flex items-center justify-center w-12 cursor-pointer"
>
<KubernetesStatus service={service} />
<span className="sr-only">View container stats</span>
</button>
)}
</div>
</div>
{service.container && service.server && (

View File

@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import useSWR from "swr";
export default function Ping({ service }) {
const { t } = useTranslation();
const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ping: service.ping}).toString()}`, {
refreshInterval: 30000
});
if (error) {
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden">
<div className="text-[8px] font-bold text-rose-500 uppercase">{t("ping.error")}</div>
</div>
);
}
if (!data) {
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden">
<div className="text-[8px] font-bold text-black/20 dark:text-white/40 uppercase">{t("ping.ping")}</div>
</div>
);
}
const statusText = `${service.ping}: HTTP status ${data.status}`;
if (data && data.status !== 200) {
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={statusText}>
<div className="text-[8px] font-bold text-rose-500/80">{data.status}</div>
</div>
);
}
if (data && data.status === 200) {
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={statusText}>
<div className="text-[8px] font-bold text-emerald-500/80">{t("common.ms", { value: data.latency, style: "unit", unit: "millisecond", unitDisplay: "narrow", maximumFractionDigits: 0 })}</div>
</div>
);
}
}

View File

@@ -1,19 +1,52 @@
import { useTranslation } from "react-i18next";
import useSWR from "swr";
export default function Status({ service }) {
const { t } = useTranslation();
const { data, error } = useSWR(`/api/docker/status/${service.container}/${service.server || ""}`);
if (error) {
return <div className="w-3 h-3 bg-rose-300 dark:bg-rose-500 rounded-full" />;
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.status}>
<div className="text-[8px] font-bold text-rose-500/80 uppercase">{t("docker.error")}</div>
</div>
}
if (data && data.status === "running") {
return <div className="w-3 h-3 bg-emerald-300 dark:bg-emerald-500 rounded-full" />;
if (data.health === "starting") {
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.health}>
<div className="text-[8px] font-bold text-blue-500/80 uppercase">{data.health}</div>
</div>
);
}
if (data.health === "unhealthy") {
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.health}>
<div className="text-[8px] font-bold text-orange-400/50 dark:text-orange-400/80 uppercase">{data.health}</div>
</div>
);
}
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.health ?? data.status}>
<div className="text-[8px] font-bold text-emerald-500/80 uppercase">{data.health ?? data.status}</div>
</div>
);
}
if (data && data.status === "not found") {
return <div className="h-2.5 w-2.5 bg-orange-400/50 dark:bg-yellow-200/40 -rotate-45" />;
if (data && (data.status === "not found" || data.status === "exited")) {
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.status}>
<div className="text-[8px] font-bold text-orange-400/50 dark:text-orange-400/80 uppercase">{data.status}</div>
</div>
);
}
return <div className="w-3 h-3 bg-black/20 dark:bg-white/40 rounded-full" />;
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden">
<div className="text-[8px] font-bold text-black/20 dark:text-white/40 uppercase">{t("docker.unknown")}</div>
</div>
);
}

View File

@@ -8,9 +8,9 @@ import cachedFetch from "utils/proxy/cached-fetch";
export default function Version() {
const { t, i18n } = useTranslation();
const buildTime = process.env.NEXT_PUBLIC_BUILDTIME ?? new Date().toISOString();
const revision = process.env.NEXT_PUBLIC_REVISION ?? "dev";
const version = process.env.NEXT_PUBLIC_VERSION ?? "dev";
const buildTime = process.env.NEXT_PUBLIC_BUILDTIME?.length ? process.env.NEXT_PUBLIC_BUILDTIME : new Date().toISOString();
const revision = process.env.NEXT_PUBLIC_REVISION?.length ? process.env.NEXT_PUBLIC_REVISION : "dev";
const version = process.env.NEXT_PUBLIC_VERSION?.length ? process.env.NEXT_PUBLIC_VERSION : "dev";
const cachedFetcher = (resource) => cachedFetch(resource, 5).then((res) => res.json());
@@ -36,17 +36,14 @@ export default function Version() {
{version} ({revision.substring(0, 7)}, {formatDate(buildTime)})
</>
) : (
releaseData &&
compareVersions(latestRelease.tag_name, version) > 0 && (
<a
href={`https://github.com/benphelps/homepage/releases/tag/${version}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-xs text-theme-500 dark:text-theme-400 flex flex-row items-center"
>
{version} ({revision.substring(0, 7)}, {formatDate(buildTime)})
</a>
)
<a
href={`https://github.com/benphelps/homepage/releases/tag/${version}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-xs text-theme-500 dark:text-theme-400 flex flex-row items-center"
>
{version} ({revision.substring(0, 7)}, {formatDate(buildTime)})
</a>
)}
</span>
{version === "main" || version === "dev" || version === "nightly"

View File

@@ -15,22 +15,21 @@ const textSizes = {
export default function DateTime({ options }) {
const { text_size: textSize, format } = options;
const { i18n } = useTranslation();
const [date, setDate] = useState(new Date());
const [date, setDate] = useState("");
useEffect(() => {
const dateFormat = new Intl.DateTimeFormat(i18n.language, { ...format });
const interval = setInterval(() => {
setDate(new Date());
setDate(dateFormat.format(new Date()));
}, 1000);
return () => clearInterval(interval);
}, [setDate]);
const dateFormat = new Intl.DateTimeFormat(i18n.language, { ...format });
}, [date, setDate, i18n.language, format]);
return (
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center grow justify-end">
<span className={`text-theme-800 dark:text-theme-200 ${textSizes[textSize || "lg"]}`}>
{dateFormat.format(date)}
{date}
</span>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import mapIcon from "utils/weather/owm-condition-map";
import mapIcon from "utils/weather/openmeteo-condition-map";
export default function Icon({ condition, timeOfDay }) {
const IconComponent = mapIcon(condition, timeOfDay);

View File

@@ -52,7 +52,7 @@ export default function Memory({ expanded }) {
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{t("common.bytes", { value: data.memory.freeMemMb * 1024 * 1024, maximumFractionDigits: 0, binary: true })}
{t("common.bytes", { value: data.memory.freeMemMb * 1024 * 1024, maximumFractionDigits: 1, binary: true })}
</div>
<div className="pr-1">{t("resources.free")}</div>
</span>
@@ -61,7 +61,7 @@ export default function Memory({ expanded }) {
<div className="pl-0.5">
{t("common.bytes", {
value: data.memory.totalMemMb * 1024 * 1024,
maximumFractionDigits: 0,
maximumFractionDigits: 1,
binary: true,
})}
</div>

View File

@@ -40,6 +40,7 @@ export default async function handler(req, res) {
return res.status(200).json({
status: info.State.Status,
health: info.State.Health?.Status
});
} catch {
return res.status(500).send({

35
src/pages/api/ping.js Normal file
View File

@@ -0,0 +1,35 @@
import { performance } from "perf_hooks";
import createLogger from "utils/logger";
import { httpProxy } from "utils/proxy/http";
const logger = createLogger("ping");
export default async function handler(req, res) {
const { ping: pingURL } = req.query;
if (!pingURL) {
logger.debug("No ping URL specified");
return res.status(400).send({
error: "No ping URL given",
});
}
let startTime = performance.now();
let [status] = await httpProxy(pingURL, {
method: "HEAD"
});
let endTime = performance.now();
if (status >= 400) {
// try one more time as a GET in case HEAD is rejected for whatever reason
startTime = performance.now();
[status] = await httpProxy(pingURL);
endTime = performance.now();
}
return res.status(200).json({
status,
latency: endTime - startTime
});
}

View File

@@ -1,8 +1,9 @@
import cachedFetch from "utils/proxy/cached-fetch";
export default async function handler(req, res) {
const { latitude, longitude, units, cache } = req.query;
const { latitude, longitude, units, cache, timezone } = req.query;
const degrees = units === "imperial" ? "fahrenheit" : "celsius";
const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=sunrise,sunset&current_weather=true&temperature_unit=${degrees}&timezone=auto`;
const timezeone = timezone ?? 'auto'
const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=sunrise,sunset&current_weather=true&temperature_unit=${degrees}&timezone=${timezeone}`;
return res.send(await cachedFetch(apiUrl, cache));
}

View File

@@ -94,7 +94,8 @@ export async function servicesResponse() {
].flat()),
];
const mergedGroups = [];
const sortedGroups = [];
const unsortedGroups = [];
const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;
mergedGroupsNames.forEach((groupName) => {
@@ -113,12 +114,12 @@ export async function servicesResponse() {
if (definedLayouts) {
const layoutIndex = definedLayouts.findIndex(layout => layout === mergedGroup.name);
if (layoutIndex > -1) mergedGroups.splice(layoutIndex, 0, mergedGroup);
else mergedGroups.push(mergedGroup);
if (layoutIndex > -1) sortedGroups[layoutIndex] = mergedGroup;
else unsortedGroups.push(mergedGroup);
} else {
mergedGroups.push(mergedGroup);
unsortedGroups.push(mergedGroup);
}
});
return mergedGroups;
return [...sortedGroups.filter(g => g), ...unsortedGroups];
}

View File

@@ -32,5 +32,5 @@ export function getSettings() {
const settingsYaml = join(process.cwd(), "config", "settings.yaml");
const fileContents = readFileSync(settingsYaml, "utf8");
return yaml.load(fileContents);
return yaml.load(fileContents) ?? {};
}

View File

@@ -202,6 +202,7 @@ export function cleanServiceGroups(groups) {
container,
currency, // coinmarketcap widget
symbols,
defaultinterval
namespace, // kubernetes widget
app
} = cleanedService.widget;
@@ -215,6 +216,7 @@ export function cleanServiceGroups(groups) {
if (currency) cleanedService.widget.currency = currency;
if (symbols) cleanedService.widget.symbols = symbols;
if (defaultinterval) cleanedService.widget.defaultinterval = defaultinterval;
if (type === "docker") {
if (server) cleanedService.widget.server = server;
@@ -265,4 +267,4 @@ export default async function getServiceWidget(group, service) {
}
return false;
}
}

View File

@@ -4,10 +4,15 @@ import { format as utilFormat } from "node:util";
import winston from "winston";
import checkAndCopyConfig, { getSettings } from "utils/config/config";
let winstonLogger;
function init() {
const configPath = join(process.cwd(), "config");
checkAndCopyConfig("settings.yaml");
const settings = getSettings();
const logpath = settings.logpath || configPath;
function combineMessageAndSplat() {
return {
@@ -57,7 +62,7 @@ function init() {
winston.format.timestamp(),
winston.format.printf(messageFormatter)
),
filename: `${configPath}/logs/homepage.log`,
filename: `${logpath}/logs/homepage.log`,
handleExceptions: true,
handleRejections: true,
}),

View File

@@ -0,0 +1,82 @@
import { JSONRPCClient, JSONRPCErrorException } from "json-rpc-2.0";
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";
import widgets from "widgets/widgets";
const logger = createLogger("jsonrpcProxyHandler");
export async function sendJsonRpcRequest(url, method, params, username, password) {
const headers = {
"content-type": "application/json",
"accept": "application/json"
}
if (username && password) {
headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
const client = new JSONRPCClient(async (rpcRequest) => {
const body = JSON.stringify(rpcRequest);
const httpRequestParams = {
method: "POST",
headers,
body
};
// eslint-disable-next-line no-unused-vars
const [status, contentType, data] = await httpProxy(url, httpRequestParams);
if (status === 200) {
const json = JSON.parse(data.toString());
// in order to get access to the underlying error object in the JSON response
// you must set `result` equal to undefined
if (json.error && (json.result === null)) {
json.result = undefined;
}
return client.receive(json);
}
return Promise.reject(data?.error ? data : new Error(data.toString()));
});
try {
const response = await client.request(method, params);
return [200, "application/json", JSON.stringify(response)];
}
catch (e) {
if (e instanceof JSONRPCErrorException) {
logger.debug("Error calling JSONPRC endpoint: %s. %s", url, e.message);
return [200, "application/json", JSON.stringify({result: null, error: {code: e.code, message: e.message}})];
}
logger.warn("Error calling JSONPRC endpoint: %s. %s", url, e);
return [500, "application/json", JSON.stringify({result: null, error: {code: 2, message: e.toString()}})];
}
}
export default async function jsonrpcProxyHandler(req, res) {
const { group, service, endpoint: method } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
const api = widgets?.[widget.type]?.api;
if (!api) {
return res.status(403).json({ error: "Service does not support API calls" });
}
if (widget) {
const url = formatApiCall(api, { ...widget });
// eslint-disable-next-line no-unused-vars
const [status, contentType, data] = await sendJsonRpcRequest(url, method, null, widget.username, widget.password);
return res.status(status).end(data);
}
}
logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}

View File

@@ -18,10 +18,15 @@ function addCookieHandler(url, params) {
};
}
export function httpsRequest(url, params) {
function handleRequest(requestor, url, params) {
return new Promise((resolve, reject) => {
addCookieHandler(url, params);
const request = https.request(url, params, (response) => {
if (params?.body) {
params.headers = params.headers ?? {};
params.headers['content-length'] = Buffer.byteLength(params.body);
}
const request = requestor.request(url, params, (response) => {
const data = [];
response.on("data", (chunk) => {
@@ -38,7 +43,7 @@ export function httpsRequest(url, params) {
reject([500, error]);
});
if (params.body) {
if (params?.body) {
request.write(params.body);
}
@@ -46,32 +51,12 @@ export function httpsRequest(url, params) {
});
}
export function httpsRequest(url, params) {
return handleRequest(https, url, params);
}
export function httpRequest(url, params) {
return new Promise((resolve, reject) => {
addCookieHandler(url, params);
const request = http.request(url, params, (response) => {
const data = [];
response.on("data", (chunk) => {
data.push(chunk);
});
response.on("end", () => {
addCookieToJar(url, response.headers);
resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]);
});
});
request.on("error", (error) => {
reject([500, error]);
});
if (params.body) {
request.write(params.body);
}
request.end();
});
return handleRequest(http, url, params);
}
export async function httpProxy(url, params = {}) {
@@ -96,7 +81,7 @@ export async function httpProxy(url, params = {}) {
return [status, contentType, data, responseHeaders];
}
catch (err) {
logger.error("Error calling %s//%s%s...", url.protocol, url.hostname, url.pathname);
logger.error("Error calling %s//%s%s...", constructedUrl.protocol, constructedUrl.hostname, constructedUrl.pathname);
logger.error(err);
return [500, "application/json", { error: {message: err?.message ?? "Unknown error", url, rawError: err} }, null];
}

View File

@@ -0,0 +1,211 @@
import * as Icons from "react-icons/wi";
// see https://open-meteo.com/en/docs
const conditions = [
{
code: 1,
icon: {
day: Icons.WiDayCloudy,
night: Icons.WiNightAltCloudy,
},
},
{
code: 2,
icon: {
day: Icons.WiDayCloudy,
night: Icons.WiNightAltCloudy,
},
},
{
code: 3,
icon: {
day: Icons.WiDayCloudy,
night: Icons.WiNightAltCloudy,
},
},
{
code: 45,
icon: {
day: Icons.WiDayFog,
night: Icons.WiNightFog,
},
},
{
code: 48,
icon: {
day: Icons.WiDayFog,
night: Icons.WiNightFog,
},
},
{
code: 51,
icon: {
day: Icons.WiDaySprinkle,
night: Icons.WiNightAltSprinkle,
},
},
{
code: 53,
icon: {
day: Icons.WiDaySprinkle,
night: Icons.WiNightAltSprinkle,
},
},
{
code: 55,
icon: {
day: Icons.WiDaySprinkle,
night: Icons.WiNightAltSprinkle,
},
},
{
code: 56,
icon: {
day: Icons.WiDaySleet,
night: Icons.WiNightAltSleet,
},
},
{
code: 57,
icon: {
day: Icons.WiDaySleet,
night: Icons.WiNightAltSleet,
},
},
{
code: 61,
icon: {
day: Icons.WiDayShowers,
night: Icons.WiNightAltShowers,
},
},
{
code: 63,
icon: {
day: Icons.WiDayShowers,
night: Icons.WiNightAltShowers,
},
},
{
code: 65,
icon: {
day: Icons.WiDayShowers,
night: Icons.WiNightAltShowers,
},
},
{
code: 66,
icon: {
day: Icons.WiDaySleet,
night: Icons.WiNightAltSleet,
},
},
{
code: 67,
icon: {
day: Icons.WiDaySleet,
night: Icons.WiNightAltSleet,
},
},
{
code: 71,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 73,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 75,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 77,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 80,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 81,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 82,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 85,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 86,
icon: {
day: Icons.WiDaySnow,
night: Icons.WiNightAltSnow,
},
},
{
code: 95,
icon: {
day: Icons.WiDayThunderstorm,
night: Icons.WiNightAltThunderstorm,
},
},
{
code: 96,
icon: {
day: Icons.WiDayThunderstorm,
night: Icons.WiNightAltThunderstorm,
},
},
{
code: 99,
icon: {
day: Icons.WiDayThunderstorm,
night: Icons.WiNightAltThunderstorm,
},
},
];
export default function mapIcon(weatherStatusCode, timeOfDay) {
const mapping = conditions.find((condition) => condition.code === weatherStatusCode);
if (mapping) {
if (timeOfDay === "day") {
return mapping.icon.day;
}
if (timeOfDay === "night") {
return mapping.icon.night;
}
}
return Icons.WiDaySunny;
}

View File

@@ -14,6 +14,11 @@ export default function Component({ service }) {
if (error) {
return <Container error={error} />;
}
if (!data) {
return <Container service={service} />;
}
const totalObserved = Object.keys(data).length;
let diffsDetected = 0;

View File

@@ -17,11 +17,12 @@ export default function Component({ service }) {
{ label: t("coinmarketcap.30days"), value: "30d" },
];
const [dateRange, setDateRange] = useState(dateRangeOptions[0].value);
const { widget } = service;
const { symbols } = widget;
const currencyCode = widget.currency ?? "USD";
const interval = widget.defaultinterval ?? dateRangeOptions[0].value;
const [dateRange, setDateRange] = useState(interval);
const { data: statsData, error: statsError } = useWidgetAPI(widget, "v1/cryptocurrency/quotes/latest", {
symbol: `${symbols.join(",")}`,

View File

@@ -7,9 +7,12 @@ const components = {
bazarr: dynamic(() => import("./bazarr/component")),
changedetectionio: dynamic(() => import("./changedetectionio/component")),
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
deluge: dynamic(() => import("./deluge/component")),
diskstation: dynamic(() => import("./diskstation/component")),
docker: dynamic(() => import("./docker/component")),
kubernetes: dynamic(() => import("./kubernetes/component")),
emby: dynamic(() => import("./emby/component")),
flood: dynamic(() => import("./flood/component")),
gluetun: dynamic(() => import("./gluetun/component")),
gotify: dynamic(() => import("./gotify/component")),
hdhomerun: dynamic(() => import("./hdhomerun/component")),
@@ -24,6 +27,7 @@ const components = {
nzbget: dynamic(() => import("./nzbget/component")),
ombi: dynamic(() => import("./ombi/component")),
overseerr: dynamic(() => import("./overseerr/component")),
paperlessngx: dynamic(() => import("./paperlessngx/component")),
pihole: dynamic(() => import("./pihole/component")),
plex: dynamic(() => import("./plex/component")),
portainer: dynamic(() => import("./portainer/component")),
@@ -35,6 +39,7 @@ const components = {
readarr: dynamic(() => import("./readarr/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")),
strelaysrv: dynamic(() => import("./strelaysrv/component")),

View File

@@ -0,0 +1,52 @@
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: torrentData, error: torrentError } = useWidgetAPI(widget);
if (torrentError) {
return <Container error={torrentError} />;
}
if (!torrentData) {
return (
<Container service={service}>
<Block label="deluge.leech" />
<Block label="deluge.download" />
<Block label="deluge.seed" />
<Block label="deluge.upload" />
</Container>
);
}
const { torrents } = torrentData;
const keys = torrents ? Object.keys(torrents) : [];
let rateDl = 0;
let rateUl = 0;
let completed = 0;
for (let i = 0; i < keys.length; i += 1) {
const torrent = torrents[keys[i]];
rateDl += torrent.download_payload_rate;
rateUl += torrent.upload_payload_rate;
completed += torrent.total_remaining === 0 ? 1 : 0;
}
const leech = keys.length - completed || 0;
return (
<Container service={service}>
<Block label="deluge.leech" value={t("common.number", { value: leech })} />
<Block label="deluge.download" value={t("common.bitrate", { value: rateDl })} />
<Block label="deluge.seed" value={t("common.number", { value: completed })} />
<Block label="deluge.upload" value={t("common.bitrate", { value: rateUl })} />
</Container>
);
}

View File

@@ -0,0 +1,63 @@
import { formatApiCall } from "utils/proxy/api-helpers";
import { sendJsonRpcRequest } from "utils/proxy/handlers/jsonrpc";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import widgets from "widgets/widgets";
const logger = createLogger("delugeProxyHandler");
const dataMethod = "web.update_ui";
const dataParams = [
["queue", "name", "total_wanted", "state", "progress", "download_payload_rate", "upload_payload_rate", "total_remaining"],
{}
];
const loginMethod = "auth.login";
async function sendRpc(url, method, params) {
const [status, contentType, data] = await sendJsonRpcRequest(url, method, params);
const json = JSON.parse(data.toString());
if (json?.error) {
if (json.error.code === 1) {
return [403, contentType, data];
}
return [500, contentType, data];
}
return [status, contentType, data];
}
function login(url, password) {
return sendRpc(url, loginMethod, [password]);
}
export default async function delugeProxyHandler(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" });
}
const api = widgets?.[widget.type]?.api
const url = new URL(formatApiCall(api, { ...widget }));
let [status, contentType, data] = await sendRpc(url, dataMethod, dataParams);
if (status === 403) {
[status, contentType, data] = await login(url, widget.password);
if (status !== 200) {
return res.status(status).end(data);
}
// eslint-disable-next-line no-unused-vars
[status, contentType, data] = await sendRpc(url, dataMethod, dataParams);
}
return res.status(status).end(data);
}

View File

@@ -0,0 +1,8 @@
import delugeProxyHandler from "./proxy";
const widget = {
api: "{url}/json",
proxyHandler: delugeProxyHandler,
};
export default widget;

View File

@@ -0,0 +1,41 @@
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: listData, error: listError } = useWidgetAPI(widget, "list");
if (listError) {
return <Container error={listError} />;
}
const tasks = listData?.data?.tasks;
if (!tasks) {
return (
<Container service={service}>
<Block label="diskstation.leech" />
<Block label="diskstation.download" />
<Block label="diskstation.seed" />
<Block label="diskstation.upload" />
</Container>
);
}
const rateDl = tasks.reduce((acc, task) => acc + (task?.additional?.transfer?.speed_download ?? 0), 0);
const rateUl = tasks.reduce((acc, task) => acc + (task?.additional?.transfer?.speed_upload ?? 0), 0);
const completed = tasks.filter((task) => task?.additional?.transfer?.size_downloaded === task?.size)?.length || 0;
const leech = tasks.length - completed || 0;
return (
<Container service={service}>
<Block label="diskstation.leech" value={t("common.number", { value: leech })} />
<Block label="diskstation.download" value={t("common.bitrate", { value: rateDl })} />
<Block label="diskstation.seed" value={t("common.number", { value: completed })} />
<Block label="diskstation.upload" value={t("common.bitrate", { value: rateUl })} />
</Container>
);
}

View File

@@ -0,0 +1,70 @@
import { formatApiCall } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
import createLogger from "utils/logger";
import widgets from "widgets/widgets";
import getServiceWidget from "utils/config/service-helpers";
const logger = createLogger("diskstationProxyHandler");
const authApi = "{url}/webapi/auth.cgi?api=SYNO.API.Auth&version=2&method=login&account={username}&passwd={password}&session=DownloadStation&format=cookie"
async function login(widget) {
const loginUrl = formatApiCall(authApi, widget);
const [status, contentType, data] = await httpProxy(loginUrl);
if (status !== 200) {
return [status, contentType, data];
}
const json = JSON.parse(data.toString());
if (json?.success !== true) {
// from https://global.download.synology.com/download/Document/Software/DeveloperGuide/Package/DownloadStation/All/enu/Synology_Download_Station_Web_API.pdf
/*
Code Description
400 No such account or incorrect password
401 Account disabled
402 Permission denied
403 2-step verification code required
404 Failed to authenticate 2-step verification code
*/
let message = "Authentication failed.";
if (json?.error?.code >= 403) message += " 2FA enabled.";
logger.warn("Unable to login. Code: %d", json?.error?.code);
return [401, "application/json", JSON.stringify({ code: json?.error?.code, message })];
}
return [status, contentType, data];
}
export default async function diskstationProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
if (!group || !service) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
const widget = await getServiceWidget(group, service);
const api = widgets?.[widget.type]?.api;
if (!api) {
return res.status(403).json({ error: "Service does not support API calls" });
}
const url = formatApiCall(api, { endpoint, ...widget });
let [status, contentType, data] = await httpProxy(url);
if (status !== 200) {
logger.debug("Error %d calling endpoint %s", status, url);
return res.status(status, data);
}
const json = JSON.parse(data.toString());
if (json?.success !== true) {
logger.debug("Logging in to DiskStation");
[status, contentType, data] = await login(widget);
if (status !== 200) {
return res.status(status).end(data)
}
[status, contentType, data] = await httpProxy(url);
}
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
}

View File

@@ -0,0 +1,14 @@
import diskstationProxyHandler from "./proxy";
const widget = {
api: "{url}/webapi/DownloadStation/task.cgi?api=SYNO.DownloadStation.Task&version=1&method={endpoint}",
proxyHandler: diskstationProxyHandler,
mappings: {
"list": {
endpoint: "list&additional=transfer",
},
},
};
export default widget;

View File

@@ -46,7 +46,9 @@ export default function Component({ service }) {
return (
<Container service={service}>
<Block label="docker.cpu" value={t("common.percent", { value: calculateCPUPercent(statsData.stats) })} />
<Block label="docker.mem" value={t("common.bytes", { value: statsData.stats.memory_stats.usage })} />
{statsData.stats.memory_stats.usage &&
<Block label="docker.mem" value={t("common.bytes", { value: statsData.stats.memory_stats.usage })} />
}
{network && (
<>
<Block label="docker.rx" value={t("common.bytes", { value: network.rx_bytes })} />

View File

@@ -0,0 +1,53 @@
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: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents");
if (torrentError || !torrentData?.torrents) {
return <Container error={torrentError ?? {message: "No torrent data returned"}} />;
}
if (!torrentData || !torrentData.torrents) {
return (
<Container service={service}>
<Block label="flood.leech" />
<Block label="flood.download" />
<Block label="flood.seed" />
<Block label="flood.upload" />
</Container>
);
}
let rateDl = 0;
let rateUl = 0;
let completed = 0;
let leech = 0;
Object.values(torrentData.torrents).forEach(torrent => {
rateDl += torrent.downRate;
rateUl += torrent.upRate;
if(torrent.status.includes('complete')){
completed += 1;
}
if(torrent.status.includes('downloading')){
leech += 1;
}
})
return (
<Container service={service}>
<Block label="flood.leech" value={t("common.number", { value: leech })} />
<Block label="flood.download" value={t("common.bitrate", { value: rateDl })} />
<Block label="flood.seed" value={t("common.number", { value: completed })} />
<Block label="flood.upload" value={t("common.bitrate", { value: rateUl })} />
</Container>
);
}

View File

@@ -0,0 +1,66 @@
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 logger = createLogger("floodProxyHandler");
async function login(widget) {
logger.debug("flood is rejecting the request, logging in.");
const loginUrl = new URL(`${widget.url}/api/auth/authenticate`).toString();
const loginParams = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: null
};
if (widget.username && widget.password) {
loginParams.body = JSON.stringify({
"username": widget.username,
"password": widget.password
});
}
// eslint-disable-next-line no-unused-vars
const [status, contentType, data] = await httpProxy(loginUrl, loginParams);
return [status, data];
}
export default async function floodProxyHandler(req, res) {
const { group, service, endpoint } = 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" });
}
const url = new URL(formatApiCall("{url}/api/{endpoint}", { endpoint, ...widget }));
const params = { method: "GET", headers: {} };
let [status, contentType, data] = await httpProxy(url, params);
if (status === 401) {
[status, data] = await login(widget);
if (status !== 200) {
logger.error("HTTP %d logging in to flood. Data: %s", status, data);
return res.status(status).end(data);
}
[status, contentType, data] = await httpProxy(url, params);
}
if (status !== 200) {
logger.error("HTTP %d getting data from flood. Data: %s", status, data);
}
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
}

View File

@@ -0,0 +1,7 @@
import floodProxyHandler from "./proxy";
const widget = {
proxyHandler: floodProxyHandler,
};
export default widget;

View File

@@ -20,12 +20,18 @@ async function login(loginUrl, username, password) {
});
const status = authResponse[0];
const data = JSON.parse(Buffer.from(authResponse[2]).toString());
let data = authResponse[2];
if (status === 200) {
cache.put(tokenCacheKey, data.token);
try {
data = JSON.parse(Buffer.from(authResponse[2]).toString());
if (status === 200) {
const expiration = new Date(data.expires) - Date.now();
cache.put(tokenCacheKey, data.token, expiration - (5 * 60 * 1000)); // expiration -5 minutes
}
} catch (e) {
logger.error(`Error ${status} logging into npm`, authResponse[2]);
}
return [status, data.token ?? data];
}
@@ -51,8 +57,8 @@ export default async function npmProxyHandler(req, res) {
if (!token) {
[status, token] = await login(loginUrl, widget.username, widget.password);
if (status !== 200) {
logger.debug(`HTTTP ${status} logging into npm api: ${data}`);
return res.status(status).send(data);
logger.debug(`HTTTP ${status} logging into npm api: ${token}`);
return res.status(status).send(token);
}
}

View File

@@ -1,40 +0,0 @@
import { JSONRPCClient } from "json-rpc-2.0";
import getServiceWidget from "utils/config/service-helpers";
export default async function nzbgetProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service);
if (widget) {
const constructedUrl = new URL(widget.url);
constructedUrl.pathname = "jsonrpc";
const authorization = Buffer.from(`${widget.username}:${widget.password}`).toString("base64");
const client = new JSONRPCClient((jsonRPCRequest) =>
fetch(constructedUrl.toString(), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Basic ${authorization}`,
},
body: JSON.stringify(jsonRPCRequest),
}).then(async (response) => {
if (response.status === 200) {
const jsonRPCResponse = await response.json();
return client.receive(jsonRPCResponse);
}
return Promise.reject(new Error(response.statusText));
})
);
return res.send(await client.request(endpoint));
}
}
return res.status(400).json({ error: "Invalid proxy service type" });
}

View File

@@ -1,7 +1,8 @@
import nzbgetProxyHandler from "./proxy";
import jsonrpcProxyHandler from "utils/proxy/handlers/jsonrpc";
const widget = {
proxyHandler: nzbgetProxyHandler,
api: "{url}/jsonrpc",
proxyHandler: jsonrpcProxyHandler,
};
export default widget;

View File

@@ -15,6 +15,7 @@ export default function Component({ service }) {
return (
<Container service={service}>
<Block label="overseerr.pending" />
<Block label="overseerr.processing" />
<Block label="overseerr.approved" />
<Block label="overseerr.available" />
</Container>
@@ -24,6 +25,7 @@ export default function Component({ service }) {
return (
<Container service={service}>
<Block label="overseerr.pending" value={statsData.pending} />
<Block label="overseerr.processing" value={statsData.processing} />
<Block label="overseerr.approved" value={statsData.approved} />
<Block label="overseerr.available" value={statsData.available} />
</Container>

View File

@@ -9,6 +9,7 @@ const widget = {
endpoint: "request/count",
validate: [
"pending",
"processing",
"approved",
"available",
],

View File

@@ -0,0 +1,29 @@
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: statisticsData, error: statisticsError } = useWidgetAPI(widget, "statistics");
if (statisticsError) {
return <Container error={statisticsError} />;
}
if (!statisticsData) {
return (
<Container service={service}>
<Block label="paperlessngx.inbox" />
<Block label="paperlessngx.total" />
</Container>
);
}
return (
<Container service={service}>
{statisticsData.documents_inbox !== undefined && <Block label="paperlessngx.inbox" value={statisticsData.documents_inbox} />}
<Block label="paperlessngx.total" value={statisticsData.documents_total} />
</Container>
);
}

View File

@@ -0,0 +1,17 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: genericProxyHandler,
mappings: {
"statistics": {
endpoint: "statistics/?format=json",
validate: [
"documents_total"
]
},
},
};
export default widget;

View File

@@ -1,30 +1,23 @@
import { formatApiCall } from "utils/proxy/api-helpers";
import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar";
import { httpProxy } from "utils/proxy/http";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
const logger = createLogger("qbittorrentProxyHandler");
async function login(widget, params) {
async function login(widget) {
logger.debug("qBittorrent is rejecting the request, logging in.");
const loginUrl = new URL(`${widget.url}/api/v2/auth/login`).toString();
const loginBody = `username=${encodeURI(widget.username)}&password=${encodeURI(widget.password)}`;
// using fetch intentionally, for login only, as the httpProxy method causes qBittorrent to
// complain about header encoding
return fetch(loginUrl, {
const loginParams = {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: loginBody,
})
.then(async (response) => {
addCookieToJar(loginUrl, response.headers);
setCookieHeader(loginUrl, params);
const data = await response.text();
return [response.status, data];
})
.catch((err) => [500, err]);
}
// eslint-disable-next-line no-unused-vars
const [status, contentType, data] = await httpProxy(loginUrl, loginParams);
return [status, data];
}
export default async function qbittorrentProxyHandler(req, res) {
@@ -44,11 +37,10 @@ export default async function qbittorrentProxyHandler(req, res) {
const url = new URL(formatApiCall("{url}/api/v2/{endpoint}", { endpoint, ...widget }));
const params = { method: "GET", headers: {} };
setCookieHeader(url, params);
let [status, contentType, data] = await httpProxy(url, params);
if (status === 403) {
[status, data] = await login(widget, params);
[status, data] = await login(widget);
if (status !== 200) {
logger.error("HTTP %d logging in to qBittorrent. Data: %s", status, data);
@@ -59,9 +51,9 @@ export default async function qbittorrentProxyHandler(req, res) {
logger.error("Error logging in to qBittorrent: Data: %s", data);
return res.status(401).end(data);
}
}
[status, contentType, data] = await httpProxy(url, params);
[status, contentType, data] = await httpProxy(url, params);
}
if (status !== 200) {
logger.error("HTTP %d getting data from qBittorrent. Data: %s", status, data);

View File

@@ -11,9 +11,14 @@ export default async function rutorrentProxyHandler(req, res) {
if (widget) {
const constructedUrl = new URL(widget.url);
let rtPort = constructedUrl.port;
if (rtPort === '') {
rtPort = constructedUrl.protocol === "https:" ? 443 : 80;
}
const rutorrent = new RuTorrent({
host: constructedUrl.hostname,
port: constructedUrl.port,
port: rtPort,
path: constructedUrl.pathname,
ssl: constructedUrl.protocol === "https:",
username: widget.username,

View File

@@ -0,0 +1,62 @@
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
// @see https://github.com/AnalogJ/scrutiny/blob/d8d56f77f9e868127c4849dac74d65512db658e8/webapp/frontend/src/app/shared/device-status.pipe.ts
const DeviceStatus = {
passed: 0,
failed_smart: 1,
failed_scrutiny: 2,
failed_both: 3,
isFailed(s){ return s > this.passed && s <= this.failed_both},
isUnknown(s){ return s < this.passed || s > this.failed_both}
}
// @see https://github.com/AnalogJ/scrutiny/blob/d8d56f77f9e868127c4849dac74d65512db658e8/webapp/frontend/src/app/core/config/app.config.ts
const DeviceStatusThreshold = {
smart: 1,
scrutiny: 2,
both: 3
}
export default function Component({ service }) {
const { widget } = service;
const { data: scrutinySettings, error: scrutinySettingsError } = useWidgetAPI(widget, "settings");
const { data: scrutinyData, error: scrutinyError } = useWidgetAPI(widget, "summary");
if (scrutinyError || scrutinySettingsError) {
const finalError = scrutinyError ?? scrutinySettingsError;
return <Container error={finalError} />;
}
if (!scrutinyData || !scrutinySettings) {
return (
<Container service={service}>
<Block label="scrutiny.passed" />
<Block label="scrutiny.failed" />
<Block label="scrutiny.unknown" />
</Container>
);
}
const deviceIds = Object.values(scrutinyData.data.summary);
const statusThreshold = scrutinySettings.settings.metrics.status_threshold;
const failed = deviceIds.filter(deviceId => (DeviceStatus.isFailed(deviceId.device.device_status) && statusThreshold === DeviceStatusThreshold.both) || [statusThreshold, DeviceStatus.failed_both].includes(deviceId.device.device_status))?.length || 0;
const unknown = deviceIds.filter(deviceId => DeviceStatus.isUnknown(deviceId.device.device_status))?.length || 0;
const passed = deviceIds.length - (failed + unknown);
return (
<Container service={service}>
<Block label="scrutiny.passed" value={passed} />
<Block label="scrutiny.failed" value={failed} />
<Block label="scrutiny.unknown" value={unknown} />
</Container>
);
}

View File

@@ -0,0 +1,23 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: genericProxyHandler,
mappings: {
summary: {
endpoint: "summary",
validate: [
"data",
]
},
settings: {
endpoint: "settings",
validate: [
"settings",
]
}
},
};
export default widget;

View File

@@ -4,7 +4,10 @@ import autobrr from "./autobrr/widget";
import bazarr from "./bazarr/widget";
import changedetectionio from "./changedetectionio/widget";
import coinmarketcap from "./coinmarketcap/widget";
import deluge from "./deluge/widget";
import diskstation from "./diskstation/widget";
import emby from "./emby/widget";
import flood from "./flood/widget";
import gluetun from "./gluetun/widget";
import gotify from "./gotify/widget";
import hdhomerun from "./hdhomerun/widget";
@@ -18,6 +21,7 @@ import npm from "./npm/widget";
import nzbget from "./nzbget/widget";
import ombi from "./ombi/widget";
import overseerr from "./overseerr/widget";
import paperlessngx from "./paperlessngx/widget";
import pihole from "./pihole/widget";
import plex from "./plex/widget";
import portainer from "./portainer/widget";
@@ -29,6 +33,7 @@ import radarr from "./radarr/widget";
import readarr from "./readarr/widget";
import rutorrent from "./rutorrent/widget";
import sabnzbd from "./sabnzbd/widget";
import scrutiny from "./scrutiny/widget";
import sonarr from "./sonarr/widget";
import speedtest from "./speedtest/widget";
import strelaysrv from "./strelaysrv/widget";
@@ -47,7 +52,10 @@ const widgets = {
bazarr,
changedetectionio,
coinmarketcap,
deluge,
diskstation,
emby,
flood,
gluetun,
gotify,
hdhomerun,
@@ -62,6 +70,7 @@ const widgets = {
nzbget,
ombi,
overseerr,
paperlessngx,
pihole,
plex,
portainer,
@@ -73,6 +82,7 @@ const widgets = {
readarr,
rutorrent,
sabnzbd,
scrutiny,
sonarr,
speedtest,
strelaysrv,