Merge branch 'main' into fix/icon

This commit is contained in:
shamoon 2022-11-18 22:26:23 -08:00 committed by GitHub
commit d76a18565c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
141 changed files with 7378 additions and 551 deletions

View file

@ -3,7 +3,7 @@ import List from "components/bookmarks/list";
export default function BookmarksGroup({ group }) {
return (
<div key={group.name} className="basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4 flex-1">
<div key={group.name} className="flex-1">
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">{group.name}</h2>
<ErrorBoundary>
<List bookmarks={group.bookmarks} />

View file

@ -1,6 +1,7 @@
import { useContext } from "react";
import { SettingsContext } from "utils/contexts/settings";
import ResolvedIcon from "components/resolvedicon";
export default function Item({ bookmark }) {
const { hostname } = new URL(bookmark.href);
@ -11,12 +12,17 @@ export default function Item({ bookmark }) {
<a
href={bookmark.href}
title={bookmark.name}
target={settings.target ?? "_blank"}
target={bookmark.target ?? settings.target ?? "_blank"}
className="block w-full text-left cursor-pointer transition-all h-15 mb-3 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"
>
<div className="flex">
<div className="flex-shrink-0 flex items-center justify-center w-11 bg-theme-500/10 dark:bg-theme-900/50 text-theme-700 hover:text-theme-700 dark:text-theme-200 text-sm font-medium rounded-l-md">
{bookmark.abbr}
{bookmark.icon &&
<div className="flex-shrink-0 w-5 h-5">
<ResolvedIcon icon={bookmark.icon} />
</div>
}
{!bookmark.icon && bookmark.abbr}
</div>
<div className="flex-1 flex items-center justify-between rounded-r-md ">
<div className="flex-1 grow pl-3 py-2 text-xs">{bookmark.name}</div>

View file

@ -0,0 +1,160 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState, useRef, useCallback, useContext } from "react";
import classNames from "classnames";
import ResolvedIcon from "./resolvedicon";
import { SettingsContext } from "utils/contexts/settings";
export default function QuickLaunch({servicesAndBookmarks, searchString, setSearchString, isOpen, close, searchDescriptions}) {
const { t } = useTranslation();
const { settings } = useContext(SettingsContext);
const searchField = useRef();
const [results, setResults] = useState([]);
const [currentItemIndex, setCurrentItemIndex] = useState(null);
function openCurrentItem(newWindow) {
const result = results[currentItemIndex];
window.open(result.href, newWindow ? "_blank" : result.target ?? settings.target ?? "_blank");
}
const closeAndReset = useCallback(() => {
close(false);
setTimeout(() => {
setSearchString("");
setCurrentItemIndex(null);
}, 200); // delay a little for animations
}, [close, setSearchString, setCurrentItemIndex]);
function handleSearchChange(event) {
setSearchString(event.target.value.toLowerCase())
}
function handleSearchKeyDown(event) {
if (!isOpen) return;
if (event.key === "Escape") {
closeAndReset();
event.preventDefault();
} else if (event.key === "Enter" && results.length) {
closeAndReset();
openCurrentItem(event.metaKey);
} else if (event.key === "ArrowDown" && results[currentItemIndex + 1]) {
setCurrentItemIndex(currentItemIndex + 1);
event.preventDefault();
} else if (event.key === "ArrowUp" && currentItemIndex > 0) {
setCurrentItemIndex(currentItemIndex - 1);
event.preventDefault();
}
}
function handleItemHover(event) {
setCurrentItemIndex(parseInt(event.target?.dataset?.index, 10));
}
function handleItemClick(event) {
closeAndReset();
openCurrentItem(event.metaKey);
}
useEffect(() => {
if (searchString.length === 0) setResults([]);
else {
let newResults = servicesAndBookmarks.filter(r => {
const nameMatch = r.name.toLowerCase().includes(searchString);
let descriptionMatch;
if (searchDescriptions) {
descriptionMatch = r.description?.toLowerCase().includes(searchString)
r.priority = nameMatch ? 2 * (+nameMatch) : +descriptionMatch; // eslint-disable-line no-param-reassign
}
return nameMatch || descriptionMatch;
});
if (searchDescriptions) {
newResults = newResults.sort((a, b) => b.priority - a.priority);
}
setResults(newResults);
if (newResults.length) {
setCurrentItemIndex(0);
}
}
}, [searchString, servicesAndBookmarks, searchDescriptions]);
const [hidden, setHidden] = useState(true);
useEffect(() => {
function handleBackdropClick(event) {
if (event.target?.tagName === "DIV") closeAndReset();
}
if (isOpen) {
searchField.current.focus();
document.body.addEventListener('click', handleBackdropClick);
setHidden(false);
} else {
document.body.removeEventListener('click', handleBackdropClick);
searchField.current.blur();
setTimeout(() => {
setHidden(true);
}, 300); // disable on close
}
}, [isOpen, closeAndReset]);
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>;
}
return (
<div className={classNames(
"relative z-10 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="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(
results.length > 0 && "rounded-t-md",
results.length === 0 && "rounded-md",
"w-full p-4 m-0 border-0 border-b border-slate-700 focus:border-slate-700 focus:outline-0 focus:ring-0 text-sm md:text-xl text-theme-700 dark:text-theme-200 bg-theme-60 dark:bg-theme-800"
)} type="text" autoCorrect="false" ref={searchField} value={searchString} onChange={handleSearchChange} onKeyDown={handleSearchKeyDown} />
{results.length > 0 && <ul className="max-h-[60vh] overflow-y-auto m-2">
{results.map((r, i) => (
<li key={r.name}>
<button type="button" data-index={i} onMouseEnter={handleItemHover} 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">
<div className="w-5 text-xs mr-4">
{r.icon && <ResolvedIcon icon={r.icon} />}
{r.abbr && r.abbr}
</div>
<div className="flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none">
<span className="mr-4">{r.name}</span>
{r.description &&
<span className="text-xs text-theme-600 text-light">
{searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description}
</span>
}
</div>
</div>
<div className="text-xs text-theme-600 font-bold pointer-events-none">{r.abbr ? t("quicklaunch.bookmark") : t("quicklaunch.service")}</div>
</button>
</li>
))}
</ul>}
</dialog>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import Image from "next/future/image";
export default function ResolvedIcon({ icon }) {
// direct or relative URLs
if (icon.startsWith("http") || icon.startsWith("/")) {
return <Image src={`${icon}`} width={32} height={32} alt="logo" />;
}
// mdi- prefixed, material design icons
if (icon.startsWith("mdi-")) {
const iconName = icon.replace("mdi-", "").replace(".svg", "");
return (
<div
style={{
width: 32,
height: 32,
maxWidth: '100%',
maxHeight: '100%',
background: "linear-gradient(180deg, rgb(var(--color-logo-start)), rgb(var(--color-logo-stop)))",
mask: `url(https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/${iconName}.svg) no-repeat center / contain`,
WebkitMask: `url(https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/${iconName}.svg) no-repeat center / contain`,
}}
/>
);
}
// fallback to dashboard-icons
const iconName = icon.replace(".png", "");
return (
<Image
src={`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${iconName}.png`}
width={32}
height={32}
alt="logo"
/>
);
}

View file

@ -1,6 +1,7 @@
import classNames from "classnames";
import List from "components/services/list";
import ResolvedIcon from "components/resolvedicon";
export default function ServicesGroup({ services, layout }) {
return (
@ -11,7 +12,14 @@ export default function ServicesGroup({ services, layout }) {
"flex-1 p-1"
)}
>
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">{services.name}</h2>
<div className="flex select-none items-center">
{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">{services.name}</h2>
</div>
<List services={services.services} layout={layout} />
</div>
);

View file

@ -1,4 +1,3 @@
import Image from "next/future/image";
import classNames from "classnames";
import { useContext, useState } from "react";
@ -7,40 +6,7 @@ import Widget from "./widget";
import Docker from "widgets/docker/component";
import { SettingsContext } from "utils/contexts/settings";
function resolveIcon(icon) {
// direct or relative URLs
if (icon.startsWith("http") || icon.startsWith("/")) {
return <Image src={`${icon}`} width={32} height={32} alt="logo" />;
}
// mdi- prefixed, material design icons
if (icon.startsWith("mdi-")) {
const iconName = icon.replace("mdi-", "").replace(".svg", "");
return (
<div
style={{
width: 32,
height: 32,
background: "linear-gradient(180deg, rgb(var(--color-logo-start)), rgb(var(--color-logo-stop)))",
mask: `url(https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/${iconName}.svg) no-repeat center / contain`,
WebkitMask: `url(https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/${iconName}.svg) no-repeat center / contain`,
}}
/>
);
}
// fallback to dashboard-icons
const iconName = icon.replace(".png", "");
return (
<Image
src={`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${iconName}.png`}
width={32}
height={32}
alt="logo"
/>
);
}
import ResolvedIcon from "components/resolvedicon";
export default function Item({ service }) {
const hasLink = service.href && service.href !== "#";
@ -71,20 +37,22 @@ export default function Item({ service }) {
(hasLink ? (
<a
href={service.href}
target={settings.target ?? "_blank"}
target={service.target ?? settings.target ?? "_blank"}
rel="noreferrer"
className="flex-shrink-0 flex items-center justify-center w-12 "
>
{resolveIcon(service.icon)}
<ResolvedIcon icon={service.icon} />
</a>
) : (
<div className="flex-shrink-0 flex items-center justify-center w-12 ">{resolveIcon(service.icon)}</div>
<div className="flex-shrink-0 flex items-center justify-center w-12 ">
<ResolvedIcon icon={service.icon} />
</div>
))}
{hasLink ? (
<a
href={service.href}
target={settings.target ?? "_blank"}
target={service.target ?? settings.target ?? "_blank"}
rel="noreferrer"
className="flex-1 flex items-center justify-between rounded-r-md "
>
@ -117,7 +85,7 @@ export default function Item({ service }) {
{service.container && service.server && (
<div
className={classNames(
statsOpen && !statsClosing ? "max-h-[55px] opacity-100" : " max-h-[0] opacity-0",
statsOpen && !statsClosing ? "max-h-[110px] opacity-100" : " max-h-[0] opacity-0",
"w-full overflow-hidden transition-all duration-300 ease-in-out"
)}
>

View file

@ -7,7 +7,7 @@ export default function Block({ value, label }) {
return (
<div
className={classNames(
"bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1",
"bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center text-center p-1",
value === undefined ? "animate-pulse" : ""
)}
>

View file

@ -1,17 +1,15 @@
import Error from "./error";
export default function Container({ error = false, children, service }) {
if (error) {
return (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
<div className="font-thin text-sm">{error}</div>
</div>
);
return <Error error={error} />
}
let visibleChildren = children;
const fields = service?.widget?.fields;
const type = service?.widget?.type;
if (fields && type) {
visibleChildren = children.filter(child => fields.some(field => `${type}.${field}` === child.props?.label));
visibleChildren = children.filter(child => fields.some(field => `${type}.${field}` === child?.props?.label));
}
return <div className="relative flex flex-row w-full">{visibleChildren}</div>;

View file

@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { IoAlertCircle } from "react-icons/io5";
function displayError(error) {
return JSON.stringify(error[1] ? error[1] : error, null, 4);
}
function displayData(data) {
return (data.type === 'Buffer') ? Buffer.from(data).toString() : JSON.stringify(data, 4);
}
export default function Error({ error }) {
const { t } = useTranslation();
if (error?.data?.error) {
error = error.data.error; // eslint-disable-line no-param-reassign
}
return (
<details className="px-1 pb-1">
<summary className="block text-center mt-1 mb-0 mx-auto p-3 rounded bg-rose-900/80 hover:bg-rose-900/95 text-theme-900 cursor-pointer">
<div className="flex items-center justify-center text-xs font-bold">
<IoAlertCircle className="mr-1 w-5 h-5"/>{t("widget.api_error")} {error.message && t("widget.information")}
</div>
</summary>
<div className="bg-white dark:bg-theme-200/50 mt-2 rounded text-rose-900 text-xs font-mono whitespace-pre-wrap break-all">
<ul className="p-4">
{error.message && <li>
<span className="text-black">{t("widget.api_error")}:</span> {error.message}
</li>}
{error.url && <li className="mt-2">
<span className="text-black">{t("widget.url")}:</span> {error.url}
</li>}
{error.rawError && <li className="mt-2">
<span className="text-black">{t("widget.raw_error")}:</span>
<div className="ml-2">
{displayError(error.rawError)}
</div>
</li>}
{error.data && <li className="mt-2">
<span className="text-black">{t("widget.response_data")}:</span>
<div className="ml-2">
{displayData(error.data)}
</div>
</li>}
</ul>
</div>
</details>
);
}

View file

@ -27,10 +27,12 @@ export default function DateTime({ options }) {
const dateFormat = new Intl.DateTimeFormat(i18n.language, { ...format });
return (
<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)}
</span>
<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)}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,7 @@
import mapIcon from "utils/weather/owm-condition-map";
export default function Icon({ condition, timeOfDay }) {
const IconComponent = mapIcon(condition, timeOfDay);
return <IconComponent className="w-10 h-10 text-theme-800 dark:text-theme-200" />;
}

View file

@ -0,0 +1,130 @@
import useSWR from "swr";
import { useState } from "react";
import { BiError } from "react-icons/bi";
import { WiCloudDown } from "react-icons/wi";
import { MdLocationDisabled, MdLocationSearching } from "react-icons/md";
import { useTranslation } from "next-i18next";
import Icon from "./icon";
function Widget({ options }) {
const { t } = useTranslation();
const { data, error } = useSWR(
`/api/widgets/openmeteo?${new URLSearchParams({ ...options }).toString()}`
);
if (error || data?.error) {
return (
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
</div>
</div>
);
}
if (!data) {
return (
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<WiCloudDown className="w-8 h-8 text-theme-800 dark:text-theme-200" />
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.updating")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.wait")}</span>
</div>
</div>
</div>
);
}
const unit = options.units === "metric" ? "celsius" : "fahrenheit";
const timeOfDay = data.current_weather.time > data.daily.sunrise[0] && data.current_weather.time < data.daily.sunset[0] ? "day" : "night";
return (
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<Icon condition={data.current_weather.weathercode} timeOfDay={timeOfDay} />
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">
{options.label && `${options.label}, `}
{t("common.number", {
value: data.current_weather.temperature,
style: "unit",
unit,
})}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t(`wmo.${data.current_weather.weathercode}-${timeOfDay}`)}</span>
</div>
</div>
</div>
);
}
export default function OpenMeteo({ options }) {
const { t } = useTranslation();
const [location, setLocation] = useState(false);
const [requesting, setRequesting] = useState(false);
if (!location && options.latitude && options.longitude) {
setLocation({ latitude: options.latitude, longitude: options.longitude });
}
const requestLocation = () => {
setRequesting(true);
if (typeof window !== "undefined") {
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude });
setRequesting(false);
},
() => {
setRequesting(false);
},
{
enableHighAccuracy: true,
maximumAge: 1000 * 60 * 60 * 3,
timeout: 1000 * 30,
}
);
}
};
// if (!requesting && !location) requestLocation();
if (!location) {
return (
<button
type="button"
onClick={() => requestLocation()}
className="flex flex-col justify-center first:ml-0 ml-4 mr-2"
>
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
{requesting ? (
<MdLocationSearching className="w-6 h-6 text-theme-800 dark:text-theme-200 animate-pulse" />
) : (
<MdLocationDisabled className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.current")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.allow")}</span>
</div>
</div>
</button>
);
}
return <Widget options={{ ...location, ...options }} />;
}

View file

@ -54,7 +54,7 @@ function Widget({ options }) {
<div className="hidden sm:flex flex-col items-center">
<Icon
condition={data.weather[0].id}
timeOfDay={data.dt > data.sys.sunrise && data.dt < data.sys.sundown ? "day" : "night"}
timeOfDay={data.dt > data.sys.sunrise && data.dt < data.sys.sunset ? "day" : "night"}
/>
</div>
<div className="flex flex-col ml-3 text-left">

View file

@ -1,4 +1,4 @@
import { BiError, BiWifi, BiCheckCircle, BiXCircle } from "react-icons/bi";
import { BiError, BiWifi, BiCheckCircle, BiXCircle, BiNetworkChart } from "react-icons/bi";
import { MdSettingsEthernet } from "react-icons/md";
import { useTranslation } from "next-i18next";
import { SiUbiquiti } from "react-icons/si";
@ -12,7 +12,7 @@ export default function Widget({ options }) {
options.type = "unifi_console";
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
if (statsError || statsData?.error) {
if (statsError) {
return (
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
@ -48,71 +48,89 @@ export default function Widget({ options }) {
const wan = defaultSite.health.find(h => h.subsystem === "wan");
const lan = defaultSite.health.find(h => h.subsystem === "lan");
const wlan = defaultSite.health.find(h => h.subsystem === "wlan");
const data = {
name: wan.gw_name,
uptime: wan["gw_system-stats"].uptime,
up: wan.status === 'ok',
wlan: {
users: wlan.num_user,
status: wlan.status
},
lan: {
users: lan.num_user,
status: lan.status
}
};
[wan, lan, wlan].forEach(s => {
s.up = s.status === "ok" // eslint-disable-line no-param-reassign
s.show = s.status !== "unknown" // eslint-disable-line no-param-reassign
});
const name = wan.gw_name ?? defaultSite.desc;
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
return (
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<div className="flex flex-col">
<div className="flex flex-row ml-3">
<div className="flex flex-row ml-3 mb-0.5">
<SiUbiquiti className="text-theme-800 dark:text-theme-200 w-3 h-3 mr-1" />
<div className="text-theme-800 dark:text-theme-200 text-xs font-bold flex flex-row justify-between">
{data.name}
{name}
</div>
</div>
<div className="flex flex-row ml-3 text-[10px] justify-between">
<div className="flex flex-row" title={t("unifi.uptime")}>
{uptime && <div className="flex flex-row" title={t("unifi.uptime")}>
<div className="pr-0.5 text-theme-800 dark:text-theme-200">
{t("common.number", {
value: data.uptime / 86400,
value: uptime / 86400,
maximumFractionDigits: 1,
})}
</div>
<div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.days")}</div>
</div>
<div className="flex flex-row">
</div>}
{wan.show && <div className="flex flex-row">
<div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.wan")}</div>
{ data.up
{wan.up
? <BiCheckCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
: <BiXCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
}
</div>
</div>}
{!wan.show && !lan.show && wlan.show && <div className="flex flex-row">
<div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.wlan")}</div>
{wlan.up
? <BiCheckCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
: <BiXCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
}
</div>}
{!wan.show && !wlan.show && lan.show && <div className="flex flex-row">
<div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.lan")}</div>
{lan.up
? <BiCheckCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
: <BiXCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
}
</div>}
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row ml-3 py-0.5">
{wlan.show && <div className="flex flex-row ml-3 py-0.5">
<BiWifi className="text-theme-800 dark:text-theme-200 w-4 h-4 mr-1" />
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between" title={t("unifi.users")}>
<div className="pr-0.5">
{t("common.number", {
value: data.wlan.users,
value: wlan.num_user,
maximumFractionDigits: 0,
})}
</div>
</div>
</div>
<div className="flex flex-row ml-3 pb-0.5">
</div>}
{lan.show && <div className="flex flex-row ml-3 pb-0.5">
<MdSettingsEthernet className="text-theme-800 dark:text-theme-200 w-4 h-4 mr-1" />
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between" title={t("unifi.users")}>
<div className="pr-0.5">
{t("common.number", {
value: data.lan.users,
value: lan.num_user,
maximumFractionDigits: 0,
})}
</div>
</div>
</div>
</div>}
{(wlan.show && !lan.show || !wlan.show && lan.show) && <div className="flex flex-row ml-3 py-0.5">
<BiNetworkChart className="text-theme-800 dark:text-theme-200 w-4 h-4 mr-1" />
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between" title={t("unifi.devices")}>
<div className="pr-0.5">
{t("common.number", {
value: wlan.show ? wlan.num_adopted : lan.num_adopted,
maximumFractionDigits: 0,
})}
</div>
</div>
</div>}
</div>
</div>
);

View file

@ -12,6 +12,7 @@ const widgetMappings = {
logo: dynamic(() => import("components/widgets/logo/logo"), { ssr: false }),
unifi_console: dynamic(() => import("components/widgets/unifi_console/unifi_console")),
glances: dynamic(() => import("components/widgets/glances/glances")),
openmeteo: dynamic(() => import("components/widgets/openmeteo/openmeteo")),
};
export default function Widget({ widget }) {

View file

@ -1,6 +1,7 @@
/* eslint-disable react/jsx-props-no-spreading */
import { SWRConfig } from "swr";
import { appWithTranslation } from "next-i18next";
import Head from "next/head";
import "styles/globals.css";
import "styles/theme.css";
@ -18,6 +19,10 @@ function MyApp({ Component, pageProps }) {
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
}}
>
<Head>
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
</Head>
<ColorProvider>
<ThemeProvider>
<SettingsProvider>

View file

@ -46,7 +46,7 @@ export default async function handler(req, res) {
});
} catch {
res.status(500).send({
error: "unknown error",
error: {message: "Unknown error"},
});
}
}

View file

@ -0,0 +1,8 @@
import cachedFetch from "utils/proxy/cached-fetch";
export default async function handler(req, res) {
const { latitude, longitude, units, cache } = 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`;
return res.send(await cachedFetch(apiUrl, cache));
}

View file

@ -21,6 +21,7 @@ import { SettingsContext } from "utils/contexts/settings";
import { bookmarksResponse, servicesResponse, widgetsResponse } from "utils/config/api-response";
import ErrorBoundary from "components/errorboundry";
import themes from "utils/styles/themes";
import QuickLaunch from "components/quicklaunch";
const ThemeToggle = dynamic(() => import("components/toggles/theme"), {
ssr: false,
@ -34,7 +35,7 @@ const Version = dynamic(() => import("components/version"), {
ssr: false,
});
const rightAlignedWidgets = ["weatherapi", "openweathermap", "weather", "search", "datetime"];
const rightAlignedWidgets = ["weatherapi", "openweathermap", "weather", "openmeteo", "search", "datetime"];
export async function getStaticProps() {
let logger;
@ -173,6 +174,8 @@ function Home({ initialSettings }) {
const { data: services } = useSWR("/api/services");
const { data: bookmarks } = useSWR("/api/bookmarks");
const { data: widgets } = useSWR("/api/widgets");
const servicesAndBookmarks = [...services.map(sg => sg.services).flat(), ...bookmarks.map(bg => bg.bookmarks).flat()]
useEffect(() => {
if (settings.language) {
@ -188,6 +191,28 @@ function Home({ initialSettings }) {
}
}, [i18n, settings, color, setColor, theme, setTheme]);
const [searching, setSearching] = useState(false);
const [searchString, setSearchString] = useState("");
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)) {
setSearching(true);
} else if (e.key === "Escape") {
setSearchString("");
setSearching(false);
}
}
}
document.addEventListener('keydown', handleKeyDown);
return function cleanup() {
document.removeEventListener('keydown', handleKeyDown);
}
})
return (
<>
<Head>
@ -219,6 +244,14 @@ function Home({ initialSettings }) {
headerStyles[initialSettings.headerStyle || "underlined"]
)}
>
<QuickLaunch
servicesAndBookmarks={servicesAndBookmarks}
searchString={searchString}
setSearchString={setSearchString}
isOpen={searching}
close={setSearching}
searchDescriptions={settings.quicklaunch?.searchDescriptions}
/>
{widgets && (
<>
{widgets
@ -247,7 +280,7 @@ function Home({ initialSettings }) {
)}
{bookmarks && (
<div className="grow flex flex-wrap pt-0 p-4 sm:p-8">
<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)}`}>
{bookmarks.map((group) => (
<BookmarksGroup key={group.name} group={group} />
))}

View file

@ -1,6 +1,6 @@
---
# For configuration options and examples, please see:
# https://github.com/benphelps/homepage/wiki/Bookmarks
# https://gethomepage.dev/en/configs/bookmarks
- Developer:
- Github:

View file

@ -1,6 +1,6 @@
---
# For configuration options and examples, please see:
# https://github.com/benphelps/homepage/wiki/Docker-Integration
# https://gethomepage.dev/en/configs/docker/
# my-docker:
# host: 127.0.0.1

View file

@ -1,6 +1,6 @@
---
# For configuration options and examples, please see:
# https://github.com/benphelps/homepage/wiki/Services
# https://gethomepage.dev/en/configs/services
- My First Group:
- My First Service:

View file

@ -1,6 +1,6 @@
---
# For configuration options and examples, please see:
# https://github.com/benphelps/homepage/wiki/Settings
# https://gethomepage.dev/en/configs/settings
providers:
openweathermap: openweathermapapikey

View file

@ -1,6 +1,6 @@
---
# For configuration options and examples, please see:
# https://github.com/benphelps/homepage/wiki/Information-Widgets
# https://gethomepage.dev/en/configs/widgets
- resources:
cpu: true

View file

@ -4,7 +4,7 @@ import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config/config";
import checkAndCopyConfig, { getSettings } from "utils/config/config";
import { servicesFromConfig, servicesFromDocker, cleanServiceGroups } from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
@ -46,6 +46,7 @@ export async function widgetsResponse() {
export async function servicesResponse() {
let discoveredServices;
let configuredServices;
let initialSettings;
try {
discoveredServices = cleanServiceGroups(await servicesFromDocker());
@ -63,11 +64,20 @@ export async function servicesResponse() {
configuredServices = [];
}
try {
initialSettings = await getSettings();
} catch (e) {
console.error("Failed to load settings.yaml, please check for errors");
if (e) console.error(e);
initialSettings = {};
}
const mergedGroupsNames = [
...new Set([discoveredServices.map((group) => group.name), configuredServices.map((group) => group.name)].flat()),
];
const mergedGroups = [];
const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;
mergedGroupsNames.forEach((groupName) => {
const discoveredGroup = discoveredServices.find((group) => group.name === groupName) || { services: [] };
@ -78,7 +88,13 @@ export async function servicesResponse() {
services: [...discoveredGroup.services, ...configuredGroup.services].filter((service) => service),
};
mergedGroups.push(mergedGroup);
if (definedLayouts) {
const layoutIndex = definedLayouts.findIndex(layout => layout === mergedGroup.name);
if (layoutIndex > -1) mergedGroups.splice(layoutIndex, 0, mergedGroup);
else mergedGroups.push(mergedGroup);
} else {
mergedGroups.push(mergedGroup);
}
});
return mergedGroups;

View file

@ -5,6 +5,8 @@ import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config/config";
const exemptWidgets = ["search"];
export async function widgetsFromConfig() {
checkAndCopyConfig("widgets.yaml");
@ -29,11 +31,16 @@ export async function cleanWidgetGroups(widgets) {
return widgets.map((widget, index) => {
const sanitizedOptions = widget.options;
const optionKeys = Object.keys(sanitizedOptions);
["url", "username", "password", "key"].forEach((pO) => {
if (optionKeys.includes(pO)) {
delete sanitizedOptions[pO];
}
});
if (!exemptWidgets.includes(widget.type)) {
["url", "username", "password", "key"].forEach((pO) => {
if (optionKeys.includes(pO)) {
// allow URL in search
if (widget.type !== "search" && pO !== "key") {
delete sanitizedOptions[pO];
}
}
});
}
return {
type: widget.type,

View file

@ -1,5 +1,6 @@
import getServiceWidget from "utils/config/service-helpers";
import { formatApiCall } from "utils/proxy/api-helpers";
import validateWidgetData from "utils/proxy/validate-widget-data";
import { httpProxy } from "utils/proxy/http";
import createLogger from "utils/logger";
import widgets from "widgets/widgets";
@ -31,6 +32,10 @@ export default async function credentialedProxyHandler(req, res) {
headers.Authorization = `Bearer ${widget.key}`;
} else if (widget.type === "proxmox") {
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
} else if (widget.type === "autobrr") {
headers["X-API-Token"] = `${widget.key}`;
} else if (widget.type === "tubearchivist") {
headers.Authorization = `Token ${widget.key}`;
} else {
headers["X-API-Key"] = `${widget.key}`;
}
@ -50,6 +55,10 @@ export default async function credentialedProxyHandler(req, res) {
logger.debug("HTTP Error %d calling %s//%s%s...", status, url.protocol, url.hostname, url.pathname);
}
if (!validateWidgetData(widget, endpoint, data)) {
return res.status(500).json({error: {message: "Invalid data", url, data}});
}
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
}

View file

@ -1,5 +1,6 @@
import getServiceWidget from "utils/config/service-helpers";
import { formatApiCall } from "utils/proxy/api-helpers";
import validateWidgetData from "utils/proxy/validate-widget-data";
import { httpProxy } from "utils/proxy/http";
import createLogger from "utils/logger";
import widgets from "widgets/widgets";
@ -32,6 +33,11 @@ export default async function genericProxyHandler(req, res, map) {
});
let resultData = data;
if (!validateWidgetData(widget, endpoint, resultData)) {
return res.status(status).json({error: {message: "Invalid data", url, data: resultData}});
}
if (status === 200 && map) {
resultData = map(data);
}
@ -44,6 +50,7 @@ export default async function genericProxyHandler(req, res, map) {
if (status >= 400) {
logger.debug("HTTP Error %d calling %s//%s%s...", status, url.protocol, url.hostname, url.pathname);
return res.status(status).json({error: {message: "HTTP Error", url, data}});
}
return res.status(status).send(resultData);

View file

@ -98,6 +98,6 @@ export async function httpProxy(url, params = {}) {
catch (err) {
logger.error("Error calling %s//%s%s...", url.protocol, url.hostname, url.pathname);
logger.error(err);
return [500, "application/json", { error: "Unexpected error" }, null];
return [500, "application/json", { error: {message: err?.message ?? "Unknown error", url, rawError: err} }, null];
}
}

View file

@ -3,5 +3,11 @@ import useSWR from "swr";
import { formatProxyUrl } from "./api-helpers";
export default function useWidgetAPI(widget, ...options) {
return useSWR(formatProxyUrl(widget, ...options));
const config = {};
if (options?.refreshInterval) {
config.refreshInterval = options.refreshInterval;
}
const { data, error } = useSWR(formatProxyUrl(widget, ...options), config);
// make the data error the top-level error
return { data, error: data?.error ?? error }
}

View file

@ -0,0 +1,22 @@
import widgets from "widgets/widgets";
export default function validateWidgetData(widget, endpoint, data) {
let valid = true;
let dataParsed;
try {
dataParsed = JSON.parse(data);
} catch (e) {
valid = false;
}
if (dataParsed) {
const validate = widgets[widget.type]?.mappings?.[endpoint]?.validate;
validate.forEach(key => {
if (dataParsed[key] === undefined) {
valid = false;
}
});
}
return valid;
}

View file

@ -12,9 +12,9 @@ export default function Component({ service }) {
const { data: adguardData, error: adguardError } = useWidgetAPI(widget, "stats");
if (adguardError) {
return <Container error={t("widget.api_error")} />;
return <Container error={adguardError} />;
}
if (!adguardData) {
return (
<Container service={service}>

View file

@ -14,7 +14,8 @@ export default function Component({ service }) {
const { data: failedLoginsData, error: failedLoginsError } = useWidgetAPI(widget, "login_failed");
if (usersError || loginsError || failedLoginsError) {
return <Container error={t("widget.api_error")} />;
const finalError = usersError ?? loginsError ?? failedLoginsError;
return <Container error={finalError} />;
}
if (!usersData || !loginsData || !failedLoginsData) {

View file

@ -0,0 +1,40 @@
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: statsData, error: statsError } = useWidgetAPI(widget, "stats");
const { data: filtersData, error: filtersError } = useWidgetAPI(widget, "filters");
const { data: indexersData, error: indexersError } = useWidgetAPI(widget, "indexers");
if (statsError || filtersError || indexersError) {
const finalError = statsError ?? filtersError ?? indexersError;
return <Container error={finalError} />;
}
if (!statsData || !filtersData || !indexersData) {
return (
<Container service={service}>
<Block label="autobrr.approvedPushes" />
<Block label="autobrr.rejectedPushes" />
<Block label="autobrr.filters" />
<Block label="autobrr.indexers" />
</Container>
);
}
return (
<Container service={service}>
<Block label="autobrr.approvedPushes" value={t("common.number", { value: statsData.push_approved_count })} />
<Block label="autobrr.rejectedPushes" value={t("common.number", { value: statsData.push_rejected_count })} />
<Block label="autobrr.filters" value={t("common.number", { value: filtersData.length })} />
<Block label="autobrr.indexers" value={t("common.number", { value: indexersData.length })} />
</Container>
);
}

View file

@ -0,0 +1,24 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
stats: {
endpoint: "release/stats",
validate: [
"push_approved_count",
"push_rejected_count"
]
},
filters: {
endpoint: "filters",
},
indexers: {
endpoint: "release/indexers",
},
},
};
export default widget;

View file

@ -12,8 +12,9 @@ export default function Component({ service }) {
const { data: episodesData, error: episodesError } = useWidgetAPI(widget, "episodes");
const { data: moviesData, error: moviesError } = useWidgetAPI(widget, "movies");
if (episodesError || moviesError) {
return <Container error="widget.api_error" />;
if (moviesError || episodesError) {
const finalError = moviesError ?? episodesError;
return <Container error={finalError} />;
}
if (!episodesData || !moviesData) {

View file

@ -0,0 +1,32 @@
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, "info");
if (error) {
return <Container error={error} />;
}
const totalObserved = Object.keys(data).length;
let diffsDetected = 0;
Object.keys(data).forEach((key) => {
if (data[key].last_checked === data[key].last_changed) {
diffsDetected += 1;
}
});
return (
<Container service={service}>
<Block label="changedetectionio.diffsDetected" value={t("common.number", { value: diffsDetected })} />
<Block label="changedetectionio.totalObserved" value={t("common.number", { value: totalObserved })} />
</Container>
);
}

View file

@ -0,0 +1,15 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/v1/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
info: {
method: "GET",
endpoint: "watch",
},
},
};
export default widget;

View file

@ -37,7 +37,7 @@ export default function Component({ service }) {
}
if (statsError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statsError} />;
}
if (!statsData || !dateRange) {

View file

@ -3,16 +3,22 @@ import dynamic from "next/dynamic";
const components = {
adguard: dynamic(() => import("./adguard/component")),
authentik: dynamic(() => import("./authentik/component")),
autobrr: dynamic(() => import("./autobrr/component")),
bazarr: dynamic(() => import("./bazarr/component")),
changedetectionio: dynamic(() => import("./changedetectionio/component")),
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
docker: dynamic(() => import("./docker/component")),
emby: dynamic(() => import("./emby/component")),
gluetun: dynamic(() => import("./gluetun/component")),
gotify: dynamic(() => import("./gotify/component")),
hdhomerun: dynamic(() => import("./hdhomerun/component")),
homebridge: dynamic(() => import("./homebridge/component")),
jackett: dynamic(() => import("./jackett/component")),
jellyfin: dynamic(() => import("./emby/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")),
lidarr: dynamic(() => import("./lidarr/component")),
mastodon: dynamic(() => import("./mastodon/component")),
navidrome: dynamic(() => import("./navidrome/component")),
npm: dynamic(() => import("./npm/component")),
nzbget: dynamic(() => import("./nzbget/component")),
ombi: dynamic(() => import("./ombi/component")),
@ -22,6 +28,7 @@ const components = {
portainer: dynamic(() => import("./portainer/component")),
prowlarr: dynamic(() => import("./prowlarr/component")),
proxmox: dynamic(() => import("./proxmox/component")),
pyload: dynamic(() => import("./pyload/component")),
qbittorrent: dynamic(() => import("./qbittorrent/component")),
radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")),
@ -33,7 +40,10 @@ const components = {
tautulli: dynamic(() => import("./tautulli/component")),
traefik: dynamic(() => import("./traefik/component")),
transmission: dynamic(() => import("./transmission/component")),
tubearchivist: dynamic(() => import("./tubearchivist/component")),
truenas: dynamic(() => import("./truenas/component")),
unifi: dynamic(() => import("./unifi/component")),
watchtower: dynamic(() => import("./watchtower/component")),
};
export default components;

View file

@ -17,8 +17,9 @@ export default function Component({ service }) {
const { data: statsData, error: statsError } = useSWR(`/api/docker/stats/${widget.container}/${widget.server || ""}`);
if (statsError || statusError) {
return <Container error={t("widget.api_error")} />;
if (statsError || statsData?.error || statusError || statusData?.error) {
const finalError = statsError ?? statsData?.error ?? statusError ?? statusData?.error;
return <Container error={finalError} />;
}
if (statusData && statusData.status !== "running") {

View file

@ -1,10 +1,10 @@
import useSWR from "swr";
import { useTranslation } from "next-i18next";
import { BsVolumeMuteFill, BsFillPlayFill, BsPauseFill, BsCpu, BsFillCpuFill } from "react-icons/bs";
import { MdOutlineSmartDisplay } from "react-icons/md";
import Container from "components/services/widget/container";
import { formatProxyUrl, formatProxyUrlWithSegments } from "utils/proxy/api-helpers";
import { formatProxyUrlWithSegments } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
function ticksToTime(ticks) {
const milliseconds = ticks / 10000;
@ -157,7 +157,7 @@ export default function Component({ service }) {
data: sessionsData,
error: sessionsError,
mutate: sessionMutate,
} = useSWR(formatProxyUrl(widget, "Sessions"), {
} = useWidgetAPI(widget, "Sessions", {
refreshInterval: 5000,
});
@ -172,7 +172,7 @@ export default function Component({ service }) {
}
if (sessionsError) {
return <Container error={t("widget.api_error")} />;
return <Container error={sessionsError} />;
}
if (!sessionsData) {

View file

@ -10,7 +10,7 @@ const widget = {
},
PlayControl: {
method: "POST",
enpoint: "Sessions/{sessionId}/Playing/{command}",
endpoint: "Sessions/{sessionId}/Playing/{command}",
segments: ["sessionId", "command"],
},
},

View file

@ -0,0 +1,31 @@
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: gluetunData, error: gluetunError } = useWidgetAPI(widget, "ip");
if (gluetunError) {
return <Container error={gluetunError} />;
}
if (!gluetunData) {
return (
<Container service={service}>
<Block label="gluetun.public_ip" />
<Block label="gluetun.region" />
<Block label="gluetun.country" />
</Container>
);
}
return (
<Container service={service}>
<Block label="gluetun.public_ip" value={gluetunData.public_ip} />
<Block label="gluetun.region" value={gluetunData.region} />
<Block label="gluetun.country" value={gluetunData.country} />
</Container>
);
}

View file

@ -0,0 +1,19 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/v1/{endpoint}",
proxyHandler: genericProxyHandler,
mappings: {
ip: {
endpoint: "publicip/ip",
validate: [
"public_ip",
"region",
"country"
]
},
},
};
export default widget;

View file

@ -1,12 +1,8 @@
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: appsData, error: appsError } = useWidgetAPI(widget, "application");
@ -14,7 +10,8 @@ export default function Component({ service }) {
const { data: clientsData, error: clientsError } = useWidgetAPI(widget, "client");
if (appsError || messagesError || clientsError) {
return <Container error={t("widget.api_error")} />;
const finalError = appsError ?? messagesError ?? clientsError;
return <Container error={finalError} />;
}

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: channelsData, error: channelsError } = useWidgetAPI(widget, "lineup");
if (channelsError) {
return <Container error={channelsError} />;
}
if (!channelsData) {
return (
<Container service={service}>
<Block label="hdhomerun.channels" />
<Block label="hdhomerun.hd" />
</Container>
);
}
const hdChannels = channelsData?.filter((channel) => channel.HD === 1);
return (
<Container service={service}>
<Block label="hdhomerun.channels" value={channelsData.length } />
<Block label="hdhomerun.hd" value={hdChannels.length} />
</Container>
);
}

View file

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

View file

@ -0,0 +1,51 @@
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: homebridgeData, error: homebridgeError } = useWidgetAPI(widget, "info");
if (homebridgeError) {
return <Container error={homebridgeError} />;
}
if (!homebridgeData) {
return (
<Container service={service}>
<Block label="widget.status" />
<Block label="homebridge.updates" />
<Block label="homebridge.child_bridges" />
</Container>
);
}
return (
<Container service={service}>
<Block
label="widget.status"
value={`${homebridgeData.status[0].toUpperCase()}${homebridgeData.status.substr(1)}`}
/>
<Block
label="homebridge.updates"
value={
(homebridgeData.updateAvailable || homebridgeData.plugins?.updatesAvailable)
? t("homebridge.update_available")
: t("homebridge.up_to_date")}
/>
{homebridgeData?.childBridges?.total > 0 &&
<Block
label="homebridge.child_bridges"
value={t("homebridge.child_bridges_status", {
total: homebridgeData.childBridges.total,
ok: homebridgeData.childBridges.running
})}
/>}
</Container>
);
}

View file

@ -0,0 +1,106 @@
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 = "homebridgeProxyHandler";
const sessionTokenCacheKey = `${proxyName}__sessionToken`;
const logger = createLogger(proxyName);
async function login(widget) {
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 headers = { "Content-Type": "application/json" };
// eslint-disable-next-line no-unused-vars
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
method: "POST",
body: JSON.stringify(loginBody),
headers,
});
try {
const { access_token: accessToken, expires_in: expiresIn } = JSON.parse(data.toString());
cache.put(sessionTokenCacheKey, accessToken, (expiresIn * 1000) - 5 * 60 * 1000); // expiresIn (s) - 5m
return { accessToken };
} catch (e) {
logger.error("Unable to login to Homebridge API: %s", e);
}
return { accessToken: false };
}
async function apiCall(widget, endpoint) {
const headers = {
"content-type": "application/json",
"Authorization": `Bearer ${cache.get(sessionTokenCacheKey)}`,
}
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
const method = "GET";
let [status, contentType, data, responseHeaders] = await httpProxy(url, {
method,
headers,
});
if (status === 401) {
logger.debug("Homebridge API rejected the request, attempting to obtain new session token");
const { accessToken } = login(widget);
headers.Authorization = `Bearer ${accessToken}`;
// retry the request, now with the new session token
[status, contentType, data, responseHeaders] = await httpProxy(url, {
method,
headers,
});
}
if (status !== 200) {
logger.error("Error getting data from Homebridge: %d. Data: %s", status, data);
}
return { status, contentType, data: JSON.parse(data.toString()), responseHeaders };
}
export default async function homebridgeProxyHandler(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)) {
await login(widget);
}
const { data: statusData } = await apiCall(widget, "status/homebridge");
const { data: versionData } = await apiCall(widget, "status/homebridge-version");
const { data: childBridgeData } = await apiCall(widget, "status/homebridge/child-bridges");
const { data: pluginsData } = await apiCall(widget, "plugins");
return res.status(200).send({
status: statusData?.status,
updateAvailable: versionData?.updateAvailable,
plugins: {
updatesAvailable: pluginsData?.filter(p => p.updateAvailable).length,
},
childBridges: {
running: childBridgeData?.filter(cb => cb.status === "ok").length,
total: childBridgeData?.length
}
});
}

View file

@ -0,0 +1,14 @@
import homebridgeProxyHandler from "./proxy";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: homebridgeProxyHandler,
mappings: {
info: {
endpoint: "/",
}
},
};
export default widget;

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: indexersData, error: indexersError } = useWidgetAPI(widget, "indexers");
if (indexersError) {
return <Container error={t("widget.api_error")} />;
return <Container error={indexersError} />;
}
if (!indexersData) {

View file

@ -1,18 +1,14 @@
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: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
if (statsError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statsError} />;
}
if (!statsData) {

View file

@ -7,6 +7,11 @@ const widget = {
mappings: {
"request/count": {
endpoint: "request/count",
validate: [
"pending",
"approved",
"available"
]
},
},
};

View file

@ -14,7 +14,8 @@ export default function Component({ service }) {
const { data: queueData, error: queueError } = useWidgetAPI(widget, "queue/status");
if (albumsError || wantedError || queueError) {
return <Container error={t("widget.api_error")} />;
const finalError = albumsError ?? wantedError ?? queueError;
return <Container error={finalError} />;
}
if (!albumsData || !wantedData || !queueData) {

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: statsData, error: statsError } = useWidgetAPI(widget, "instance");
if (statsError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statsError} />;
}
if (!statsData) {

View file

@ -0,0 +1,56 @@
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
function SinglePlayingEntry({ entry }) {
const { username, artist, title, album } = entry;
let fullTitle = title;
if (artist) fullTitle = `${artist} - ${title}`;
if (album) fullTitle += `${album}`;
if (username) fullTitle += ` (${username})`;
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">
<div className="text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2">
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden">{fullTitle}</div>
</div>
</div>
);
}
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: navidromeData, error: navidromeError } = useWidgetAPI(widget, "getNowPlaying");
if (navidromeError || navidromeData?.["subsonic-response"]?.error) {
return <Container error={navidromeError ?? navidromeData?.["subsonic-response"]?.error} />;
}
if (!navidromeData) {
return (
<SinglePlayingEntry entry={{ title: t("navidrome.please_wait") }} />
);
}
const { nowPlaying } = navidromeData["subsonic-response"];
if (!nowPlaying.entry) {
// nothing playing
return (
<SinglePlayingEntry entry={{ title: t("navidrome.nothing_streaming") }} />
);
}
const nowPlayingEntries = Object.values(nowPlaying.entry);
return (
<div className="flex flex-col pb-1 mx-1">
{nowPlayingEntries.map((entry) => (
<SinglePlayingEntry key={entry.id} entry={entry} />
))}
</div>
);
}

View file

@ -0,0 +1,14 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/rest/{endpoint}?u={user}&t={token}&s={salt}&v=1.16.1&c=homepage&f=json",
proxyHandler: genericProxyHandler,
mappings: {
"getNowPlaying": {
endpoint: "getNowPlaying",
},
},
};
export default widget;

View file

@ -1,18 +1,14 @@
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, "nginx/proxy-hosts");
if (infoError) {
return <Container error={t("widget.api_error")} />;
return <Container error={infoError} />;
}
if (!infoData) {

View file

@ -1,6 +1,33 @@
import cache from "memory-cache";
import getServiceWidget from "utils/config/service-helpers";
import { formatApiCall } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
import widgets from "widgets/widgets";
import createLogger from "utils/logger";
const proxyName = "npmProxyHandler";
const tokenCacheKey = `${proxyName}__token`;
const logger = createLogger(proxyName);
async function login(loginUrl, username, password) {
const authResponse = await httpProxy(loginUrl, {
method: "POST",
body: JSON.stringify({ identity: username, secret: password }),
headers: {
"Content-Type": "application/json",
},
});
const status = authResponse[0];
const data = JSON.parse(Buffer.from(authResponse[2]).toString());
if (status === 200) {
cache.put(tokenCacheKey, data.token);
}
return [status, data.token ?? data];
}
export default async function npmProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
@ -14,27 +41,54 @@ export default async function npmProxyHandler(req, res) {
if (widget) {
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
const loginUrl = `${widget.url}/api/tokens`;
const body = { identity: widget.username, secret: widget.password };
const authResponse = await fetch(loginUrl, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
}).then((response) => response.json());
let status;
let contentType;
let data;
let token = cache.get(tokenCacheKey);
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);
}
}
const apiResponse = await fetch(url, {
[status, contentType, data] = await httpProxy(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authResponse.token}`,
Authorization: `Bearer ${token}`,
},
}).then((response) => response.json());
});
return res.send(apiResponse);
if (status === 403) {
logger.debug(`HTTTP ${status} retrieving data from npm api, logging in and trying again.`);
cache.del(tokenCacheKey);
[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);
}
// eslint-disable-next-line no-unused-vars
[status, contentType, data] = await httpProxy(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
}
if (status !== 200) {
return res.status(status).send(data);
}
return res.send(data);
}
}

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
if (statusError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statusError} />;
}
if (!statusData) {

View file

@ -1,18 +1,14 @@
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: statsData, error: statsError } = useWidgetAPI(widget, "Request/count");
if (statsError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statsError} />;
}
if (!statsData) {

View file

@ -1,18 +1,14 @@
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: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
if (statsError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statsError} />;
}
if (!statsData) {

View file

@ -7,6 +7,11 @@ const widget = {
mappings: {
"request/count": {
endpoint: "request/count",
validate: [
"pending",
"approved",
"available",
],
},
},
};

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: piholeData, error: piholeError } = useWidgetAPI(widget, "api.php");
if (piholeError) {
return <Container error={t("widget.api_error")} />;
return <Container error={piholeError} />;
}
if (!piholeData) {

View file

@ -7,6 +7,11 @@ const widget = {
mappings: {
"api.php": {
endpoint: "api.php",
validate: [
"dns_queries_today",
"ads_blocked_today",
"domains_being_blocked"
]
},
},
};

View file

@ -1,21 +1,20 @@
import useSWR from "swr";
import { useTranslation } from "next-i18next";
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { formatProxyUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: plexData, error: plexAPIError } = useSWR(formatProxyUrl(widget, "unified"), {
const { data: plexData, error: plexAPIError } = useWidgetAPI(widget, "unified", {
refreshInterval: 5000,
});
if (plexAPIError) {
return <Container error={t("widget.api_error")} />;
return <Container error={plexAPIError} />;
}
if (!plexData) {

View file

@ -44,7 +44,7 @@ async function fetchFromPlexAPI(endpoint, widget) {
if (status !== 200) {
logger.error("HTTP %d communicating with Plex. Data: %s", status, data.toString());
return [status, data.toString()];
return [status, data];
}
try {
@ -65,6 +65,11 @@ export default async function plexProxyHandler(req, res) {
logger.debug("Getting streams from Plex API");
let streams;
let [status, apiData] = await fetchFromPlexAPI("/status/sessions", widget);
if (status !== 200) {
return res.status(status).json({error: {message: "HTTP error communicating with Plex API", data: Buffer.from(apiData).toString()}});
}
if (apiData && apiData.MediaContainer) {
streams = apiData.MediaContainer._attributes.size;
}

View file

@ -14,7 +14,7 @@ export default function Component({ service }) {
});
if (containersError) {
return <Container error={t("widget.api_error")} />;
return <Container error={containersError} />;
}
if (!containersData) {

View file

@ -1,19 +1,16 @@
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: indexersData, error: indexersError } = useWidgetAPI(widget, "indexer");
const { data: grabsData, error: grabsError } = useWidgetAPI(widget, "indexerstats");
if (indexersError || grabsError) {
return <Container error={t("widget.api_error")} />;
const finalError = indexersError ?? grabsError;
return <Container error={finalError} />;
}
if (!indexersData || !grabsData) {

View file

@ -16,7 +16,7 @@ export default function Component({ service }) {
const { data: clusterData, error: clusterError } = useWidgetAPI(widget, "cluster/resources");
if (clusterError) {
return <Container error={t("widget.api_error")} />;
return <Container error={clusterError} />;
}
if (!clusterData || !clusterData.data) {

View file

@ -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 { t } = useTranslation();
const { widget } = service;
const { data: pyloadData, error: pyloadError } = useWidgetAPI(widget, "status");
if (pyloadError) {
return <Container error={pyloadError} />;
}
if (!pyloadData) {
return (
<Container service={service}>
<Block label="pyload.speed" />
<Block label="pyload.active" />
<Block label="pyload.queue" />
<Block label="pyload.total" />
</Container>
);
}
return (
<Container service={service}>
<Block label="pyload.speed" value={t("common.bitrate", { value: pyloadData.speed })} />
<Block label="pyload.active" value={t("common.number", { value: pyloadData.active })} />
<Block label="pyload.queue" value={t("common.number", { value: pyloadData.queue })} />
<Block label="pyload.total" value={t("common.number", { value: pyloadData.total })} />
</Container>
);
}

102
src/widgets/pyload/proxy.js Normal file
View file

@ -0,0 +1,102 @@
import cache from 'memory-cache';
import getServiceWidget from 'utils/config/service-helpers';
import { formatApiCall } from 'utils/proxy/api-helpers';
import widgets from 'widgets/widgets';
import createLogger from 'utils/logger';
import { httpProxy } from 'utils/proxy/http';
const proxyName = 'pyloadProxyHandler';
const logger = createLogger(proxyName);
const sessionCacheKey = `${proxyName}__sessionId`;
const isNgCacheKey = `${proxyName}__isNg`;
async function fetchFromPyloadAPI(url, sessionId, params) {
const options = {
body: params
? Object.keys(params)
.map((prop) => `${prop}=${params[prop]}`)
.join('&')
: `session=${sessionId}`,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
};
// see https://github.com/benphelps/homepage/issues/517
const isNg = cache.get(isNgCacheKey);
if (isNg && !params) {
delete options.body;
options.headers.Cookie = cache.get(sessionCacheKey);
}
// eslint-disable-next-line no-unused-vars
const [status, contentType, data, responseHeaders] = await httpProxy(url, options);
let returnData;
try {
returnData = JSON.parse(Buffer.from(data).toString());
} catch(e) {
logger.error(`Error logging into pyload API: ${JSON.stringify(data)}`);
returnData = data;
}
return [status, returnData, responseHeaders];
}
async function login(loginUrl, username, password = '') {
const [status, sessionId, responseHeaders] = await fetchFromPyloadAPI(loginUrl, null, { username, password });
// this API actually returns status 200 even on login failure
if (status !== 200 || sessionId === false) {
logger.error(`HTTP ${status} logging into Pyload API, returned: ${JSON.stringify(sessionId)}`);
} else if (responseHeaders['set-cookie']?.join().includes('pyload_session')) {
// Support pyload-ng, see https://github.com/benphelps/homepage/issues/517
cache.put(isNgCacheKey, true);
const sessionCookie = responseHeaders['set-cookie'][0];
cache.put(sessionCacheKey, sessionCookie, 60 * 60 * 23 * 1000); // cache for 23h
} else {
cache.put(sessionCacheKey, sessionId);
}
return sessionId;
}
export default async function pyloadProxyHandler(req, res) {
const { group, service, endpoint } = req.query;
try {
if (group && service) {
const widget = await getServiceWidget(group, service);
if (widget) {
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
const loginUrl = `${widget.url}/api/login`;
let sessionId = cache.get(sessionCacheKey) ?? await login(loginUrl, widget.username, widget.password);
let [status, data] = await fetchFromPyloadAPI(url, sessionId);
if (status === 403 || status === 401) {
logger.info('Failed to retrieve data from Pyload API, trying to login again...');
cache.del(sessionCacheKey);
sessionId = await login(loginUrl, widget.username, widget.password);
[status, data] = await fetchFromPyloadAPI(url, sessionId);
}
if (data?.error || status !== 200) {
try {
return res.status(status).send({error: {message: "HTTP error communicating with Plex API", data: Buffer.from(data).toString()}});
} catch (e) {
return res.status(status).send({error: {message: "HTTP error communicating with Plex API", data}});
}
}
return res.json(data);
}
}
} catch (e) {
logger.error(e);
return res.status(500).send({error: {message: `Error communicating with Plex API: ${e.toString()}`}});
}
return res.status(400).json({ error: 'Invalid proxy service type' });
}

View file

@ -0,0 +1,14 @@
import pyloadProxyHandler from "./proxy";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: pyloadProxyHandler,
mappings: {
"status": {
endpoint: "statusServer",
}
}
}
export default widget;

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents/info");
if (torrentError) {
return <Container error={t("widget.api_error")} />;
return <Container error={torrentError} />;
}
if (!torrentData) {

View file

@ -1,25 +1,23 @@
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: moviesData, error: moviesError } = useWidgetAPI(widget, "movie");
const { data: queuedData, error: queuedError } = useWidgetAPI(widget, "queue/status");
if (moviesError || queuedError) {
return <Container error={t("widget.api_error")} />;
const finalError = moviesError ?? queuedError;
return <Container error={finalError} />;
}
if (!moviesData || !queuedData) {
return (
<Container service={service}>
<Block label="radarr.wanted" />
<Block label="radarr.missing" />
<Block label="radarr.queued" />
<Block label="radarr.movies" />
</Container>
@ -29,6 +27,7 @@ export default function Component({ service }) {
return (
<Container service={service}>
<Block label="radarr.wanted" value={moviesData.wanted} />
<Block label="radarr.missing" value={moviesData.missing} />
<Block label="radarr.queued" value={queuedData.totalCount} />
<Block label="radarr.movies" value={moviesData.have} />
</Container>

View file

@ -9,12 +9,16 @@ const widget = {
movie: {
endpoint: "movie",
map: (data) => ({
wanted: jsonArrayFilter(data, (item) => item.isAvailable === false).length,
have: jsonArrayFilter(data, (item) => item.isAvailable === true).length,
wanted: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile && item.isAvailable).length,
have: jsonArrayFilter(data, (item) => item.hasFile).length,
missing: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile).length,
}),
},
"queue/status": {
endpoint: "queue/status",
validate: [
"totalCount"
]
},
},
};

View file

@ -14,7 +14,8 @@ export default function Component({ service }) {
const { data: queueData, error: queueError } = useWidgetAPI(widget, "queue/status");
if (booksError || wantedError || queueError) {
return <Container error={t("widget.api_error")} />;
const finalError = booksError ?? wantedError ?? queueError;
return <Container error={finalError} />;
}
if (!booksData || !wantedData || !queueData) {

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: statusData, error: statusError } = useWidgetAPI(widget);
if (statusError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statusError} />;
}
if (!statusData) {

View file

@ -22,7 +22,7 @@ export default function Component({ service }) {
const { data: queueData, error: queueError } = useWidgetAPI(widget, "queue");
if (queueError) {
return <Container error={t("widget.api_error")} />;
return <Container error={queueError} />;
}
if (!queueData) {

View file

@ -7,6 +7,9 @@ const widget = {
mappings: {
queue: {
endpoint: "queue",
validate: [
"queue"
]
},
},
};

View file

@ -1,12 +1,8 @@
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: wantedData, error: wantedError } = useWidgetAPI(widget, "wanted/missing");
@ -14,7 +10,8 @@ export default function Component({ service }) {
const { data: seriesData, error: seriesError } = useWidgetAPI(widget, "series");
if (wantedError || queuedError || seriesError) {
return <Container error={t("widget.api_error")} />;
const finalError = wantedError ?? queuedError ?? seriesError;
return <Container error={finalError} />;
}
if (!wantedData || !queuedData || !seriesData) {

View file

@ -11,12 +11,21 @@ const widget = {
map: (data) => ({
total: asJson(data).length,
}),
validate: [
"total"
]
},
queue: {
endpoint: "queue",
validate: [
"totalRecords"
]
},
"wanted/missing": {
endpoint: "wanted/missing",
validate: [
"totalRecords"
]
},
},
};

View file

@ -11,8 +11,8 @@ export default function Component({ service }) {
const { data: speedtestData, error: speedtestError } = useWidgetAPI(widget, "speedtest/latest");
if (speedtestError || (speedtestData && !speedtestData.data)) {
return <Container error={t("widget.api_error")} />;
if (speedtestError) {
return <Container error={speedtestError} />;
}
if (!speedtestData) {

View file

@ -7,6 +7,9 @@ const widget = {
mappings: {
"speedtest/latest": {
endpoint: "speedtest/latest",
validate: [
"data"
]
},
},
};

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: statsData, error: statsError } = useWidgetAPI(widget, "status");
if (statsError) {
return <Container error={t("widget.api_error")} />;
return <Container error={statsError} />;
}
if (!statsData) {

View file

@ -7,6 +7,11 @@ const widget = {
mappings: {
status: {
endpoint: "status",
validate: [
"numActiveSessions",
"numConnections",
"bytesProxied"
]
},
},
};

View file

@ -1,11 +1,10 @@
/* eslint-disable camelcase */
import useSWR from "swr";
import { useTranslation } from "next-i18next";
import { BsFillPlayFill, BsPauseFill, BsCpu, BsFillCpuFill } from "react-icons/bs";
import { MdOutlineSmartDisplay, MdSmartDisplay } from "react-icons/md";
import Container from "components/services/widget/container";
import { formatProxyUrl } from "utils/proxy/api-helpers";
import useWidgetAPI from "utils/proxy/use-widget-api";
function millisecondsToTime(milliseconds) {
const seconds = Math.floor((milliseconds / 1000) % 60);
@ -119,12 +118,12 @@ export default function Component({ service }) {
const { widget } = service;
const { data: activityData, error: activityError } = useSWR(formatProxyUrl(widget, "get_activity"), {
const { data: activityData, error: activityError } = useWidgetAPI(widget, "get_activity", {
refreshInterval: 5000,
});
if (activityError) {
return <Container error={t("widget.api_error")} />;
return <Container error={activityError} />;
}
if (!activityData) {

View file

@ -1,18 +1,14 @@
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: traefikData, error: traefikError } = useWidgetAPI(widget, "overview");
if (traefikError) {
return <Container error={t("widget.api_error")} />;
return <Container error={traefikError} />;
}
if (!traefikData) {

View file

@ -7,6 +7,9 @@ const widget = {
mappings: {
overview: {
endpoint: "overview",
validate: [
"http"
]
},
},
};

View file

@ -12,7 +12,7 @@ export default function Component({ service }) {
const { data: torrentData, error: torrentError } = useWidgetAPI(widget);
if (torrentError) {
return <Container error={t("widget.api_error")} />;
return <Container error={torrentError} />;
}
if (!torrentData) {

View file

@ -68,6 +68,7 @@ export default async function transmissionProxyHandler(req, res) {
if (status !== 200) {
logger.error("Error getting data from Transmission: %d. Data: %s", status, data);
return res.status(500).send({error: {message:"Error getting data from Transmission", url, data}});
}
if (contentType) res.setHeader("Content-Type", contentType);

View file

@ -0,0 +1,66 @@
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";
const processUptime = uptime => {
const seconds = uptime.toFixed(0);
const levels = [
[Math.floor(seconds / 31536000), 'year'],
[Math.floor((seconds % 31536000) / 2592000), 'month'],
[Math.floor(((seconds % 31536000) % 2592000) / 86400), 'day'],
[Math.floor(((seconds % 31536000) % 86400) / 3600), 'hour'],
[Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), 'minute'],
[(((seconds % 31536000) % 86400) % 3600) % 60, 'second'],
];
for (let i = 0; i< levels.length; i += 1) {
const level = levels[i];
if (level[0] > 0){
return {
value: level[0],
unit: level[1]
}
}
}
return {
value: 0,
unit: 'second'
};
}
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts");
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
if (alertError || statusError) {
const finalError = alertError ?? statusError;
return <Container error={finalError} />;
}
if (!alertData || !statusData) {
return (
<Container service={service}>
<Block label="truenas.load" />
<Block label="truenas.uptime" />
<Block label="truenas.alerts" />
</Container>
);
}
return (
<Container service={service}>
<Block label="truenas.load" value={t("common.number", { value: statusData.loadavg[0] })} />
<Block label="truenas.uptime" value={t('truenas.time', processUptime(statusData.uptime_seconds))} />
<Block label="truenas.alerts" value={t("common.number", { value: alertData.pending })} />
</Container>
);
}

View file

@ -0,0 +1,25 @@
import { jsonArrayFilter } from "utils/proxy/api-helpers";
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/v2.0/{endpoint}",
proxyHandler: genericProxyHandler,
mappings: {
alerts: {
endpoint: "alert/list",
map: (data) => ({
pending: jsonArrayFilter(data, (item) => item?.dismissed === false).length,
}),
},
status: {
endpoint: "system/info",
validate: [
"loadavg",
"uptime_seconds"
]
},
},
};
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: downloadsData, error: downloadsError } = useWidgetAPI(widget, "downloads");
const { data: videosData, error: videosError } = useWidgetAPI(widget, "videos");
const { data: channelsData, error: channelsError } = useWidgetAPI(widget, "channels");
const { data: playlistsData, error: playlistsError } = useWidgetAPI(widget, "playlists");
if (downloadsError || videosError || channelsError || playlistsError) {
const finalError = downloadsError ?? videosError ?? channelsError ?? playlistsError;
return <Container error={finalError} />;
}
if (!downloadsData || !videosData || !channelsData || !playlistsData) {
return (
<Container service={service}>
<Block label="tubearchivist.downloads" />
<Block label="tubearchivist.videos" />
<Block label="tubearchivist.channels" />
<Block label="tubearchivist.playlists" />
</Container>
);
}
return (
<Container service={service}>
<Block label="tubearchivist.downloads" value={t("common.number", { value: downloadsData?.paginate?.total_hits })} />
<Block label="tubearchivist.videos" value={t("common.number", { value: videosData?.paginate?.total_hits })} />
<Block label="tubearchivist.channels" value={t("common.number", { value: channelsData?.paginate?.total_hits })} />
<Block label="tubearchivist.playlists" value={t("common.number", { value: playlistsData?.paginate?.total_hits })} />
</Container>
);
}

View file

@ -0,0 +1,35 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
downloads: {
endpoint: "download",
validate: [
"paginate",
]
},
videos: {
endpoint: "video",
validate: [
"paginate",
]
},
channels: {
endpoint: "channel",
validate: [
"paginate",
]
},
playlists: {
endpoint: "playlist",
validate: [
"paginate",
]
},
},
};
export default widget;

View file

@ -11,8 +11,8 @@ export default function Component({ service }) {
const { data: statsData, error: statsError } = useWidgetAPI(widget, "stat/sites");
if (statsError || statsData?.error) {
return <Container error={t("widget.api_error")} />;
if (statsError) {
return <Container error={statsError} />;
}
const defaultSite = statsData?.data?.find(s => s.name === "default");
@ -31,28 +31,25 @@ export default function Component({ service }) {
const wan = defaultSite.health.find(h => h.subsystem === "wan");
const lan = defaultSite.health.find(h => h.subsystem === "lan");
const wlan = defaultSite.health.find(h => h.subsystem === "wlan");
const data = {
name: wan.gw_name,
uptime: wan["gw_system-stats"].uptime,
up: wan.status === 'ok',
wlan: {
users: wlan.num_user,
status: wlan.status
},
lan: {
users: lan.num_user,
status: lan.status
},
};
[wan, lan, wlan].forEach(s => {
s.up = s.status === "ok" // eslint-disable-line no-param-reassign
s.show = s.status !== "unknown" // eslint-disable-line no-param-reassign
});
const uptime = `${t("common.number", { value: data.uptime / 86400, maximumFractionDigits: 1 })} ${t("unifi.days")}`;
const uptime = wan["gw_system-stats"] ? `${t("common.number", { value: wan["gw_system-stats"].uptime / 86400, maximumFractionDigits: 1 })} ${t("unifi.days")}` : null;
return (
<Container service={service}>
<Block label="unifi.uptime" value={ uptime } />
<Block label="unifi.wan" value={ data.up ? t("unifi.up") : t("unifi.down") } />
<Block label="unifi.lan_users" value={ t("common.number", { value: data.lan.users }) } />
<Block label="unifi.wlan_users" value={ t("common.number", { value: data.wlan.users }) } />
{uptime && <Block label="unifi.uptime" value={ uptime } />}
{wan.show && <Block label="unifi.wan" value={ wan.status === "ok" ? t("unifi.up") : t("unifi.down") } />}
{lan.show && <Block label="unifi.lan_users" value={ t("common.number", { value: lan.num_user }) } />}
{lan.show && !wlan.show && <Block label="unifi.lan_devices" value={ t("common.number", { value: lan.num_adopted }) } />}
{lan.show && !wlan.show && <Block label="unifi.lan" value={ lan.up ? t("unifi.up") : t("unifi.down") } />}
{wlan.show && <Block label="unifi.wlan_users" value={ t("common.number", { value: wlan.num_user }) } />}
{wlan.show && !lan.show && <Block label="unifi.wlan_devices" value={ t("common.number", { value: wlan.num_adopted }) } />}
{wlan.show && !lan.show && <Block label="unifi.wlan" value={ wlan.up ? t("unifi.up") : t("unifi.down") } />}
</Container>
);
}

View file

@ -74,7 +74,7 @@ export default async function unifiProxyHandler(req, res) {
// don't make two requests each time data from Unifi is required
[status, contentType, data, responseHeaders] = await httpProxy(widget.url);
prefix = "";
if (responseHeaders["x-csrf-token"]) {
if (responseHeaders?.["x-csrf-token"]) {
prefix = udmpPrefix;
}
cache.put(prefixCacheKey, prefix);
@ -88,17 +88,18 @@ export default async function unifiProxyHandler(req, res) {
setCookieHeader(url, params);
[status, contentType, data, responseHeaders] = await httpProxy(url, params);
if (status === 401) {
logger.debug("Unifi isn't logged in or rejected the reqeust, attempting login.");
[status, contentType, data, responseHeaders] = await login(widget);
if (status !== 200) {
logger.error("HTTP %d logging in to Unifi. Data: %s", status, data);
return res.status(status).end(data);
return res.status(status).json({error: {message: `HTTP Error ${status}`, url, data}});
}
const json = JSON.parse(data.toString());
if (!(json?.meta?.rc === "ok" || json.login_time)) {
if (!(json?.meta?.rc === "ok" || json?.login_time || json?.update_time)) {
logger.error("Error logging in to Unifi: Data: %s", data);
return res.status(401).end(data);
}
@ -112,6 +113,7 @@ export default async function unifiProxyHandler(req, res) {
if (status !== 200) {
logger.error("HTTP %d getting data from Unifi endpoint %s. Data: %s", status, url.href, data);
return res.status(status).json({error: {message: `HTTP Error ${status}`, url, data}});
}
if (contentType) res.setHeader("Content-Type", contentType);

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: watchData, error: watchError } = useWidgetAPI(widget, "watchtower");
if (watchError) {
return <Container error={watchError} />;
}
if (!watchData) {
return (
<Container service={service}>
<Block label="watchtower.containers_scanned " />
<Block label="watchtower.containers_updated" />
<Block label="watchtower.containers_failed" />
</Container>
);
}
return (
<Container service={service}>
<Block label="watchtower.containers_scanned" value={t("common.number", { value: watchData.watchtower_containers_scanned })} />
<Block label="watchtower.containers_updated" value={t("common.number", { value: watchData.watchtower_containers_updated })} />
<Block label="watchtower.containers_failed" value={t("common.number", { value: watchData.watchtower_containers_failed })} />
</Container>
);
}

View file

@ -0,0 +1,49 @@
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 = "watchtowerProxyHandler";
const logger = createLogger(proxyName);
export default async function watchtowerProxyHandler(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(widgets[widget.type].api, { endpoint, ...widget }));
const [status, contentType, data] = await httpProxy(url, {
method: "GET",
headers: {
"Authorization": `Bearer ${widget.key}`,
}
});
if (status !== 200 || !data) {
logger.error("Error getting data from WatchTower: %d. Data: %s", status, data);
return res.status(status).json({error: {message: `HTTP Error ${status}`, url, data}});
}
const cleanData = data.toString().split("\n").filter(s => s.startsWith("watchtower"));
const jsonRes = {}
cleanData.map(e => e.split(" ")).forEach(strArray => {
const [key, value] = strArray
jsonRes[key] = value
});
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(jsonRes);
}

Some files were not shown because too many files have changed in this diff Show more