Merge branch 'main' into glances-fs

This commit is contained in:
Ben Phelps 2023-09-03 17:23:08 +03:00
commit 108ca23212
75 changed files with 2336 additions and 528 deletions

View file

@ -5,44 +5,64 @@ import { MdKeyboardArrowDown } from "react-icons/md";
import ErrorBoundary from "components/errorboundry";
import List from "components/bookmarks/list";
import ResolvedIcon from "components/resolvedicon";
export default function BookmarksGroup({ group, disableCollapse }) {
export default function BookmarksGroup({ bookmarks, layout, disableCollapse }) {
const panel = useRef();
return (
<div key={group.name} className="flex-1">
<Disclosure defaultOpen>
{({ open }) => (
<>
<Disclosure.Button disabled={disableCollapse} className="flex w-full select-none items-center group">
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">{group.name}</h2>
<MdKeyboardArrowDown className={classNames(
disableCollapse ? 'hidden' : '',
'transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl',
open ? '' : 'rotate-90'
)} />
</Disclosure.Button>
<Transition
// Otherwise the transition group does display: none and cancels animation
className="!block"
unmount={false}
beforeLeave={() => {
panel.current.style.height = `${panel.current.scrollHeight}px`;
setTimeout(() => {panel.current.style.height = `0`}, 1);
}}
beforeEnter={() => {
panel.current.style.height = `0px`;
setTimeout(() => {panel.current.style.height = `${panel.current.scrollHeight}px`}, 1);
}}
>
<Disclosure.Panel className="transition-all overflow-hidden duration-300 ease-out" ref={panel} static>
<ErrorBoundary>
<List bookmarks={group.bookmarks} />
</ErrorBoundary>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
<div
key={bookmarks.name}
className={classNames(
layout?.style === "row" ? "basis-full" : "basis-full md:basis-1/4 lg:basis-1/5 xl:basis-1/6",
layout?.header === false ? "flex-1 px-1 -my-1" : "flex-1 p-1"
)}
>
<Disclosure defaultOpen>
{({ open }) => (
<>
{layout?.header !== false && (
<Disclosure.Button disabled={disableCollapse} className="flex w-full select-none items-center group">
{layout?.icon && (
<div className="flex-shrink-0 mr-2 w-7 h-7">
<ResolvedIcon icon={layout.icon} />
</div>
)}
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">{bookmarks.name}</h2>
<MdKeyboardArrowDown
className={classNames(
disableCollapse ? "hidden" : "",
"transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl",
open ? "" : "rotate-180"
)}
/>
</Disclosure.Button>
)}
<Transition
// Otherwise the transition group does display: none and cancels animation
className="!block"
unmount={false}
beforeLeave={() => {
panel.current.style.height = `${panel.current.scrollHeight}px`;
setTimeout(() => {
panel.current.style.height = `0`;
}, 1);
}}
beforeEnter={() => {
panel.current.style.height = `0px`;
setTimeout(() => {
panel.current.style.height = `${panel.current.scrollHeight}px`;
}, 1);
}}
>
<Disclosure.Panel className="transition-all overflow-hidden duration-300 ease-out" ref={panel} static>
<ErrorBoundary>
<List bookmarks={bookmarks.bookmarks} layout={layout} />
</ErrorBoundary>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
</div>
);
}

View file

@ -1,8 +1,17 @@
import classNames from "classnames";
import { columnMap } from "../../utils/layout/columns";
import Item from "components/bookmarks/item";
export default function List({ bookmarks }) {
export default function List({ bookmarks, layout }) {
return (
<ul className="mt-3 flex flex-col">
<ul
className={classNames(
layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col",
"mt-3"
)}
>
{bookmarks.map((bookmark) => (
<Item key={`${bookmark.name}-${bookmark.href}`} bookmark={bookmark} />
))}

View file

@ -61,7 +61,6 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
}
}
function handleItemHover(event) {
setCurrentItemIndex(parseInt(event.target?.dataset?.index, 10));
}
@ -71,6 +70,16 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
openCurrentItem(event.metaKey);
}
function handleItemKeyDown(event) {
if (!isOpen) return;
// native button handles other keys
if (event.key === "Escape") {
closeAndReset();
event.preventDefault();
}
}
useEffect(() => {
if (searchString.length === 0) setResults([]);
else {
@ -162,10 +171,10 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
{results.length > 0 && <ul className="max-h-[60vh] overflow-y-auto m-2">
{results.map((r, i) => (
<li key={r.container ?? r.app ?? `${r.name}-${r.href}`}>
<button type="button" data-index={i} onMouseEnter={handleItemHover} className={classNames(
<button type="button" data-index={i} onMouseEnter={handleItemHover} onClick={handleItemClick} onKeyDown={handleItemKeyDown} className={classNames(
"flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200",
i === currentItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
)} onClick={handleItemClick}>
)}>
<div className="flex flex-row items-center mr-4 pointer-events-none">
{(r.icon || r.abbr) && <div className="w-5 text-xs mr-4">
{r.icon && <ResolvedIcon icon={r.icon} />}

View file

@ -33,7 +33,7 @@ export default function ServicesGroup({ group, services, layout, fiveColumns, di
<MdKeyboardArrowDown className={classNames(
disableCollapse ? 'hidden' : '',
'transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl',
open ? '' : 'rotate-90'
open ? '' : 'rotate-180'
)} />
</Disclosure.Button>
}

View file

@ -1,18 +1,8 @@
import classNames from "classnames";
import Item from "components/services/item";
import { columnMap } from "../../utils/layout/columns";
const columnMap = [
"grid-cols-1 md:grid-cols-1 lg:grid-cols-1",
"grid-cols-1 md:grid-cols-1 lg:grid-cols-1",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-2",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-5",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-6",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-7",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-8",
];
import Item from "components/services/item";
export default function List({ group, services, layout }) {
return (

View file

@ -7,6 +7,14 @@ import Raw from "./raw";
export function getAllClasses(options, additionalClassNames = '') {
if (options?.style?.header === "boxedWidgets") {
if (options?.style?.cardBlur !== undefined) {
// eslint-disable-next-line no-param-reassign
additionalClassNames = [
additionalClassNames,
`backdrop-blur${options.style.cardBlur.length ? '-' : ""}${options.style.cardBlur}`
].join(' ')
}
return classNames(
"flex flex-col justify-center first:ml-0 ml-2 mr-2",
"mt-2 m:mb-0 rounded-md shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 dark:bg-white/5 p-2 pl-3 pr-3",

View file

@ -9,7 +9,7 @@ export default function Document() {
content="A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations."
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/site.webmanifest?v=4" />
<link rel="manifest" href="/site.webmanifest?v=4" crossorigin="use-credentials" />
<link rel="mask-icon" href="/safari-pinned-tab.svg?v=4" color="#1e9cd7" />
</Head>
<body>

View file

@ -55,6 +55,10 @@ export default async function handler(req, res) {
req.query.endpoint = `${req.query.endpoint}?${query}`;
}
if (mapping?.headers) {
req.extraHeaders = mapping.headers;
}
if (endpointProxy instanceof Function) {
return endpointProxy(req, res, map);
}

View file

@ -209,12 +209,12 @@ function Home({ initialSettings }) {
searchProvider = searchProviders[searchWidget.options?.provider];
}
}
const headerStyle = initialSettings?.headerStyle || "underlined";
const headerStyle = settings?.headerStyle || "underlined";
useEffect(() => {
function handleKeyDown(e) {
if (e.target.tagName === "BODY") {
if (String.fromCharCode(e.keyCode).match(/(\w|\s)/g) && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) {
if (e.target.tagName === "BODY" || e.target.id === "inner_wrapper") {
if (e.key.match(/(\w|\s)/g) && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.code === "Tab")) {
setSearching(true);
} else if (e.key === "Escape") {
setSearchString("");
@ -233,12 +233,12 @@ function Home({ initialSettings }) {
return (
<>
<Head>
<title>{initialSettings.title || "Homepage"}</title>
{initialSettings.base && <base href={initialSettings.base} />}
{initialSettings.favicon ? (
<title>{settings.title || "Homepage"}</title>
{settings.base && <base href={settings.base} />}
{settings.favicon ? (
<>
<link rel="apple-touch-icon" sizes="180x180" href={initialSettings.favicon} />
<link rel="icon" href={initialSettings.favicon} />
<link rel="apple-touch-icon" sizes="180x180" href={settings.favicon} />
<link rel="icon" href={settings.favicon} />
</>
) : (
<>
@ -248,33 +248,31 @@ function Home({ initialSettings }) {
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=4" />
</>
)}
<meta
name="msapplication-TileColor"
content={themes[initialSettings.color || "slate"][initialSettings.theme || "dark"]}
/>
<meta name="theme-color" content={themes[initialSettings.color || "slate"][initialSettings.theme || "dark"]} />
<meta name="msapplication-TileColor" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
<meta name="theme-color" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
</Head>
<div className="relative container m-auto flex flex-col justify-start z-10 h-full">
<QuickLaunch
servicesAndBookmarks={servicesAndBookmarks}
searchString={searchString}
setSearchString={setSearchString}
isOpen={searching}
close={setSearching}
searchProvider={settings.quicklaunch?.hideInternetSearch ? null : searchProvider}
/>
<div
className={classNames(
"flex flex-row flex-wrap justify-between",
headerStyles[headerStyle]
"flex flex-row flex-wrap justify-between",
headerStyles[headerStyle],
settings.cardBlur !== undefined && headerStyle === "boxed" && `backdrop-blur${settings.cardBlur.length ? '-' : ""}${settings.cardBlur}`
)}
>
<QuickLaunch
servicesAndBookmarks={servicesAndBookmarks}
searchString={searchString}
setSearchString={setSearchString}
isOpen={searching}
close={setSearching}
searchProvider={settings.quicklaunch?.hideInternetSearch ? null : searchProvider}
/>
{widgets && (
<>
{widgets
.filter((widget) => !rightAlignedWidgets.includes(widget.type))
.map((widget, i) => (
<Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: false}} />
<Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: false, cardBlur: settings.cardBlur }} />
))}
<div className={classNames(
@ -284,7 +282,7 @@ function Home({ initialSettings }) {
{widgets
.filter((widget) => rightAlignedWidgets.includes(widget.type))
.map((widget, i) => (
<Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: true}} />
<Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: true, cardBlur: settings.cardBlur }} />
))}
</div>
</>
@ -292,39 +290,42 @@ function Home({ initialSettings }) {
</div>
{services?.length > 0 && (
<div className="flex flex-wrap p-4 sm:p-8 sm:pt-4 items-start pb-2">
<div key="services" className="flex flex-wrap p-4 sm:p-8 sm:pt-4 items-start pb-2">
{services.map((group) => (
<ServicesGroup
<ServicesGroup
key={group.name}
group={group.name}
services={group}
layout={initialSettings.layout?.[group.name]}
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse} />
layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse}
/>
))}
</div>
)}
{bookmarks?.length > 0 && (
<div className={`grow flex flex-wrap pt-0 p-4 sm:p-8 gap-2 grid-cols-1 lg:grid-cols-2 lg:grid-cols-${Math.min(6, bookmarks.length)}`}>
<div key="bookmarks" className="flex flex-wrap p-4 sm:p-8 sm:pt-4 items-start pb-2">
{bookmarks.map((group) => (
<BookmarksGroup
key={group.name}
group={group}
disableCollapse={settings.disableCollapse} />
bookmarks={group}
layout={settings.layout?.[group.name]}
disableCollapse={settings.disableCollapse}
/>
))}
</div>
)}
<div className="flex flex-col mt-auto p-8 w-full">
<div className="flex w-full justify-end">
{!initialSettings?.color && <ColorToggle />}
{!settings?.color && <ColorToggle />}
<Revalidate />
{!initialSettings?.theme && <ThemeToggle />}
{!settings.theme && <ThemeToggle />}
</div>
<div className="flex mt-4 w-full justify-end">
{!initialSettings?.hideVersion && <Version />}
{!settings.hideVersion && <Version />}
</div>
</div>
</div>
@ -374,6 +375,7 @@ export default function Wrapper({ initialSettings, fallback }) {
>
<div
id="inner_wrapper"
tabIndex="-1"
className={classNames(
'fixed overflow-auto w-full h-full',
backgroundBlur && `backdrop-blur${initialSettings.background.blur.length ? '-' : ""}${initialSettings.background.blur}`,

View file

@ -34,6 +34,16 @@ export async function bookmarksResponse() {
if (!bookmarks) return [];
let initialSettings;
try {
initialSettings = await getSettings();
} catch (e) {
console.error("Failed to load settings.yaml, please check for errors");
if (e) console.error(e.toString());
initialSettings = {};
}
// map easy to write YAML objects into easy to consume JS arrays
const bookmarksArray = bookmarks.map((group) => ({
name: Object.keys(group)[0],
@ -43,7 +53,21 @@ export async function bookmarksResponse() {
})),
}));
return bookmarksArray;
const sortedGroups = [];
const unsortedGroups = [];
const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;
bookmarksArray.forEach((group) => {
if (definedLayouts) {
const layoutIndex = definedLayouts.findIndex(layout => layout === group.name);
if (layoutIndex > -1) sortedGroups[layoutIndex] = group;
else unsortedGroups.push(group);
} else {
unsortedGroups.push(group);
}
});
return [...sortedGroups.filter(g => g), ...unsortedGroups];
}
export async function widgetsResponse() {

View file

@ -158,22 +158,29 @@ export async function servicesFromKubernetes() {
return null;
});
const traefikIngressList = await crd.listClusterCustomObject("traefik.io", "v1alpha1", "ingressroutes")
const traefikIngressListContaino = await crd.listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes")
.then((response) => response.body)
.catch(async (error) => {
logger.error("Error getting traefik ingresses from traefik.io: %d %s %s", error.statusCode, error.body, error.response);
if (error.statusCode !== 404) {
logger.error("Error getting traefik ingresses from traefik.containo.us: %d %s %s", error.statusCode, error.body, error.response);
}
// Fallback to the old traefik CRD group
const fallbackIngressList = await crd.listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes")
.then((response) => response.body)
.catch((fallbackError) => {
logger.error("Error getting traefik ingresses from traefik.containo.us: %d %s %s", fallbackError.statusCode, fallbackError.body, fallbackError.response);
return null;
});
return fallbackIngressList;
return [];
});
const traefikIngressListIo = await crd.listClusterCustomObject("traefik.io", "v1alpha1", "ingressroutes")
.then((response) => response.body)
.catch(async (error) => {
if (error.statusCode !== 404) {
logger.error("Error getting traefik ingresses from traefik.io: %d %s %s", error.statusCode, error.body, error.response);
}
return [];
});
const traefikIngressList = [...traefikIngressListContaino, ...traefikIngressListIo];
if (traefikIngressList && traefikIngressList.items.length > 0) {
const traefikServices = traefikIngressList.items
.filter((ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`])
@ -299,6 +306,8 @@ export function cleanServiceGroups(groups) {
stream, // mjpeg
fit,
method, // openmediavault widget
mappings, // customapi widget
refreshInterval,
} = cleanedService.widget;
let fieldsList = fields;
@ -372,6 +381,10 @@ export function cleanServiceGroups(groups) {
if (type === "openmediavault") {
if (method) cleanedService.widget.method = method;
}
if (type === "customapi") {
if (mappings) cleanedService.widget.mappings = mappings;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
}
}
return cleanedService;

View file

@ -0,0 +1,12 @@
// eslint-disable-next-line import/prefer-default-export
export const columnMap = [
"grid-cols-1 md:grid-cols-1 lg:grid-cols-1",
"grid-cols-1 md:grid-cols-1 lg:grid-cols-1",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-2",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-5",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-6",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-7",
"grid-cols-1 md:grid-cols-2 lg:grid-cols-8",
];

View file

@ -32,6 +32,7 @@ export default async function credentialedProxyHandler(req, res, map) {
"authentik",
"cloudflared",
"ghostfolio",
"mealie",
"tailscale",
"truenas",
"pterodactyl",

View file

@ -20,15 +20,14 @@ export default async function genericProxyHandler(req, res, map) {
if (widget) {
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
let headers;
const headers = req.extraHeaders ?? widget.headers ?? {};
if (widget.username && widget.password) {
headers = {
Authorization: `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`,
};
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
}
const params = {
method: req.method,
method: widget.method ?? req.method,
headers,
}
if (req.body) {

View file

@ -0,0 +1,36 @@
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: infoData, error: infoError } = useWidgetAPI(widget, "info");
if (infoError) {
return <Container service={service} error={infoError} />;
}
if (!infoData) {
return (
<Container service={service}>
<Block label="atsumeru.series" />
<Block label="atsumeru.archives" />
<Block label="atsumeru.chapters" />
<Block label="atsumeru.categories" />
</Container>
);
}
return (
<Container service={service}>
<Block label="atsumeru.series" value={t("common.number", { value: infoData.stats.total_series })} />
<Block label="atsumeru.archives" value={t("common.number", { value: infoData.stats.total_archives })} />
<Block label="atsumeru.chapters" value={t("common.number", { value: infoData.stats.total_chapters })} />
<Block label="atsumeru.categories" value={t("common.number", { value: infoData.stats.total_categories })} />
</Container>
);
}

View file

@ -0,0 +1,14 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/server/{endpoint}",
proxyHandler: genericProxyHandler,
mappings: {
info: {
endpoint: "info"
}
},
};
export default widget;

View file

@ -0,0 +1,36 @@
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, error } = useWidgetAPI(widget, "stats");
if (error) {
return <Container service={service} error={error} />;
}
if (!data) {
return (
<Container service={service}>
<Block label="calibreweb.books" />
<Block label="calibreweb.authors" />
<Block label="calibreweb.categories" />
<Block label="calibreweb.series" />
</Container>
);
}
return (
<Container service={service}>
<Block label="calibreweb.books" value={t("common.number", { value: data.books })} />
<Block label="calibreweb.authors" value={t("common.number", { value: data.authors })} />
<Block label="calibreweb.categories" value={t("common.number", { value: data.categories })} />
<Block label="calibreweb.series" value={t("common.number", { value: data.series })} />
</Container>
);
}

View file

@ -0,0 +1,14 @@
import genericProxyHandler from "../../utils/proxy/handlers/generic";
const widget = {
api: "{url}/{endpoint}",
proxyHandler: genericProxyHandler,
mappings: {
stats: {
endpoint: "opds/stats",
},
},
};
export default widget;

View file

@ -2,16 +2,19 @@ import dynamic from "next/dynamic";
const components = {
adguard: dynamic(() => import("./adguard/component")),
atsumeru: dynamic(() => import("./atsumeru/component")),
audiobookshelf: dynamic(() => import("./audiobookshelf/component")),
authentik: dynamic(() => import("./authentik/component")),
autobrr: dynamic(() => import("./autobrr/component")),
azuredevops: dynamic(() => import("./azuredevops/component")),
bazarr: dynamic(() => import("./bazarr/component")),
caddy: dynamic(() => import("./caddy/component")),
calibreweb: dynamic(() => import("./calibreweb/component")),
changedetectionio: dynamic(() => import("./changedetectionio/component")),
channelsdvrserver: dynamic(() => import("./channelsdvrserver/component")),
cloudflared: dynamic(() => import("./cloudflared/component")),
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
customapi: dynamic(() => import("./customapi/component")),
deluge: dynamic(() => import("./deluge/component")),
diskstation: dynamic(() => import("./diskstation/component")),
downloadstation: dynamic(() => import("./downloadstation/component")),
@ -42,6 +45,7 @@ const components = {
kopia: dynamic(() => import("./kopia/component")),
lidarr: dynamic(() => import("./lidarr/component")),
mastodon: dynamic(() => import("./mastodon/component")),
mealie: dynamic(() => import("./mealie/component")),
medusa: dynamic(() => import("./medusa/component")),
minecraft: dynamic(() => import("./minecraft/component")),
miniflux: dynamic(() => import("./miniflux/component")),
@ -93,6 +97,7 @@ const components = {
unifi: dynamic(() => import("./unifi/component")),
unmanic: dynamic(() => import("./unmanic/component")),
uptimekuma: dynamic(() => import("./uptimekuma/component")),
uptimerobot: dynamic(() => import("./uptimerobot/component")),
urbackup: dynamic(() => import("./urbackup/component")),
watchtower: dynamic(() => import("./watchtower/component")),
whatsupdocker: dynamic(() => import("./whatsupdocker/component")),

View file

@ -0,0 +1,75 @@
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";
function getValue(field, data) {
let value = data;
let lastField = field;
let key = '';
while (typeof lastField === "object") {
key = Object.keys(lastField)[0] ?? null;
if (key === null) {
break;
}
value = value[key];
lastField = lastField[key];
}
if (typeof value === 'undefined') {
return null;
}
return value[lastField] ?? null;
}
function formatValue(t, mapping, value) {
switch (mapping?.format) {
case 'number':
return t("common.number", { value: parseInt(value, 10) });
case 'float':
return t("common.number", { value });
case 'percent':
return t("common.percent", { value });
case 'text':
default:
return value;
}
}
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { mappings = [], refreshInterval = 10000 } = widget;
const { data: customData, error: customError } = useWidgetAPI(widget, null, {
refreshInterval: Math.max(1000, refreshInterval),
});
if (customError) {
return <Container service={service} error={customError} />;
}
if (!customData) {
return (
<Container service={service}>
{ mappings.slice(0,4).map(item => <Block label={item.label} key={item.field} />) }
</Container>
);
}
return (
<Container service={service}>
{ mappings.slice(0,4).map(mapping => <Block
label={mapping.label}
key={mapping.field}
value={formatValue(t, mapping, getValue(mapping.field, customData))}
/>) }
</Container>
);
}

View file

@ -0,0 +1,8 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}",
proxyHandler: genericProxyHandler,
};
export default widget;

View file

@ -29,17 +29,19 @@ function ticksToString(ticks) {
function SingleSessionEntry({ playCommand, session }) {
const {
NowPlayingItem: { Name, SeriesName, RunTimeTicks },
NowPlayingItem: { Name, SeriesName },
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
const RunTimeTicks = session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;
const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {
IsVideoDirect: true,
VideoDecoderIsHardware: true,
VideoEncoderIsHardware: true,
};
const percent = (PositionTicks / RunTimeTicks) * 100;
const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;
return (
<>
@ -98,13 +100,15 @@ function SingleSessionEntry({ playCommand, session }) {
function SessionEntry({ playCommand, session }) {
const {
NowPlayingItem: { Name, SeriesName, RunTimeTicks },
NowPlayingItem: { Name, SeriesName },
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
const RunTimeTicks = session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;
const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {};
const percent = (PositionTicks / RunTimeTicks) * 100;
const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;
return (
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">

View file

@ -0,0 +1,33 @@
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: mealieData, error: mealieError } = useWidgetAPI(widget);
if (mealieError || mealieData?.statusCode === 401) {
return <Container service={service} error={mealieError ?? mealieData} />;
}
if (!mealieData) {
return (
<Container service={service}>
<Block label="mealie.recipes" />
<Block label="mealie.users" />
<Block label="mealie.categories" />
<Block label="mealie.tags" />
</Container>
);
}
return (
<Container service={service}>
<Block label="mealie.recipes" value={mealieData.totalRecipes} />
<Block label="mealie.users" value={mealieData.totalUsers} />
<Block label="mealie.categories" value={mealieData.totalCategories} />
<Block label="mealie.tags" value={mealieData.totalTags} />
</Container>
);
}

View file

@ -0,0 +1,8 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/groups/statistics",
proxyHandler: credentialedProxyHandler,
};
export default widget;

View file

@ -0,0 +1,97 @@
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";
function secondsToDhms(seconds) {
const d = Math.floor(seconds / (3600*24));
const h = Math.floor(seconds % (3600*24) / 3600);
const m = Math.floor(seconds % 3600 / 60);
const s = Math.floor(seconds % 60);
const dDisplay = d > 0 ? d + (d === 1 ? " day, " : " days, ") : "";
const hDisplay = h > 0 ? h + (h === 1 ? " hr, " : " hrs, ") : "";
let mDisplay = m > 0 && d === 0 ? m + (m === 1 ? " min" : " mins") : "";
let sDisplay = "";
if (d === 0 && h === 0) {
mDisplay = m > 0 ? m + (m === 1 ? " min, " : " mins, ") : "";
sDisplay = s > 0 ? s + (s === 1 ? " sec" : " secs") : "";
}
return (dDisplay + hDisplay + mDisplay + sDisplay).replace(/,\s*$/, "");
}
export default function Component({ service }) {
const { widget } = service;
const { t } = useTranslation();
const { data: uptimerobotData, error: uptimerobotError } = useWidgetAPI(widget, "getmonitors");
if (uptimerobotError) {
return <Container service={service} error={uptimerobotError} />;
}
if (!uptimerobotData) {
return (
<Container service={service}>
<Block label="uptimerobot.status" />
<Block label="uptimerobot.uptime" />
</Container>
);
}
// multiple monitors
if (uptimerobotData.pagination?.total > 1) {
const sitesUp = uptimerobotData.monitors.filter(m => m.status === 2).length;
return (
<Container service={service}>
<Block label="uptimerobot.sitesUp" value={sitesUp} />
<Block label="uptimerobot.sitesDown" value={uptimerobotData.pagination.total - sitesUp} />
</Container>
);
}
// single monitor
const monitor = uptimerobotData.monitors[0];
let status;
let uptime = 0;
let logIndex = 0;
switch (monitor.status) {
case 0:
status = t("uptimerobot.paused");
break;
case 1:
status = t("uptimerobot.notyetchecked");
break;
case 2:
status = t("uptimerobot.up");
uptime = secondsToDhms(monitor.logs[0].duration);
logIndex = 1;
break;
case 8:
status = t("uptimerobot.seemsdown");
break;
case 9:
status = t("uptimerobot.down");
break;
default:
status = t("uptimerobot.unknown");
break;
}
const lastDown = new Date(monitor.logs[logIndex].datetime * 1000).toLocaleString();
const downDuration = secondsToDhms(monitor.logs[logIndex].duration);
const hideDown = logIndex === 1 && monitor.logs[logIndex].type !== 1;
return (
<Container service={service}>
<Block label="uptimerobot.status" value={status} />
<Block label="uptimerobot.uptime" value={uptime} />
{!hideDown && <Block label="uptimerobot.lastDown" value={lastDown} />}
{!hideDown && <Block label="uptimerobot.downDuration" value={downDuration} />}
</Container>
);
}

View file

@ -0,0 +1,20 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/v2/{endpoint}?api_key={key}",
proxyHandler: genericProxyHandler,
mappings: {
getmonitors: {
method: "POST",
endpoint: "getMonitors",
body: 'format=json&logs=1',
headers: {
"content-type": "application/x-www-form-urlencoded",
"cache-control": "no-cache"
},
},
},
};
export default widget;

View file

@ -1,14 +1,17 @@
import adguard from "./adguard/widget";
import atsumeru from "./atsumeru/widget";
import audiobookshelf from "./audiobookshelf/widget";
import authentik from "./authentik/widget";
import autobrr from "./autobrr/widget";
import azuredevops from "./azuredevops/widget";
import bazarr from "./bazarr/widget";
import caddy from "./caddy/widget";
import calibreweb from "./calibreweb/widget";
import changedetectionio from "./changedetectionio/widget";
import channelsdvrserver from "./channelsdvrserver/widget";
import cloudflared from "./cloudflared/widget";
import coinmarketcap from "./coinmarketcap/widget";
import customapi from "./customapi/widget";
import deluge from "./deluge/widget";
import diskstation from "./diskstation/widget";
import downloadstation from "./downloadstation/widget";
@ -36,6 +39,7 @@ import komga from "./komga/widget";
import kopia from "./kopia/widget";
import lidarr from "./lidarr/widget";
import mastodon from "./mastodon/widget";
import mealie from "./mealie/widget";
import medusa from "./medusa/widget";
import minecraft from "./minecraft/widget";
import miniflux from "./miniflux/widget";
@ -87,6 +91,7 @@ import truenas from "./truenas/widget";
import unifi from "./unifi/widget";
import unmanic from "./unmanic/widget";
import uptimekuma from "./uptimekuma/widget";
import uptimerobot from "./uptimerobot/widget";
import watchtower from "./watchtower/widget";
import whatsupdocker from "./whatsupdocker/widget";
import xteve from "./xteve/widget";
@ -94,16 +99,19 @@ import urbackup from "./urbackup/widget";
const widgets = {
adguard,
atsumeru,
audiobookshelf,
authentik,
autobrr,
azuredevops,
bazarr,
caddy,
calibreweb,
changedetectionio,
channelsdvrserver,
cloudflared,
coinmarketcap,
customapi,
deluge,
diskstation,
downloadstation,
@ -132,6 +140,7 @@ const widgets = {
kopia,
lidarr,
mastodon,
mealie,
medusa,
minecraft,
miniflux,
@ -184,6 +193,7 @@ const widgets = {
unifi_console: unifi,
unmanic,
uptimekuma,
uptimerobot,
urbackup,
watchtower,
whatsupdocker,