Run pre-commit hooks over existing codebase

Co-Authored-By: Ben Phelps <ben@phelps.io>
pull/2209/head
shamoon 7 months ago
parent fa50bbad9c
commit 19c25713c4

@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the - Focusing on what is best not just for us as individuals, but for the
overall community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or - The use of sexualized language or imagery, and sexual attention or
advances of any kind advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email - Publishing others' private information, such as a physical or email
address, without their explicit permission address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities

@ -1,4 +1,5 @@
# Contributing to Homepage # Contributing to Homepage
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
- Reporting a bug - Reporting a bug
@ -8,15 +9,19 @@ We love your input! We want to make contributing to this project as easy and tra
- Becoming a maintainer - Becoming a maintainer
## We Develop with Github ## We Develop with Github
We use github to host code, to track issues and feature requests, as well as accept pull requests. We use github to host code, to track issues and feature requests, as well as accept pull requests.
## Any contributions you make will be under the GNU General Public License v3.0 ## Any contributions you make will be under the GNU General Public License v3.0
In short, when you submit code changes, your submissions are understood to be under the same [GNU General Public License v3.0](https://choosealicense.com/licenses/gpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern. In short, when you submit code changes, your submissions are understood to be under the same [GNU General Public License v3.0](https://choosealicense.com/licenses/gpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](https://github.com/gethomepage/homepage/issues) ## Report bugs using Github's [issues](https://github.com/gethomepage/homepage/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/gethomepage/homepage/issues/new); it's that easy! We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/gethomepage/homepage/issues/new); it's that easy!
## Write bug reports with detail, background, and sample configurations ## Write bug reports with detail, background, and sample configurations
Homepage includes a lot of configuration options and is often deploying in larger systems. Please include as much information (configurations, deployment method, Docker & API versions, etc) as you can when reporting an issue. Homepage includes a lot of configuration options and is often deploying in larger systems. Please include as much information (configurations, deployment method, Docker & API versions, etc) as you can when reporting an issue.
**Great Bug Reports** tend to have: **Great Bug Reports** tend to have:
@ -29,16 +34,20 @@ Homepage includes a lot of configuration options and is often deploying in large
- What actually happens - What actually happens
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
People *love* thorough bug reports. I'm not even kidding. People _love_ thorough bug reports. I'm not even kidding.
## Development Guidelines ## Development Guidelines
Please see the [documentation regarding development](https://gethomepage.dev/latest/more/development/) and specifically the [guidelines for new service widgets](https://gethomepage.dev/latest/more/development/#service-widget-guidelines) if you are considering making one. Please see the [documentation regarding development](https://gethomepage.dev/latest/more/development/) and specifically the [guidelines for new service widgets](https://gethomepage.dev/latest/more/development/#service-widget-guidelines) if you are considering making one.
## Use a Consistent Coding Style ## Use a Consistent Coding Style
This project follows the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript), please follow it when submitting pull requests. This project follows the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript), please follow it when submitting pull requests.
## License ## License
By contributing, you agree that your contributions will be licensed under its GNU General Public License. By contributing, you agree that your contributions will be licensed under its GNU General Public License.
## References ## References
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/main/CONTRIBUTING.md) This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/main/CONTRIBUTING.md)

@ -391,7 +391,7 @@ You can choose from the following styles for docker or k8s status and ping: `dot
For example: For example:
```yaml ```yaml
statusStyle: 'dot' statusStyle: "dot"
``` ```
or per-service (`services.yaml`) with: or per-service (`services.yaml`) with:

1
k3d/.gitignore vendored

@ -1,2 +1 @@
kubeconfig kubeconfig

@ -11,7 +11,7 @@ All the commands in the document should be run from the `k3d` directory.
## Requisite Tools ## Requisite Tools
| Tool | Description | | Tool | Description |
|-------------------------------------------------------------|----------------------------------------------------------| | ----------------------------------------------------------- | -------------------------------------------------------- |
| [docker](https://docker.io) | Docker container runtime | | [docker](https://docker.io) | Docker container runtime |
| [kubectl](https://kubernetes.io/releases/download/#kubectl) | Kubernetes CLI | | [kubectl](https://kubernetes.io/releases/download/#kubectl) | Kubernetes CLI |
| [helm](https://helm.sh) | Kubernetes package manager | | [helm](https://helm.sh) | Kubernetes package manager |
@ -20,7 +20,6 @@ All the commands in the document should be run from the `k3d` directory.
| [tilt](https://tilt.dev) | (Optional) Local CI loop for kubernetes deployment | | [tilt](https://tilt.dev) | (Optional) Local CI loop for kubernetes deployment |
| [direnv](https://direnv.net/) | (Optional) Automatically loads `kubeconfig` via `.envrc` | | [direnv](https://direnv.net/) | (Optional) Automatically loads `kubeconfig` via `.envrc` |
## One-off Test Deployments ## One-off Test Deployments
Create a cluster: Create a cluster:
@ -57,7 +56,7 @@ tilt up
Press space bar to open the tilt web UI, which is quite informative. Press space bar to open the tilt web UI, which is quite informative.
Open the Homepage deployment: Finally, open the Homepage deployment:
```sh ```sh
xdg-open http://homepage.k3d.localhost:8080/ xdg-open http://homepage.k3d.localhost:8080/

@ -2,9 +2,9 @@
## Requirements ## Requirements
* Kubernetes 1.19+ - Kubernetes 1.19+
* Metrics service - Metrics service
* An Ingress controller - An Ingress controller
## Deployment ## Deployment

@ -69,7 +69,7 @@ function prettyBytes(number, options) {
const exponent = Math.min( const exponent = Math.min(
Math.floor(options.binary ? Math.log(number) / Math.log(1024) : Math.log10(number) / 3), Math.floor(options.binary ? Math.log(number) / Math.log(1024) : Math.log10(number) / 3),
UNITS.length - 1 UNITS.length - 1,
); );
number /= (options.binary ? 1024 : 1000) ** exponent; number /= (options.binary ? 1024 : 1000) ** exponent;
@ -94,13 +94,18 @@ module.exports = {
{ {
init: (i18next) => { init: (i18next) => {
i18next.services.formatter.add("bytes", (value, lng, options) => i18next.services.formatter.add("bytes", (value, lng, options) =>
prettyBytes(parseFloat(value), { locale: lng, ...options }) prettyBytes(parseFloat(value), { locale: lng, ...options }),
); );
i18next.services.formatter.add("rate", (value, lng, options) => { i18next.services.formatter.add("rate", (value, lng, options) => {
const k = options.binary ? 1024 : 1000; const k = options.binary ? 1024 : 1000;
const sizes = options.bits ? (options.binary ? BIBIT_UNITS : BIT_UNITS) : (options.binary ? BIBYTE_UNITS : BYTE_UNITS); const sizes = options.bits
? options.binary
? BIBIT_UNITS
: BIT_UNITS
: options.binary
? BIBYTE_UNITS
: BYTE_UNITS;
if (value === 0) return `0 ${sizes[0]}/s`; if (value === 0) return `0 ${sizes[0]}/s`;
@ -109,14 +114,14 @@ module.exports = {
const i = options.binary ? 2 : Math.floor(Math.log(value) / Math.log(k)); const i = options.binary ? 2 : Math.floor(Math.log(value) / Math.log(k));
const formatted = new Intl.NumberFormat(lng, { maximumFractionDigits: dm, minimumFractionDigits: dm }).format( const formatted = new Intl.NumberFormat(lng, { maximumFractionDigits: dm, minimumFractionDigits: dm }).format(
parseFloat(value / k ** i) parseFloat(value / k ** i),
); );
return `${formatted} ${sizes[i]}/s`; return `${formatted} ${sizes[i]}/s`;
}); });
i18next.services.formatter.add("percent", (value, lng, options) => i18next.services.formatter.add("percent", (value, lng, options) =>
new Intl.NumberFormat(lng, { style: "percent", ...options }).format(parseFloat(value) / 100.0) new Intl.NumberFormat(lng, { style: "percent", ...options }).format(parseFloat(value) / 100.0),
); );
}, },
type: "3rdParty", type: "3rdParty",

@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

@ -1,6 +1,6 @@
import { useRef } from "react"; import { useRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Disclosure, Transition } from '@headlessui/react'; import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md"; import { MdKeyboardArrowDown } from "react-icons/md";
import ErrorBoundary from "components/errorboundry"; import ErrorBoundary from "components/errorboundry";
@ -15,7 +15,7 @@ export default function BookmarksGroup({ bookmarks, layout, disableCollapse }) {
className={classNames( className={classNames(
"bookmark-group", "bookmark-group",
layout?.style === "row" ? "basis-full" : "basis-full md:basis-1/4 lg:basis-1/5 xl:basis-1/6", 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" layout?.header === false ? "flex-1 px-1 -my-1" : "flex-1 p-1",
)} )}
> >
<Disclosure defaultOpen> <Disclosure defaultOpen>
@ -28,12 +28,14 @@ export default function BookmarksGroup({ bookmarks, layout, disableCollapse }) {
<ResolvedIcon icon={layout.icon} /> <ResolvedIcon icon={layout.icon} />
</div> </div>
)} )}
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium bookmark-group-name">{bookmarks.name}</h2> <h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium bookmark-group-name">
{bookmarks.name}
</h2>
<MdKeyboardArrowDown <MdKeyboardArrowDown
className={classNames( className={classNames(
disableCollapse ? "hidden" : "", disableCollapse ? "hidden" : "",
"transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl", "transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl",
open ? "" : "rotate-180" open ? "" : "rotate-180",
)} )}
/> />
</Disclosure.Button> </Disclosure.Button>

@ -15,22 +15,24 @@ export default function Item({ bookmark }) {
title={bookmark.name} title={bookmark.name}
target={bookmark.target ?? settings.target ?? "_blank"} target={bookmark.target ?? settings.target ?? "_blank"}
className={classNames( className={classNames(
settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? '-' : ""}${settings.cardBlur}`, settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`,
"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" "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">
<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-icon"> <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-icon">
{bookmark.icon && {bookmark.icon && (
<div className="flex-shrink-0 w-5 h-5"> <div className="flex-shrink-0 w-5 h-5">
<ResolvedIcon icon={bookmark.icon} alt={bookmark.abbr} /> <ResolvedIcon icon={bookmark.icon} alt={bookmark.abbr} />
</div> </div>
} )}
{!bookmark.icon && bookmark.abbr} {!bookmark.icon && bookmark.abbr}
</div> </div>
<div className="flex-1 flex items-center justify-between rounded-r-md bookmark-text"> <div className="flex-1 flex items-center justify-between rounded-r-md bookmark-text">
<div className="flex-1 grow pl-3 py-2 text-xs bookmark-name">{bookmark.name}</div> <div className="flex-1 grow pl-3 py-2 text-xs bookmark-name">{bookmark.name}</div>
<div className="px-2 py-2 truncate text-theme-500 dark:text-theme-300 text-xs bookmark-description">{description}</div> <div className="px-2 py-2 truncate text-theme-500 dark:text-theme-300 text-xs bookmark-description">
{description}
</div>
</div> </div>
</div> </div>
</a> </a>

@ -9,7 +9,7 @@ export default function List({ bookmarks, layout }) {
<ul <ul
className={classNames( className={classNames(
layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col", layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col",
"mt-3 bookmark-list" "mt-3 bookmark-list",
)} )}
> >
{bookmarks.map((bookmark) => ( {bookmarks.map((bookmark) => (

@ -1,10 +1,10 @@
import useSWR from "swr" import useSWR from "swr";
export default function FileContent({ path, loadingValue, errorValue, emptyValue = '' }) { export default function FileContent({ path, loadingValue, errorValue, emptyValue = "" }) {
const fetcher = (url) => fetch(url).then((res) => res.text()) const fetcher = (url) => fetch(url).then((res) => res.text());
const { data, error, isLoading } = useSWR(`/api/config/${ path }`, fetcher) const { data, error, isLoading } = useSWR(`/api/config/${path}`, fetcher);
if (error) return (errorValue) if (error) return errorValue;
if (isLoading) return (loadingValue) if (isLoading) return loadingValue;
return (data || emptyValue) return data || emptyValue;
} }

@ -6,10 +6,19 @@ import ResolvedIcon from "./resolvedicon";
import { SettingsContext } from "utils/contexts/settings"; import { SettingsContext } from "utils/contexts/settings";
export default function QuickLaunch({servicesAndBookmarks, searchString, setSearchString, isOpen, close, searchProvider}) { export default function QuickLaunch({
servicesAndBookmarks,
searchString,
setSearchString,
isOpen,
close,
searchProvider,
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const { searchDescriptions, hideVisitURL } = settings?.quicklaunch ? settings.quicklaunch : { searchDescriptions: false, hideVisitURL: false }; const { searchDescriptions, hideVisitURL } = settings?.quicklaunch
? settings.quicklaunch
: { searchDescriptions: false, hideVisitURL: false };
const searchField = useRef(); const searchField = useRef();
@ -19,7 +28,7 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
function openCurrentItem(newWindow) { function openCurrentItem(newWindow) {
const result = results[currentItemIndex]; const result = results[currentItemIndex];
window.open(result.href, newWindow ? "_blank" : result.target ?? settings.target ?? "_blank", 'noreferrer'); window.open(result.href, newWindow ? "_blank" : result.target ?? settings.target ?? "_blank", "noreferrer");
} }
const closeAndReset = useCallback(() => { const closeAndReset = useCallback(() => {
@ -35,7 +44,7 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
try { try {
if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url
let urlString = rawSearchString; let urlString = rawSearchString;
if (urlString.indexOf('http') !== 0) urlString = `https://${rawSearchString}`; if (urlString.indexOf("http") !== 0) urlString = `https://${rawSearchString}`;
setUrl(new URL(urlString)); // basic validation setUrl(new URL(urlString)); // basic validation
} catch (e) { } catch (e) {
setUrl(null); setUrl(null);
@ -83,12 +92,12 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
useEffect(() => { useEffect(() => {
if (searchString.length === 0) setResults([]); if (searchString.length === 0) setResults([]);
else { else {
let newResults = servicesAndBookmarks.filter(r => { let newResults = servicesAndBookmarks.filter((r) => {
const nameMatch = r.name.toLowerCase().includes(searchString); const nameMatch = r.name.toLowerCase().includes(searchString);
let descriptionMatch; let descriptionMatch;
if (searchDescriptions) { if (searchDescriptions) {
descriptionMatch = r.description?.toLowerCase().includes(searchString) descriptionMatch = r.description?.toLowerCase().includes(searchString);
r.priority = nameMatch ? 2 * (+nameMatch) : +descriptionMatch; // eslint-disable-line no-param-reassign r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch; // eslint-disable-line no-param-reassign
} }
return nameMatch || descriptionMatch; return nameMatch || descriptionMatch;
}); });
@ -98,23 +107,19 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
} }
if (searchProvider) { if (searchProvider) {
newResults.push( newResults.push({
{
href: searchProvider.url + encodeURIComponent(searchString), href: searchProvider.url + encodeURIComponent(searchString),
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")} `, name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")} `,
type: 'search', type: "search",
} });
)
} }
if (!hideVisitURL && url) { if (!hideVisitURL && url) {
newResults.unshift( newResults.unshift({
{
href: url.toString(), href: url.toString(),
name: `${t("quicklaunch.visit")} URL`, name: `${t("quicklaunch.visit")} URL`,
type: 'url', type: "url",
} });
)
} }
setResults(newResults); setResults(newResults);
@ -125,7 +130,6 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
} }
}, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchProvider, url, t]); }, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchProvider, url, t]);
const [hidden, setHidden] = useState(true); const [hidden, setHidden] = useState(true);
useEffect(() => { useEffect(() => {
function handleBackdropClick(event) { function handleBackdropClick(event) {
@ -134,66 +138,103 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
if (isOpen) { if (isOpen) {
searchField.current.focus(); searchField.current.focus();
document.body.addEventListener('click', handleBackdropClick); document.body.addEventListener("click", handleBackdropClick);
setHidden(false); setHidden(false);
} else { } else {
document.body.removeEventListener('click', handleBackdropClick); document.body.removeEventListener("click", handleBackdropClick);
searchField.current.blur(); searchField.current.blur();
setTimeout(() => { setTimeout(() => {
setHidden(true); setHidden(true);
}, 300); // disable on close }, 300); // disable on close
} }
}, [isOpen, closeAndReset]); }, [isOpen, closeAndReset]);
function highlightText(text) { function highlightText(text) {
const parts = text.split(new RegExp(`(${searchString})`, 'gi')); const parts = text.split(new RegExp(`(${searchString})`, "gi"));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === searchString.toLowerCase() ? (
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
return <span>{parts.map((part, i) => part.toLowerCase() === searchString.toLowerCase() ? <span key={`${searchString}_${i}`} className="bg-theme-300/10">{part}</span> : part)}</span>; <span key={`${searchString}_${i}`} className="bg-theme-300/10">
{part}
</span>
) : (
part
),
)}
</span>
);
} }
return ( return (
<div className={classNames( <div
className={classNames(
"relative z-40 ease-in-out duration-300 transition-opacity", "relative z-40 ease-in-out duration-300 transition-opacity",
hidden && !isOpen && "hidden", hidden && !isOpen && "hidden",
!hidden && isOpen && "opacity-100", !hidden && isOpen && "opacity-100",
!isOpen && "opacity-0", !isOpen && "opacity-0",
)} role="dialog" aria-modal="true"> )}
role="dialog"
aria-modal="true"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-50" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-50" />
<div className="fixed inset-0 z-20 overflow-y-auto"> <div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full min-w-full items-start justify-center text-center"> <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"> <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( <input
placeholder="Search"
className={classNames(
results.length > 0 && "rounded-t-md", results.length > 0 && "rounded-t-md",
results.length === 0 && "rounded-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" "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"> 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) => ( {results.map((r, i) => (
<li key={r.container ?? r.app ?? `${r.name}-${r.href}`}> <li key={r.container ?? r.app ?? `${r.name}-${r.href}`}>
<button type="button" data-index={i} onMouseEnter={handleItemHover} onClick={handleItemClick} onKeyDown={handleItemKeyDown} 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", "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", i === currentItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
)}> )}
>
<div className="flex flex-row items-center mr-4 pointer-events-none"> <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 || r.abbr) && (
<div className="w-5 text-xs mr-4">
{r.icon && <ResolvedIcon icon={r.icon} />} {r.icon && <ResolvedIcon icon={r.icon} />}
{r.abbr && r.abbr} {r.abbr && r.abbr}
</div>} </div>
)}
<div className="flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none"> <div className="flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none">
<span className="mr-4">{r.name}</span> <span className="mr-4">{r.name}</span>
{r.description && {r.description && (
<span className="text-xs text-theme-600 text-light"> <span className="text-xs text-theme-600 text-light">
{searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description} {searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description}
</span> </span>
} )}
</div>
</div> </div>
<div className="text-xs text-theme-600 font-bold pointer-events-none">
{t(`quicklaunch.${r.type ? r.type.toLowerCase() : "bookmark"}`)}
</div> </div>
<div className="text-xs text-theme-600 font-bold pointer-events-none">{t(`quicklaunch.${r.type ? r.type.toLowerCase() : 'bookmark'}`)}</div>
</button> </button>
</li> </li>
))} ))}
</ul>} </ul>
)}
</dialog> </dialog>
</div> </div>
</div> </div>

@ -5,8 +5,8 @@ import { SettingsContext } from "utils/contexts/settings";
import { ThemeContext } from "utils/contexts/theme"; import { ThemeContext } from "utils/contexts/theme";
const iconSetURLs = { const iconSetURLs = {
'mdi': "https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/", mdi: "https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/",
'si' : "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/", si: "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/",
}; };
export default function ResolvedIcon({ icon, width = 32, height = 32, alt = "logo" }) { export default function ResolvedIcon({ icon, width = 32, height = 32, alt = "logo" }) {
@ -38,12 +38,13 @@ export default function ResolvedIcon({ icon, width = 32, height = 32, alt = "log
if (prefix in iconSetURLs) { if (prefix in iconSetURLs) {
// default to theme setting // default to theme setting
let iconName = icon.replace(`${prefix}-`, "").replace(".svg", ""); let iconName = icon.replace(`${prefix}-`, "").replace(".svg", "");
let iconColor = settings.iconStyle === "theme" ? let iconColor =
`rgb(var(--color-${ theme === "dark" ? 300 : 900 }) / var(--tw-text-opacity, 1))` : settings.iconStyle === "theme"
"linear-gradient(180deg, rgb(var(--color-logo-start)), rgb(var(--color-logo-stop)))"; ? `rgb(var(--color-${theme === "dark" ? 300 : 900}) / var(--tw-text-opacity, 1))`
: "linear-gradient(180deg, rgb(var(--color-logo-start)), rgb(var(--color-logo-stop)))";
// use custom hex color if provided // use custom hex color if provided
const colorMatches = icon.match(/[#][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]$/i) const colorMatches = icon.match(/[#][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]$/i);
if (colorMatches?.length) { if (colorMatches?.length) {
iconName = icon.replace(`${prefix}-`, "").replace(".svg", "").replace(`-${colorMatches[0]}`, ""); iconName = icon.replace(`${prefix}-`, "").replace(".svg", "").replace(`-${colorMatches[0]}`, "");
iconColor = `${colorMatches[0]}`; iconColor = `${colorMatches[0]}`;
@ -56,8 +57,8 @@ export default function ResolvedIcon({ icon, width = 32, height = 32, alt = "log
style={{ style={{
width, width,
height, height,
maxWidth: '100%', maxWidth: "100%",
maxHeight: '100%', maxHeight: "100%",
background: `${iconColor}`, background: `${iconColor}`,
mask: `url(${iconSource}) no-repeat center / contain`, mask: `url(${iconSource}) no-repeat center / contain`,
WebkitMask: `url(${iconSource}) no-repeat center / contain`, WebkitMask: `url(${iconSource}) no-repeat center / contain`,
@ -79,7 +80,7 @@ export default function ResolvedIcon({ icon, width = 32, height = 32, alt = "log
height, height,
objectFit: "contain", objectFit: "contain",
maxHeight: "100%", maxHeight: "100%",
maxWidth: "100%" maxWidth: "100%",
}} }}
alt={alt} alt={alt}
/> />
@ -97,7 +98,7 @@ export default function ResolvedIcon({ icon, width = 32, height = 32, alt = "log
height, height,
objectFit: "contain", objectFit: "contain",
maxHeight: "100%", maxHeight: "100%",
maxWidth: "100%" maxWidth: "100%",
}} }}
alt={alt} alt={alt}
/> />

@ -33,7 +33,7 @@ export default function Dropdown({ options, value, setValue }) {
type="button" type="button"
className={classNames( className={classNames(
value === option.value ? "bg-theme-300/40 dark:bg-theme-900/40" : "", value === option.value ? "bg-theme-300/40 dark:bg-theme-900/40" : "",
"w-full block px-3 py-1.5 text-sm hover:bg-theme-300/70 hover:dark:bg-theme-900/70 text-left" "w-full block px-3 py-1.5 text-sm hover:bg-theme-300/70 hover:dark:bg-theme-900/70 text-left",
)} )}
> >
{option.label} {option.label}

@ -1,13 +1,12 @@
import { useRef } from "react"; import { useRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Disclosure, Transition } from '@headlessui/react'; import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md"; import { MdKeyboardArrowDown } from "react-icons/md";
import List from "components/services/list"; import List from "components/services/list";
import ResolvedIcon from "components/resolvedicon"; import ResolvedIcon from "components/resolvedicon";
export default function ServicesGroup({ group, services, layout, fiveColumns, disableCollapse }) { export default function ServicesGroup({ group, services, layout, fiveColumns, disableCollapse }) {
const panel = useRef(); const panel = useRef();
return ( return (
@ -23,33 +22,43 @@ export default function ServicesGroup({ group, services, layout, fiveColumns, di
<Disclosure defaultOpen> <Disclosure defaultOpen>
{({ open }) => ( {({ open }) => (
<> <>
{ layout?.header !== false && {layout?.header !== false && (
<Disclosure.Button disabled={disableCollapse} className="flex w-full select-none items-center group"> <Disclosure.Button disabled={disableCollapse} className="flex w-full select-none items-center group">
{layout?.icon && {layout?.icon && (
<div className="flex-shrink-0 mr-2 w-7 h-7 service-group-icon"> <div className="flex-shrink-0 mr-2 w-7 h-7 service-group-icon">
<ResolvedIcon icon={layout.icon} /> <ResolvedIcon icon={layout.icon} />
</div> </div>
} )}
<h2 className="flex text-theme-800 dark:text-theme-300 text-xl font-medium service-group-name">{services.name}</h2> <h2 className="flex text-theme-800 dark:text-theme-300 text-xl font-medium service-group-name">
<MdKeyboardArrowDown className={classNames( {services.name}
disableCollapse ? 'hidden' : '', </h2>
'transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl', <MdKeyboardArrowDown
open ? '' : 'rotate-180' 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> </Disclosure.Button>
} )}
<Transition <Transition
// Otherwise the transition group does display: none and cancels animation // Otherwise the transition group does display: none and cancels animation
className="!block" className="!block"
unmount={false} unmount={false}
beforeLeave={() => { beforeLeave={() => {
panel.current.style.height = `${panel.current.scrollHeight}px`; panel.current.style.height = `${panel.current.scrollHeight}px`;
setTimeout(() => {panel.current.style.height = `0`}, 1); setTimeout(() => {
panel.current.style.height = `0`;
}, 1);
}} }}
beforeEnter={() => { beforeEnter={() => {
panel.current.style.height = `0px`; panel.current.style.height = `0px`;
setTimeout(() => {panel.current.style.height = `${panel.current.scrollHeight}px`}, 1); setTimeout(() => {
setTimeout(() => {panel.current.style.height = 'auto'}, 150); // animation is 150ms panel.current.style.height = `${panel.current.scrollHeight}px`;
}, 1);
setTimeout(() => {
panel.current.style.height = "auto";
}, 150); // animation is 150ms
}} }}
> >
<Disclosure.Panel className="transition-all overflow-hidden duration-300 ease-out" ref={panel} static> <Disclosure.Panel className="transition-all overflow-hidden duration-300 ease-out" ref={panel} static>

@ -14,8 +14,8 @@ import ResolvedIcon from "components/resolvedicon";
export default function Item({ service, group }) { export default function Item({ service, group }) {
const hasLink = service.href && service.href !== "#"; const hasLink = service.href && service.href !== "#";
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const showStats = (service.showStats === false) ? false : settings.showStats; const showStats = service.showStats === false ? false : settings.showStats;
const statusStyle = (service.statusStyle !== undefined) ? service.statusStyle : settings.statusStyle; const statusStyle = service.statusStyle !== undefined ? service.statusStyle : settings.statusStyle;
const [statsOpen, setStatsOpen] = useState(service.showStats); const [statsOpen, setStatsOpen] = useState(service.showStats);
const [statsClosing, setStatsClosing] = useState(false); const [statsClosing, setStatsClosing] = useState(false);
@ -34,9 +34,9 @@ export default function Item({ service, group }) {
<li key={service.name} id={service.id} className="service" data-name={service.name || ""}> <li key={service.name} id={service.id} className="service" data-name={service.name || ""}>
<div <div
className={classNames( className={classNames(
settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? '-' : ""}${settings.cardBlur}`, settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`,
hasLink && "cursor-pointer", hasLink && "cursor-pointer",
"transition-all h-15 mb-2 p-1 rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10 relative overflow-clip service-card" "transition-all h-15 mb-2 p-1 rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10 relative overflow-clip service-card",
)} )}
> >
<div className="flex select-none z-0 service-title"> <div className="flex select-none z-0 service-title">
@ -65,19 +65,27 @@ export default function Item({ service, group }) {
> >
<div className="flex-1 px-2 py-2 text-sm text-left z-10 service-name"> <div className="flex-1 px-2 py-2 text-sm text-left z-10 service-name">
{service.name} {service.name}
<p className="text-theme-500 dark:text-theme-300 text-xs font-light service-description">{service.description}</p> <p className="text-theme-500 dark:text-theme-300 text-xs font-light service-description">
{service.description}
</p>
</div> </div>
</a> </a>
) : ( ) : (
<div className="flex-1 flex items-center justify-between rounded-r-md service-title-text"> <div className="flex-1 flex items-center justify-between rounded-r-md service-title-text">
<div className="flex-1 px-2 py-2 text-sm text-left z-10 service-name"> <div className="flex-1 px-2 py-2 text-sm text-left z-10 service-name">
{service.name} {service.name}
<p className="text-theme-500 dark:text-theme-300 text-xs font-light service-description">{service.description}</p> <p className="text-theme-500 dark:text-theme-300 text-xs font-light service-description">
{service.description}
</p>
</div> </div>
</div> </div>
)} )}
<div className={`absolute top-0 right-0 flex flex-row justify-end ${statusStyle === 'dot' ? 'gap-0' : 'gap-2 mr-2'} z-30 service-tags`}> <div
className={`absolute top-0 right-0 flex flex-row justify-end ${
statusStyle === "dot" ? "gap-0" : "gap-2 mr-2"
} z-30 service-tags`}
>
{service.ping && ( {service.ping && (
<div className="flex-shrink-0 flex items-center justify-center service-tag service-ping"> <div className="flex-shrink-0 flex items-center justify-center service-tag service-ping">
<Ping group={group} service={service.name} style={statusStyle} /> <Ping group={group} service={service.name} style={statusStyle} />
@ -95,7 +103,7 @@ export default function Item({ service, group }) {
<span className="sr-only">View container stats</span> <span className="sr-only">View container stats</span>
</button> </button>
)} )}
{(service.app && !service.external) && ( {service.app && !service.external && (
<button <button
type="button" type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))} onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
@ -112,20 +120,28 @@ export default function Item({ service, group }) {
<div <div
className={classNames( className={classNames(
showStats || (statsOpen && !statsClosing) ? "max-h-[110px] opacity-100" : " max-h-[0] opacity-0", showStats || (statsOpen && !statsClosing) ? "max-h-[110px] opacity-100" : " max-h-[0] opacity-0",
"w-full overflow-hidden transition-all duration-300 ease-in-out service-stats" "w-full overflow-hidden transition-all duration-300 ease-in-out service-stats",
)} )}
> >
{(showStats || statsOpen) && <Docker service={{ widget: { container: service.container, server: service.server } }} />} {(showStats || statsOpen) && (
<Docker service={{ widget: { container: service.container, server: service.server } }} />
)}
</div> </div>
)} )}
{service.app && ( {service.app && (
<div <div
className={classNames( className={classNames(
showStats || (statsOpen && !statsClosing) ? "max-h-[55px] opacity-100" : " max-h-[0] opacity-0", showStats || (statsOpen && !statsClosing) ? "max-h-[55px] opacity-100" : " max-h-[0] opacity-0",
"w-full overflow-hidden transition-all duration-300 ease-in-out service-stats" "w-full overflow-hidden transition-all duration-300 ease-in-out service-stats",
)} )}
> >
{(showStats || statsOpen) && <Kubernetes service={{ widget: { namespace: service.namespace, app: service.app, podSelector: service.podSelector } }} />} {(showStats || statsOpen) && (
<Kubernetes
service={{
widget: { namespace: service.namespace, app: service.app, podSelector: service.podSelector },
}}
/>
)}
</div> </div>
)} )}

@ -28,17 +28,21 @@ export default function KubernetesStatus({ service, style }) {
} }
} }
if (style === 'dot') { if (style === "dot") {
colorClass = colorClass.replace(/text-/g, 'bg-').replace(/\/\d\d/g, ''); colorClass = colorClass.replace(/text-/g, "bg-").replace(/\/\d\d/g, "");
backgroundClass = "p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20"; backgroundClass = "p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20";
} }
return ( return (
<div className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] k8s-status`} title={statusTitle}> <div
{style !== 'dot' ? className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] k8s-status`}
<div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div> : title={statusTitle}
>
{style !== "dot" ? (
<div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div>
) : (
<div className={`rounded-full h-3 w-3 ${colorClass}`} /> <div className={`rounded-full h-3 w-3 ${colorClass}`} />
} )}
</div> </div>
); );
} }

@ -9,7 +9,7 @@ export default function List({ group, services, layout }) {
<ul <ul
className={classNames( className={classNames(
layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col", layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col",
"mt-3 services-list" "mt-3 services-list",
)} )}
> >
{services.map((service) => ( {services.map((service) => (

@ -4,7 +4,7 @@ import useSWR from "swr";
export default function Ping({ group, service, style }) { export default function Ping({ group, service, style }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, error } = useSWR(`api/ping?${new URLSearchParams({ group, service }).toString()}`, { const { data, error } = useSWR(`api/ping?${new URLSearchParams({ group, service }).toString()}`, {
refreshInterval: 30000 refreshInterval: 30000,
}); });
let colorClass = "text-black/20 dark:text-white/40 opacity-20"; let colorClass = "text-black/20 dark:text-white/40 opacity-20";
@ -29,7 +29,7 @@ export default function Ping({ group, service, style }) {
statusText = data.status; statusText = data.status;
} }
} else if (data) { } else if (data) {
const ping = t("common.ms", { value: data.latency, style: "unit", unit: "millisecond", maximumFractionDigits: 0 }) const ping = t("common.ms", { value: data.latency, style: "unit", unit: "millisecond", maximumFractionDigits: 0 });
statusTitle += ` ${data.status} (${ping})`; statusTitle += ` ${data.status} (${ping})`;
colorClass = "text-emerald-500/80"; colorClass = "text-emerald-500/80";
@ -42,14 +42,17 @@ export default function Ping({ group, service, style }) {
} }
if (style === "dot") { if (style === "dot") {
backgroundClass = 'p-4'; backgroundClass = "p-4";
colorClass = colorClass.replace(/text-/g, 'bg-').replace(/\/\d\d/g, ''); colorClass = colorClass.replace(/text-/g, "bg-").replace(/\/\d\d/g, "");
} }
return ( return (
<div className={`w-auto text-center rounded-b-[3px] overflow-hidden ping-status ${backgroundClass}`} title={statusTitle}> <div
{style !== 'dot' && <div className={`font-bold uppercase text-[8px] ${colorClass}`}>{statusText}</div>} className={`w-auto text-center rounded-b-[3px] overflow-hidden ping-status ${backgroundClass}`}
{style === 'dot' && <div className={`rounded-full h-3 w-3 ${colorClass}`}/>} title={statusTitle}
>
{style !== "dot" && <div className={`font-bold uppercase text-[8px] ${colorClass}`}>{statusText}</div>}
{style === "dot" && <div className={`rounded-full h-3 w-3 ${colorClass}`} />}
</div> </div>
); );
} }

@ -37,25 +37,29 @@ export default function Status({ service, style }) {
} }
if (data.status === "not found" || data.status === "exited" || data.status?.startsWith("partial")) { if (data.status === "not found" || data.status === "exited" || data.status?.startsWith("partial")) {
if (data.status === "not found") statusLabel = t("docker.not_found") if (data.status === "not found") statusLabel = t("docker.not_found");
else if (data.status === "exited") statusLabel = t("docker.exited") else if (data.status === "exited") statusLabel = t("docker.exited");
else statusLabel = data.status.replace("partial", t("docker.partial")) else statusLabel = data.status.replace("partial", t("docker.partial"));
colorClass = "text-orange-400/50 dark:text-orange-400/80"; colorClass = "text-orange-400/50 dark:text-orange-400/80";
} }
} }
if (style === 'dot') { if (style === "dot") {
colorClass = colorClass.replace(/text-/g, 'bg-').replace(/\/\d\d/g, ''); colorClass = colorClass.replace(/text-/g, "bg-").replace(/\/\d\d/g, "");
backgroundClass = "p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20"; backgroundClass = "p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20";
statusTitle = statusLabel; statusTitle = statusLabel;
} }
return ( return (
<div className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] docker-status`} title={statusTitle}> <div
{style !== 'dot' ? className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] docker-status`}
<div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div> : title={statusTitle}
>
{style !== "dot" ? (
<div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div>
) : (
<div className={`rounded-full h-3 w-3 ${colorClass}`} /> <div className={`rounded-full h-3 w-3 ${colorClass}`} />
} )}
</div> </div>
); );
} }

@ -9,7 +9,7 @@ export default function Block({ value, label }) {
className={classNames( className={classNames(
"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", "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" : "", value === undefined ? "animate-pulse" : "",
"service-block" "service-block",
)} )}
> >
<div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div> <div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div>

@ -12,14 +12,14 @@ export default function Container({ error = false, children, service }) {
return null; return null;
} }
return <Error service={service} error={error} /> return <Error service={service} error={error} />;
} }
const childrenArray = Array.isArray(children) ? children : [children]; const childrenArray = Array.isArray(children) ? children : [children];
let visibleChildren = childrenArray; let visibleChildren = childrenArray;
let fields = service?.widget?.fields; let fields = service?.widget?.fields;
if (typeof fields === 'string') fields = JSON.parse(service.widget.fields); if (typeof fields === "string") fields = JSON.parse(service.widget.fields);
const type = service?.widget?.type; const type = service?.widget?.type;
if (fields && type) { if (fields && type) {
// if the field contains a "." then it most likely contains a common loc value // if the field contains a "." then it most likely contains a common loc value
@ -27,13 +27,15 @@ export default function Container({ error = false, children, service }) {
// fields: [ "resources.cpu", "resources.mem", "field"] // fields: [ "resources.cpu", "resources.mem", "field"]
// or even // or even
// fields: [ "resources.cpu", "widget_type.field" ] // fields: [ "resources.cpu", "widget_type.field" ]
visibleChildren = childrenArray?.filter(child => fields.some(field => { visibleChildren = childrenArray?.filter((child) =>
fields.some((field) => {
let fullField = field; let fullField = field;
if (!field.includes(".")) { if (!field.includes(".")) {
fullField = `${type}.${field}`; fullField = `${type}.${field}`;
} }
return fullField === child?.props?.label; return fullField === child?.props?.label;
})); }),
);
} }
return <div className="relative flex flex-row w-full service-container">{visibleChildren}</div>; return <div className="relative flex flex-row w-full service-container">{visibleChildren}</div>;

@ -6,7 +6,7 @@ function displayError(error) {
} }
function displayData(data) { function displayData(data) {
return (data.type === 'Buffer') ? Buffer.from(data).toString() : JSON.stringify(data, 4); return data.type === "Buffer" ? Buffer.from(data).toString() : JSON.stringify(data, 4);
} }
export default function Error({ error }) { export default function Error({ error }) {
@ -20,29 +20,34 @@ export default function Error({ error }) {
<details className="px-1 pb-1"> <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"> <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"> <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")} <IoAlertCircle className="mr-1 w-5 h-5" />
{t("widget.api_error")} {error.message && t("widget.information")}
</div> </div>
</summary> </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"> <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"> <ul className="p-4">
{error.message && <li> {error.message && (
<li>
<span className="text-black">{t("widget.api_error")}:</span> {error.message} <span className="text-black">{t("widget.api_error")}:</span> {error.message}
</li>} </li>
{error.url && <li className="mt-2"> )}
{error.url && (
<li className="mt-2">
<span className="text-black">{t("widget.url")}:</span> {error.url} <span className="text-black">{t("widget.url")}:</span> {error.url}
</li>} </li>
{error.rawError && <li className="mt-2"> )}
{error.rawError && (
<li className="mt-2">
<span className="text-black">{t("widget.raw_error")}:</span> <span className="text-black">{t("widget.raw_error")}:</span>
<div className="ml-2"> <div className="ml-2">{displayError(error.rawError)}</div>
{displayError(error.rawError)} </li>
</div> )}
</li>} {error.data && (
{error.data && <li className="mt-2"> <li className="mt-2">
<span className="text-black">{t("widget.response_data")}:</span> <span className="text-black">{t("widget.response_data")}:</span>
<div className="ml-2"> <div className="ml-2">{displayData(error.data)}</div>
{displayData(error.data)} </li>
</div> )}
</li>}
</ul> </ul>
</div> </div>
</details> </details>

@ -4,28 +4,37 @@ import classNames from "classnames";
import { TabContext } from "utils/contexts/tab"; import { TabContext } from "utils/contexts/tab";
export function slugify(tabName) { export function slugify(tabName) {
return tabName !== undefined ? encodeURIComponent(tabName.toString().replace(/\s+/g, '-').toLowerCase()) : '' return tabName !== undefined ? encodeURIComponent(tabName.toString().replace(/\s+/g, "-").toLowerCase()) : "";
} }
export default function Tab({ tab }) { export default function Tab({ tab }) {
const { activeTab, setActiveTab } = useContext(TabContext); const { activeTab, setActiveTab } = useContext(TabContext);
return ( return (
<li key={tab} role="presentation" <li
className={classNames( key={tab}
"text-theme-700 dark:text-theme-200 relative h-8 w-full rounded-md flex m-1", role="presentation"
)}> className={classNames("text-theme-700 dark:text-theme-200 relative h-8 w-full rounded-md flex m-1")}
<button id={`${tab}-tab`} type="button" role="tab" >
aria-controls={`#${tab}`} aria-selected={activeTab === slugify(tab) ? "true" : "false"} <button
id={`${tab}-tab`}
type="button"
role="tab"
aria-controls={`#${tab}`}
aria-selected={activeTab === slugify(tab) ? "true" : "false"}
className={classNames( className={classNames(
"h-full w-full rounded-md", "h-full w-full rounded-md",
activeTab === slugify(tab) ? "bg-theme-300/20 dark:bg-white/10" : "hover:bg-theme-100/20 dark:hover:bg-white/5", activeTab === slugify(tab)
? "bg-theme-300/20 dark:bg-white/10"
: "hover:bg-theme-100/20 dark:hover:bg-white/5",
)} )}
onClick={() => { onClick={() => {
setActiveTab(slugify(tab)); setActiveTab(slugify(tab));
window.location.hash = `#${slugify(tab)}`; window.location.hash = `#${slugify(tab)}`;
}} }}
>{tab}</button> >
{tab}
</button>
</li> </li>
); );
} }

@ -65,7 +65,7 @@ export default function ColorToggle() {
title={color} title={color}
className={classNames( className={classNames(
active === color ? "border-2" : "border-0", active === color ? "border-2" : "border-0",
`rounded-md w-5 h-5 border-black/50 dark:border-white/50 theme-${color} bg-theme-400` `rounded-md w-5 h-5 border-black/50 dark:border-white/50 theme-${color} bg-theme-400`,
)} )}
/> />
<span className="sr-only">{color}</span> <span className="sr-only">{color}</span>

@ -6,7 +6,9 @@ import { MdNewReleases } from "react-icons/md";
export default function Version() { export default function Version() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const buildTime = process.env.NEXT_PUBLIC_BUILDTIME?.length ? process.env.NEXT_PUBLIC_BUILDTIME : new Date().toISOString(); const buildTime = process.env.NEXT_PUBLIC_BUILDTIME?.length
? process.env.NEXT_PUBLIC_BUILDTIME
: new Date().toISOString();
const revision = process.env.NEXT_PUBLIC_REVISION?.length ? process.env.NEXT_PUBLIC_REVISION : "dev"; const revision = process.env.NEXT_PUBLIC_REVISION?.length ? process.env.NEXT_PUBLIC_REVISION : "dev";
const version = process.env.NEXT_PUBLIC_VERSION?.length ? process.env.NEXT_PUBLIC_VERSION : "dev"; const version = process.env.NEXT_PUBLIC_VERSION?.length ? process.env.NEXT_PUBLIC_VERSION : "dev";
@ -44,7 +46,8 @@ export default function Version() {
</span> </span>
{version === "main" || version === "dev" || version === "nightly" {version === "main" || version === "dev" || version === "nightly"
? null ? null
: releaseData && latestRelease && : releaseData &&
latestRelease &&
compareVersions(latestRelease.tag_name, version) > 0 && ( compareVersions(latestRelease.tag_name, version) > 0 && (
<a <a
href={latestRelease.html_url} href={latestRelease.html_url}

@ -15,7 +15,7 @@ import { SettingsContext } from "utils/contexts/settings";
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl"]; const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl"];
function convertToFahrenheit(t) { function convertToFahrenheit(t) {
return t * 9/5 + 32 return (t * 9) / 5 + 32;
} }
export default function Widget({ options }) { export default function Widget({ options }) {
@ -23,35 +23,49 @@ export default function Widget({ options }) {
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const { data, error } = useSWR( const { data, error } = useSWR(
`api/widgets/glances?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`, { `api/widgets/glances?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,
{
refreshInterval: 1500, refreshInterval: 1500,
} },
); );
if (error || data?.error) { if (error || data?.error) {
return <Error options={options} /> return <Error options={options} />;
} }
if (!data) { if (!data) {
return <Resources options={options} additionalClassNames="information-widget-glances"> return (
<Resources options={options} additionalClassNames="information-widget-glances">
{options.cpu !== false && <Resource icon={FiCpu} label={t("glances.wait")} percentage="0" />} {options.cpu !== false && <Resource icon={FiCpu} label={t("glances.wait")} percentage="0" />}
{options.mem !== false && <Resource icon={FaMemory} label={t("glances.wait")} percentage="0" />} {options.mem !== false && <Resource icon={FaMemory} label={t("glances.wait")} percentage="0" />}
{options.cputemp && <Resource icon={FaThermometerHalf} label={t("glances.wait")} percentage="0" />} {options.cputemp && <Resource icon={FaThermometerHalf} label={t("glances.wait")} percentage="0" />}
{ options.disk && !Array.isArray(options.disk) && <Resource key={options.disk} icon={FiHardDrive} label={t("glances.wait")} percentage="0" /> } {options.disk && !Array.isArray(options.disk) && (
{ options.disk && Array.isArray(options.disk) && options.disk.map((disk) => <Resource key={`disk_${disk}`} icon={FiHardDrive} label={t("glances.wait")} percentage="0" /> ) } <Resource key={options.disk} icon={FiHardDrive} label={t("glances.wait")} percentage="0" />
)}
{options.disk &&
Array.isArray(options.disk) &&
options.disk.map((disk) => (
<Resource key={`disk_${disk}`} icon={FiHardDrive} label={t("glances.wait")} percentage="0" />
))}
{options.uptime && <Resource icon={FaRegClock} label={t("glances.wait")} percentage="0" />} {options.uptime && <Resource icon={FaRegClock} label={t("glances.wait")} percentage="0" />}
{options.label && <WidgetLabel label={options.label} />} {options.label && <WidgetLabel label={options.label} />}
</Resources>; </Resources>
);
} }
const unit = options.units === "imperial" ? "fahrenheit" : "celsius"; const unit = options.units === "imperial" ? "fahrenheit" : "celsius";
let mainTemp = 0; let mainTemp = 0;
let maxTemp = 80; let maxTemp = 80;
const cpuSensors = data.sensors?.filter(s => cpuSensorLabels.some(label => s.label.startsWith(label)) && s.type === "temperature_core"); const cpuSensors = data.sensors?.filter(
(s) => cpuSensorLabels.some((label) => s.label.startsWith(label)) && s.type === "temperature_core",
);
if (options.cputemp && cpuSensors) { if (options.cputemp && cpuSensors) {
try { try {
mainTemp = cpuSensors.reduce((acc, s) => acc + s.value, 0) / cpuSensors.length; mainTemp = cpuSensors.reduce((acc, s) => acc + s.value, 0) / cpuSensors.length;
maxTemp = Math.max(cpuSensors.reduce((acc, s) => acc + (s.warning > 0 ? s.warning : 0), 0) / cpuSensors.length, maxTemp); maxTemp = Math.max(
cpuSensors.reduce((acc, s) => acc + (s.warning > 0 ? s.warning : 0), 0) / cpuSensors.length,
maxTemp,
);
if (unit === "fahrenheit") { if (unit === "fahrenheit") {
mainTemp = convertToFahrenheit(mainTemp); mainTemp = convertToFahrenheit(mainTemp);
maxTemp = convertToFahrenheit(maxTemp); maxTemp = convertToFahrenheit(maxTemp);
@ -70,11 +84,12 @@ export default function Widget({ options }) {
: [data.fs.find((d) => d.mnt_point === options.disk)].filter((d) => d); : [data.fs.find((d) => d.mnt_point === options.disk)].filter((d) => d);
} }
const addedClasses = classNames('information-widget-glances', { 'expanded': options.expanded }) const addedClasses = classNames("information-widget-glances", { expanded: options.expanded });
return ( return (
<Resources options={options} target={settings.target ?? "_blank"} additionalClassNames={addedClasses}> <Resources options={options} target={settings.target ?? "_blank"} additionalClassNames={addedClasses}>
{options.cpu !== false && <Resource {options.cpu !== false && (
<Resource
icon={FiCpu} icon={FiCpu}
value={t("common.number", { value={t("common.number", {
value: data.cpu.total, value: data.cpu.total,
@ -87,13 +102,15 @@ export default function Widget({ options }) {
value: data.load.min15, value: data.load.min15,
style: "unit", style: "unit",
unit: "percent", unit: "percent",
maximumFractionDigits: 0 maximumFractionDigits: 0,
})} })}
expandedLabel={t("glances.load")} expandedLabel={t("glances.load")}
percentage={data.cpu.total} percentage={data.cpu.total}
expanded={options.expanded} expanded={options.expanded}
/>} />
{options.mem !== false && <Resource )}
{options.mem !== false && (
<Resource
icon={FaMemory} icon={FaMemory}
value={t("common.bytes", { value={t("common.bytes", {
value: data.mem.free, value: data.mem.free,
@ -109,9 +126,11 @@ export default function Widget({ options }) {
expandedLabel={t("glances.total")} expandedLabel={t("glances.total")}
percentage={data.mem.percent} percentage={data.mem.percent}
expanded={options.expanded} expanded={options.expanded}
/>} />
)}
{disks.map((disk) => ( {disks.map((disk) => (
<Resource key={`disk_${disk.mnt_point ?? disk.device_name}`} <Resource
key={`disk_${disk.mnt_point ?? disk.device_name}`}
icon={FiHardDrive} icon={FiHardDrive}
value={t("common.bytes", { value: disk.free })} value={t("common.bytes", { value: disk.free })}
label={t("glances.free")} label={t("glances.free")}
@ -121,35 +140,35 @@ export default function Widget({ options }) {
expanded={options.expanded} expanded={options.expanded}
/> />
))} ))}
{options.cputemp && mainTemp > 0 && {options.cputemp && mainTemp > 0 && (
<Resource <Resource
icon={FaThermometerHalf} icon={FaThermometerHalf}
value={t("common.number", { value={t("common.number", {
value: mainTemp, value: mainTemp,
maximumFractionDigits: 1, maximumFractionDigits: 1,
style: "unit", style: "unit",
unit unit,
})} })}
label={t("glances.temp")} label={t("glances.temp")}
expandedValue={t("common.number", { expandedValue={t("common.number", {
value: maxTemp, value: maxTemp,
maximumFractionDigits: 1, maximumFractionDigits: 1,
style: "unit", style: "unit",
unit unit,
})} })}
expandedLabel={t("glances.warn")} expandedLabel={t("glances.warn")}
percentage={tempPercent} percentage={tempPercent}
expanded={options.expanded} expanded={options.expanded}
/> />
} )}
{options.uptime && data.uptime && {options.uptime && data.uptime && (
<Resource <Resource
icon={FaRegClock} icon={FaRegClock}
value={data.uptime.replace(" days,", t("glances.days")).replace(/:\d\d:\d\d$/g, t("glances.hours"))} value={data.uptime.replace(" days,", t("glances.days")).replace(/:\d\d:\d\d$/g, t("glances.hours"))}
label={t("glances.uptime")} label={t("glances.uptime")}
percentage={Math.round((new Date().getSeconds() / 60) * 100).toString()} percentage={Math.round((new Date().getSeconds() / 60) * 100).toString()}
/> />
} )}
{options.label && <WidgetLabel label={options.label} />} {options.label && <WidgetLabel label={options.label} />}
</Resources> </Resources>
); );

@ -14,12 +14,14 @@ const textSizes = {
export default function Greeting({ options }) { export default function Greeting({ options }) {
if (options.text) { if (options.text) {
return <Container options={options} additionalClassNames="information-widget-greeting"> return (
<Container options={options} additionalClassNames="information-widget-greeting">
<Raw> <Raw>
<span className={`text-theme-800 dark:text-theme-200 mr-3 ${textSizes[options.text_size || "xl"]}`}> <span className={`text-theme-800 dark:text-theme-200 mr-3 ${textSizes[options.text_size || "xl"]}`}>
{options.text} {options.text}
</span> </span>
</Raw> </Raw>
</Container>; </Container>
);
} }
} }

@ -15,52 +15,47 @@ export default function Widget({ options }) {
cpu: { cpu: {
load: 0, load: 0,
total: 0, total: 0,
percent: 0 percent: 0,
}, },
memory: { memory: {
used: 0, used: 0,
total: 0, total: 0,
free: 0, free: 0,
percent: 0 percent: 0,
} },
}; };
const { data, error } = useSWR( const { data, error } = useSWR(`api/widgets/kubernetes?${new URLSearchParams({ lang: i18n.language }).toString()}`, {
`api/widgets/kubernetes?${new URLSearchParams({ lang: i18n.language }).toString()}`, { refreshInterval: 1500,
refreshInterval: 1500 });
}
);
if (error || data?.error) { if (error || data?.error) {
return <Error options={options} /> return <Error options={options} />;
} }
if (!data) { if (!data) {
return <Container options={options} additionalClassNames="information-widget-kubernetes"> return (
<Container options={options} additionalClassNames="information-widget-kubernetes">
<Raw> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{cluster.show && {cluster.show && <Node type="cluster" key="cluster" options={options.cluster} data={defaultData} />}
<Node type="cluster" key="cluster" options={options.cluster} data={defaultData} /> {nodes.show && <Node type="node" key="nodes" options={options.nodes} data={defaultData} />}
}
{nodes.show &&
<Node type="node" key="nodes" options={options.nodes} data={defaultData} />
}
</div> </div>
</Raw> </Raw>
</Container>; </Container>
);
} }
return <Container options={options} additionalClassNames="information-widget-kubernetes"> return (
<Container options={options} additionalClassNames="information-widget-kubernetes">
<Raw> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{cluster.show && {cluster.show && <Node key="cluster" type="cluster" options={options.cluster} data={data.cluster} />}
<Node key="cluster" type="cluster" options={options.cluster} data={data.cluster} /> {nodes.show &&
} data.nodes &&
{nodes.show && data.nodes && data.nodes.map((node) => <Node key={node.name} type="node" options={options.nodes} data={node} />)}
data.nodes.map((node) =>
<Node key={node.name} type="node" options={options.nodes} data={node} />)
}
</div> </div>
</Raw> </Raw>
</Container>; </Container>
);
} }

@ -8,7 +8,6 @@ import UsageBar from "../resources/usage-bar";
export default function Node({ type, options, data }) { export default function Node({ type, options, data }) {
const { t } = useTranslation(); const { t } = useTranslation();
function icon() { function icon() {
if (type === "cluster") { if (type === "cluster") {
return <SiKubernetes className="text-theme-800 dark:text-theme-200 w-5 h-5" />; return <SiKubernetes className="text-theme-800 dark:text-theme-200 w-5 h-5" />;
@ -31,7 +30,7 @@ export default function Node({ type, options, data }) {
value: data?.cpu?.percent ?? 0, value: data?.cpu?.percent ?? 0,
style: "unit", style: "unit",
unit: "percent", unit: "percent",
maximumFractionDigits: 0 maximumFractionDigits: 0,
})} })}
</div> </div>
<FiCpu className="text-theme-800 dark:text-theme-200 w-3 h-3" /> <FiCpu className="text-theme-800 dark:text-theme-200 w-3 h-3" />
@ -42,14 +41,16 @@ export default function Node({ type, options, data }) {
{t("common.bytes", { {t("common.bytes", {
value: data?.memory?.free ?? 0, value: data?.memory?.free ?? 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
binary: true binary: true,
})} })}
</div> </div>
<FaMemory className="text-theme-800 dark:text-theme-200 w-3 h-3" /> <FaMemory className="text-theme-800 dark:text-theme-200 w-3 h-3" />
</div> </div>
<UsageBar percent={data?.memory?.percent} /> <UsageBar percent={data?.memory?.percent} />
{options.showLabel && ( {options.showLabel && (
<div className="pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{type === "cluster" ? options.label : data.name}</div> <div className="pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">
{type === "cluster" ? options.label : data.name}
</div>
)} )}
</div> </div>
</div> </div>

@ -1,16 +1,20 @@
import Container from "../widget/container"; import Container from "../widget/container";
import Raw from "../widget/raw"; import Raw from "../widget/raw";
import ResolvedIcon from "components/resolvedicon" import ResolvedIcon from "components/resolvedicon";
export default function Logo({ options }) { export default function Logo({ options }) {
return ( return (
<Container options={options} additionalClassNames={`information-widget-logo ${ options.icon ? 'resolved' : 'fallback'}`}> <Container
options={options}
additionalClassNames={`information-widget-logo ${options.icon ? "resolved" : "fallback"}`}
>
<Raw> <Raw>
{options.icon ? {options.icon ? (
<div className="resolved mr-3"> <div className="resolved mr-3">
<ResolvedIcon icon={options.icon} width={48} height={48} /> <ResolvedIcon icon={options.icon} width={48} height={48} />
</div> : </div>
) : (
// fallback to homepage logo // fallback to homepage logo
<div className="fallback w-12 h-12"> <div className="fallback w-12 h-12">
<svg <svg
@ -64,8 +68,8 @@ export default function Logo({ options }) {
</g> </g>
</svg> </svg>
</div> </div>
} )}
</Raw> </Raw>
</Container> </Container>
) );
} }

@ -9,27 +9,30 @@ import Node from "./node";
export default function Longhorn({ options }) { export default function Longhorn({ options }) {
const { expanded, total, labels, include, nodes } = options; const { expanded, total, labels, include, nodes } = options;
const { data, error } = useSWR(`api/widgets/longhorn`, { const { data, error } = useSWR(`api/widgets/longhorn`, {
refreshInterval: 1500 refreshInterval: 1500,
}); });
if (error || data?.error) { if (error || data?.error) {
return <Error options={options} /> return <Error options={options} />;
} }
if (!data) { if (!data) {
return <Container options={options} additionalClassNames="infomation-widget-longhorn"> return (
<Container options={options} additionalClassNames="infomation-widget-longhorn">
<Raw> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between" /> <div className="flex flex-row self-center flex-wrap justify-between" />
</Raw> </Raw>
</Container>; </Container>
);
} }
return <Container options={options} additionalClassNames="infomation-widget-longhorn"> return (
<Container options={options} additionalClassNames="infomation-widget-longhorn">
<Raw> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{data.nodes {data.nodes
.filter((node) => { .filter((node) => {
if (node.id === 'total' && total) { if (node.id === "total" && total) {
return true; return true;
} }
if (!nodes) { if (!nodes) {
@ -40,12 +43,13 @@ export default function Longhorn({ options }) {
} }
return true; return true;
}) })
.map((node) => .map((node) => (
<div key={node.id}> <div key={node.id}>
<Node data={{ node }} expanded={expanded} labels={labels} /> <Node data={{ node }} expanded={expanded} labels={labels} />
</div> </div>
)} ))}
</div> </div>
</Raw> </Raw>
</Container>; </Container>
);
} }

@ -7,7 +7,8 @@ import WidgetLabel from "../widget/widget_label";
export default function Node({ data, expanded, labels }) { export default function Node({ data, expanded, labels }) {
const { t } = useTranslation(); const { t } = useTranslation();
return <Resource return (
<Resource
additionalClassNames="information-widget-longhorn-node" additionalClassNames="information-widget-longhorn-node"
icon={FaThermometerHalf} icon={FaThermometerHalf}
value={t("common.bytes", { value: data.node.available })} value={t("common.bytes", { value: data.node.available })}
@ -16,6 +17,8 @@ export default function Node({ data, expanded, labels }) {
expandedLabel={t("resources.total")} expandedLabel={t("resources.total")}
percentage={Math.round(((data.node.maximum - data.node.available) / data.node.maximum) * 100)} percentage={Math.round(((data.node.maximum - data.node.available) / data.node.maximum) * 100)}
expanded={expanded} expanded={expanded}
>{ labels && <WidgetLabel label={data.node.id} /> } >
{labels && <WidgetLabel label={data.node.id} />}
</Resource> </Resource>
);
} }

@ -15,27 +15,31 @@ import mapIcon from "../../../utils/weather/openmeteo-condition-map";
function Widget({ options }) { function Widget({ options }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, error } = useSWR( const { data, error } = useSWR(`api/widgets/openmeteo?${new URLSearchParams({ ...options }).toString()}`);
`api/widgets/openmeteo?${new URLSearchParams({ ...options }).toString()}`
);
if (error || data?.error) { if (error || data?.error) {
return <Error options={options} /> return <Error options={options} />;
} }
if (!data) { if (!data) {
return <Container options={options} additionalClassNames="information-widget-openmeteo"> return (
<Container options={options} additionalClassNames="information-widget-openmeteo">
<PrimaryText>{t("weather.updating")}</PrimaryText> <PrimaryText>{t("weather.updating")}</PrimaryText>
<SecondaryText>{t("weather.wait")}</SecondaryText> <SecondaryText>{t("weather.wait")}</SecondaryText>
<WidgetIcon icon={WiCloudDown} size="l" /> <WidgetIcon icon={WiCloudDown} size="l" />
</Container>; </Container>
);
} }
const unit = options.units === "metric" ? "celsius" : "fahrenheit"; const unit = options.units === "metric" ? "celsius" : "fahrenheit";
const condition = data.current_weather.weathercode; const condition = data.current_weather.weathercode;
const timeOfDay = data.current_weather.time > data.daily.sunrise[0] && data.current_weather.time < data.daily.sunset[0] ? "day" : "night"; const timeOfDay =
data.current_weather.time > data.daily.sunrise[0] && data.current_weather.time < data.daily.sunset[0]
? "day"
: "night";
return <Container options={options} additionalClassNames="information-widget-openmeteo"> return (
<Container options={options} additionalClassNames="information-widget-openmeteo">
<PrimaryText> <PrimaryText>
{options.label && `${options.label}, `} {options.label && `${options.label}, `}
{t("common.number", { {t("common.number", {
@ -46,7 +50,8 @@ function Widget({ options }) {
</PrimaryText> </PrimaryText>
<SecondaryText>{t(`wmo.${data.current_weather.weathercode}-${timeOfDay}`)}</SecondaryText> <SecondaryText>{t(`wmo.${data.current_weather.weathercode}-${timeOfDay}`)}</SecondaryText>
<WidgetIcon icon={mapIcon(condition, timeOfDay)} size="xl" /> <WidgetIcon icon={mapIcon(condition, timeOfDay)} size="xl" />
</Container>; </Container>
);
} }
export default function OpenMeteo({ options }) { export default function OpenMeteo({ options }) {
@ -73,7 +78,7 @@ export default function OpenMeteo({ options }) {
enableHighAccuracy: true, enableHighAccuracy: true,
maximumAge: 1000 * 60 * 60 * 3, maximumAge: 1000 * 60 * 60 * 3,
timeout: 1000 * 30, timeout: 1000 * 30,
} },
); );
} }
}; };
@ -81,11 +86,17 @@ export default function OpenMeteo({ options }) {
// if (!requesting && !location) requestLocation(); // if (!requesting && !location) requestLocation();
if (!location) { if (!location) {
return <ContainerButton options={options} callback={requestLocation} additionalClassNames="information-widget-openmeteo-location-button"> return (
<ContainerButton
options={options}
callback={requestLocation}
additionalClassNames="information-widget-openmeteo-location-button"
>
<PrimaryText>{t("weather.current")}</PrimaryText> <PrimaryText>{t("weather.current")}</PrimaryText>
<SecondaryText>{t("weather.allow")}</SecondaryText> <SecondaryText>{t("weather.allow")}</SecondaryText>
<WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse /> <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse />
</ContainerButton>; </ContainerButton>
);
} }
return <Widget options={{ ...location, ...options }} />; return <Widget options={{ ...location, ...options }} />;

@ -16,19 +16,21 @@ function Widget({ options }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { data, error } = useSWR( const { data, error } = useSWR(
`api/widgets/openweathermap?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}` `api/widgets/openweathermap?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,
); );
if (error || data?.cod === 401 || data?.error) { if (error || data?.cod === 401 || data?.error) {
return <Error options={options} /> return <Error options={options} />;
} }
if (!data) { if (!data) {
return <Container options={options} additionalClassNames="information-widget-openweathermap"> return (
<Container options={options} additionalClassNames="information-widget-openweathermap">
<PrimaryText>{t("weather.updating")}</PrimaryText> <PrimaryText>{t("weather.updating")}</PrimaryText>
<SecondaryText>{t("weather.wait")}</SecondaryText> <SecondaryText>{t("weather.wait")}</SecondaryText>
<WidgetIcon icon={WiCloudDown} size="l" /> <WidgetIcon icon={WiCloudDown} size="l" />
</Container>; </Container>
);
} }
const unit = options.units === "metric" ? "celsius" : "fahrenheit"; const unit = options.units === "metric" ? "celsius" : "fahrenheit";
@ -36,11 +38,16 @@ function Widget({ options }) {
const condition = data.weather[0].id; const condition = data.weather[0].id;
const timeOfDay = data.dt > data.sys.sunrise && data.dt < data.sys.sunset ? "day" : "night"; const timeOfDay = data.dt > data.sys.sunrise && data.dt < data.sys.sunset ? "day" : "night";
return <Container options={options} additionalClassNames="information-widget-openweathermap"> return (
<PrimaryText>{options.label && `${options.label}, ` }{t("common.number", { value: data.main.temp, style: "unit", unit })}</PrimaryText> <Container options={options} additionalClassNames="information-widget-openweathermap">
<PrimaryText>
{options.label && `${options.label}, `}
{t("common.number", { value: data.main.temp, style: "unit", unit })}
</PrimaryText>
<SecondaryText>{data.weather[0].description}</SecondaryText> <SecondaryText>{data.weather[0].description}</SecondaryText>
<WidgetIcon icon={mapIcon(condition, timeOfDay)} size="xl" /> <WidgetIcon icon={mapIcon(condition, timeOfDay)} size="xl" />
</Container>; </Container>
);
} }
export default function OpenWeatherMap({ options }) { export default function OpenWeatherMap({ options }) {
@ -67,17 +74,19 @@ export default function OpenWeatherMap({ options }) {
enableHighAccuracy: true, enableHighAccuracy: true,
maximumAge: 1000 * 60 * 60 * 3, maximumAge: 1000 * 60 * 60 * 3,
timeout: 1000 * 30, timeout: 1000 * 30,
} },
); );
} }
}; };
if (!location) { if (!location) {
return <ContainerButton options={options} callback={requestLocation} > return (
<ContainerButton options={options} callback={requestLocation}>
<PrimaryText>{t("weather.current")}</PrimaryText> <PrimaryText>{t("weather.current")}</PrimaryText>
<SecondaryText>{t("weather.allow")}</SecondaryText> <SecondaryText>{t("weather.allow")}</SecondaryText>
<WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse /> <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse />
</ContainerButton>; </ContainerButton>
);
} }
return <Widget options={{ ...location, ...options }} />; return <Widget options={{ ...location, ...options }} />;

@ -13,15 +13,25 @@ export default function Cpu({ expanded, refresh = 1500 }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return <Error /> return <Error />;
} }
if (!data) { if (!data) {
return <Resource icon={FiCpu} value="-" label={t("resources.cpu")} expandedValue="-" return (
expandedLabel={t("resources.load")} percentage="0" expanded={expanded} /> <Resource
icon={FiCpu}
value="-"
label={t("resources.cpu")}
expandedValue="-"
expandedLabel={t("resources.load")}
percentage="0"
expanded={expanded}
/>
);
} }
return <Resource return (
<Resource
icon={FiCpu} icon={FiCpu}
value={t("common.number", { value={t("common.number", {
value: data.cpu.usage, value: data.cpu.usage,
@ -38,4 +48,5 @@ export default function Cpu({ expanded, refresh = 1500 }) {
percentage={data.cpu.usage} percentage={data.cpu.usage}
expanded={expanded} expanded={expanded}
/> />
);
} }

@ -6,7 +6,7 @@ import Resource from "../widget/resource";
import Error from "../widget/error"; import Error from "../widget/error";
function convertToFahrenheit(t) { function convertToFahrenheit(t) {
return t * 9/5 + 32 return (t * 9) / 5 + 32;
} }
export default function CpuTemp({ expanded, units, refresh = 1500 }) { export default function CpuTemp({ expanded, units, refresh = 1500 }) {
@ -17,18 +17,20 @@ export default function CpuTemp({ expanded, units, refresh = 1500 }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return <Error /> return <Error />;
} }
if (!data || !data.cputemp) { if (!data || !data.cputemp) {
return <Resource return (
<Resource
icon={FaThermometerHalf} icon={FaThermometerHalf}
value="-" value="-"
label={t("resources.temp")} label={t("resources.temp")}
expandedValue="-" expandedValue="-"
expandedLabel={t("resources.max")} expandedLabel={t("resources.max")}
expanded={expanded} expanded={expanded}
/>; />
);
} }
let mainTemp = data.cputemp.main; let mainTemp = data.cputemp.main;
@ -36,26 +38,28 @@ export default function CpuTemp({ expanded, units, refresh = 1500 }) {
mainTemp = data.cputemp.cores.reduce((a, b) => a + b) / data.cputemp.cores.length; mainTemp = data.cputemp.cores.reduce((a, b) => a + b) / data.cputemp.cores.length;
} }
const unit = units === "imperial" ? "fahrenheit" : "celsius"; const unit = units === "imperial" ? "fahrenheit" : "celsius";
mainTemp = (unit === "celsius") ? mainTemp : convertToFahrenheit(mainTemp); mainTemp = unit === "celsius" ? mainTemp : convertToFahrenheit(mainTemp);
const maxTemp = (unit === "celsius") ? data.cputemp.max : convertToFahrenheit(data.cputemp.max); const maxTemp = unit === "celsius" ? data.cputemp.max : convertToFahrenheit(data.cputemp.max);
return <Resource return (
<Resource
icon={FaThermometerHalf} icon={FaThermometerHalf}
value={t("common.number", { value={t("common.number", {
value: mainTemp, value: mainTemp,
maximumFractionDigits: 1, maximumFractionDigits: 1,
style: "unit", style: "unit",
unit unit,
})} })}
label={t("resources.temp")} label={t("resources.temp")}
expandedValue={t("common.number", { expandedValue={t("common.number", {
value: maxTemp, value: maxTemp,
maximumFractionDigits: 1, maximumFractionDigits: 1,
style: "unit", style: "unit",
unit unit,
})} })}
expandedLabel={t("resources.max")} expandedLabel={t("resources.max")}
percentage={Math.round((mainTemp / maxTemp) * 100)} percentage={Math.round((mainTemp / maxTemp) * 100)}
expanded={expanded} expanded={expanded}
/>; />
);
} }

@ -13,11 +13,12 @@ export default function Disk({ options, expanded, refresh = 1500 }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return <Error options={options} /> return <Error options={options} />;
} }
if (!data || !data.drive) { if (!data || !data.drive) {
return <Resource return (
<Resource
icon={FiHardDrive} icon={FiHardDrive}
value="-" value="-"
label={t("resources.free")} label={t("resources.free")}
@ -25,13 +26,15 @@ export default function Disk({ options, expanded, refresh = 1500 }) {
expandedLabel={t("resources.total")} expandedLabel={t("resources.total")}
expanded={expanded} expanded={expanded}
percentage="0" percentage="0"
/>; />
);
} }
// data.drive.used not accurate? // data.drive.used not accurate?
const percent = Math.round(((data.drive.size - data.drive.available) / data.drive.size) * 100); const percent = Math.round(((data.drive.size - data.drive.available) / data.drive.size) * 100);
return <Resource return (
<Resource
icon={FiHardDrive} icon={FiHardDrive}
value={t("common.bytes", { value: data.drive.available })} value={t("common.bytes", { value: data.drive.available })}
label={t("resources.free")} label={t("resources.free")}
@ -39,5 +42,6 @@ export default function Disk({ options, expanded, refresh = 1500 }) {
expandedLabel={t("resources.total")} expandedLabel={t("resources.total")}
percentage={percent} percentage={percent}
expanded={expanded} expanded={expanded}
/>; />
);
} }

@ -13,11 +13,12 @@ export default function Memory({ expanded, refresh = 1500 }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return <Error /> return <Error />;
} }
if (!data) { if (!data) {
return <Resource return (
<Resource
icon={FaMemory} icon={FaMemory}
value="-" value="-"
label={t("resources.free")} label={t("resources.free")}
@ -25,12 +26,14 @@ export default function Memory({ expanded, refresh = 1500 }) {
expandedLabel={t("resources.total")} expandedLabel={t("resources.total")}
expanded={expanded} expanded={expanded}
percentage="0" percentage="0"
/>; />
);
} }
const percent = Math.round((data.memory.active / data.memory.total) * 100); const percent = Math.round((data.memory.active / data.memory.total) * 100);
return <Resource return (
<Resource
icon={FaMemory} icon={FaMemory}
value={t("common.bytes", { value: data.memory.available, maximumFractionDigits: 1, binary: true })} value={t("common.bytes", { value: data.memory.available, maximumFractionDigits: 1, binary: true })}
label={t("resources.free")} label={t("resources.free")}
@ -38,5 +41,6 @@ export default function Memory({ expanded, refresh = 1500 }) {
expandedLabel={t("resources.total")} expandedLabel={t("resources.total")}
percentage={percent} percentage={percent}
expanded={expanded} expanded={expanded}
/>; />
);
} }

@ -12,7 +12,8 @@ export default function Resources({ options }) {
let { refresh } = options; let { refresh } = options;
if (!refresh) refresh = 1500; if (!refresh) refresh = 1500;
refresh = Math.max(refresh, 1000); refresh = Math.max(refresh, 1000);
return <Container options={options}> return (
<Container options={options}>
<Raw> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{options.cpu && <Cpu expanded={expanded} refresh={refresh} />} {options.cpu && <Cpu expanded={expanded} refresh={refresh} />}
@ -27,5 +28,6 @@ export default function Resources({ options }) {
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div> <div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>
)} )}
</Raw> </Raw>
</Container>; </Container>
);
} }

@ -13,7 +13,7 @@ export default function Uptime({ refresh = 1500 }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return <Error /> return <Error />;
} }
if (!data) { if (!data) {
@ -21,9 +21,9 @@ export default function Uptime({ refresh = 1500 }) {
} }
const mo = Math.floor(data.uptime / (3600 * 24 * 31)); const mo = Math.floor(data.uptime / (3600 * 24 * 31));
const d = Math.floor(data.uptime % (3600 * 24 * 31) / (3600 * 24)); const d = Math.floor((data.uptime % (3600 * 24 * 31)) / (3600 * 24));
const h = Math.floor(data.uptime % (3600 * 24) / 3600); const h = Math.floor((data.uptime % (3600 * 24)) / 3600);
const m = Math.floor(data.uptime % 3600 / 60); const m = Math.floor((data.uptime % 3600) / 60);
let uptime; let uptime;
if (mo > 0) uptime = `${mo}${t("resources.months")} ${d}${t("resources.days")}`; if (mo > 0) uptime = `${mo}${t("resources.months")} ${d}${t("resources.days")}`;

@ -1,4 +1,4 @@
export default function UsageBar({ percent, additionalClassNames='' }) { export default function UsageBar({ percent, additionalClassNames = "" }) {
return ( return (
<div className={`mt-0.5 w-full bg-theme-800/30 rounded-full h-1 dark:bg-theme-200/20 ${additionalClassNames}`}> <div className={`mt-0.5 w-full bg-theme-800/30 rounded-full h-1 dark:bg-theme-200/20 ${additionalClassNames}`}>
<div <div

@ -54,7 +54,7 @@ function getAvailableProviderIds(options) {
const localStorageKey = "search-name"; const localStorageKey = "search-name";
export function getStoredProvider() { export function getStoredProvider() {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
const storedName = localStorage.getItem(localStorageKey); const storedName = localStorage.getItem(localStorageKey);
if (storedName) { if (storedName) {
return Object.values(searchProviders).find((el) => el.name === storedName); return Object.values(searchProviders).find((el) => el.name === storedName);
@ -69,7 +69,9 @@ export default function Search({ options }) {
const availableProviderIds = getAvailableProviderIds(options); const availableProviderIds = getAvailableProviderIds(options);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? searchProviders.google]); const [selectedProvider, setSelectedProvider] = useState(
searchProviders[availableProviderIds[0] ?? searchProviders.google],
);
useEffect(() => { useEffect(() => {
const storedProvider = getStoredProvider(); const storedProvider = getStoredProvider();
@ -80,7 +82,8 @@ export default function Search({ options }) {
} }
}, [availableProviderIds]); }, [availableProviderIds]);
const submitCallback = useCallback(event => { const submitCallback = useCallback(
(event) => {
const q = encodeURIComponent(query); const q = encodeURIComponent(query);
const { url } = selectedProvider; const { url } = selectedProvider;
if (url) { if (url) {
@ -92,7 +95,9 @@ export default function Search({ options }) {
event.preventDefault(); event.preventDefault();
event.target.reset(); event.target.reset();
setQuery(""); setQuery("");
}, [options.target, options.url, query, selectedProvider]); },
[options.target, options.url, query, selectedProvider],
);
if (!availableProviderIds) { if (!availableProviderIds) {
return null; return null;
@ -101,9 +106,10 @@ export default function Search({ options }) {
const onChangeProvider = (provider) => { const onChangeProvider = (provider) => {
setSelectedProvider(provider); setSelectedProvider(provider);
localStorage.setItem(localStorageKey, provider.name); localStorage.setItem(localStorageKey, provider.name);
} };
return <ContainerForm options={options} callback={submitCallback} additionalClassNames="grow information-widget-search" > return (
<ContainerForm options={options} callback={submitCallback} additionalClassNames="grow information-widget-search">
<Raw> <Raw>
<div className="flex-col relative h-8 my-4 min-w-fit"> <div className="flex-col relative h-8 my-4 min-w-fit">
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" /> <div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
@ -126,7 +132,13 @@ export default function Search({ options }) {
// eslint-disable-next-line jsx-a11y/no-autofocus // eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus} autoFocus={options.focus}
/> />
<Listbox as="div" value={selectedProvider} onChange={onChangeProvider} className="relative text-left" disabled={availableProviderIds?.length === 1}> <Listbox
as="div"
value={selectedProvider}
onChange={onChangeProvider}
className="relative text-left"
disabled={availableProviderIds?.length === 1}
>
<div> <div>
<Listbox.Button <Listbox.Button
className=" className="
@ -162,7 +174,7 @@ export default function Search({ options }) {
<li <li
className={classNames( className={classNames(
"rounded-md cursor-pointer", "rounded-md cursor-pointer",
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100" active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
)} )}
> >
<p.icon className="h-4 w-4 mx-4 my-2" /> <p.icon className="h-4 w-4 mx-4 my-2" />
@ -177,5 +189,6 @@ export default function Search({ options }) {
</Listbox> </Listbox>
</div> </div>
</Raw> </Raw>
</ContainerForm>; </ContainerForm>
);
} }

@ -19,31 +19,36 @@ export default function Widget({ options }) {
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index }); const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
if (statsError) { if (statsError) {
return <Error options={options} /> return <Error options={options} />;
} }
const defaultSite = options.site ? statsData?.data.find(s => s.desc === options.site) : statsData?.data?.find(s => s.name === "default"); const defaultSite = options.site
? statsData?.data.find((s) => s.desc === options.site)
: statsData?.data?.find((s) => s.name === "default");
if (!defaultSite) { if (!defaultSite) {
return <Container options={options} additionalClassNames="information-widget-unifi-console"> return (
<Container options={options} additionalClassNames="information-widget-unifi-console">
<PrimaryText>{t("unifi.wait")}</PrimaryText> <PrimaryText>{t("unifi.wait")}</PrimaryText>
<WidgetIcon icon={SiUbiquiti} /> <WidgetIcon icon={SiUbiquiti} />
</Container>; </Container>
);
} }
const wan = defaultSite.health.find(h => h.subsystem === "wan"); const wan = defaultSite.health.find((h) => h.subsystem === "wan");
const lan = defaultSite.health.find(h => h.subsystem === "lan"); const lan = defaultSite.health.find((h) => h.subsystem === "lan");
const wlan = defaultSite.health.find(h => h.subsystem === "wlan"); const wlan = defaultSite.health.find((h) => h.subsystem === "wlan");
[wan, lan, wlan].forEach(s => { [wan, lan, wlan].forEach((s) => {
s.up = s.status === "ok" // eslint-disable-line no-param-reassign s.up = s.status === "ok"; // eslint-disable-line no-param-reassign
s.show = s.status !== "unknown" // 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 name = wan.gw_name ?? defaultSite.desc;
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null; const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
const dataEmpty = !(wan.show || lan.show || wlan.show || uptime); const dataEmpty = !(wan.show || lan.show || wlan.show || uptime);
return <Container options={options} additionalClassNames="information-widget-unifi-console"> return (
<Container options={options} additionalClassNames="information-widget-unifi-console">
<Raw> <Raw>
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> <div className="flex-none flex flex-row items-center mr-3 py-1.5">
<div className="flex flex-col"> <div className="flex flex-col">
@ -53,16 +58,19 @@ export default function Widget({ options }) {
{name} {name}
</div> </div>
</div> </div>
{dataEmpty && <div className="flex flex-row ml-3 text-[8px] justify-between"> {dataEmpty && (
<div className="flex flex-row ml-3 text-[8px] justify-between">
<div className="flex flex-row items-center justify-end"> <div className="flex flex-row items-center justify-end">
<div className="flex flex-row"> <div className="flex flex-row">
<BiError className="w-4 h-4 text-theme-800 dark:text-theme-200" /> <BiError className="w-4 h-4 text-theme-800 dark:text-theme-200" />
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("unifi.empty_data")}</span> <span className="text-theme-800 dark:text-theme-200 text-xs">{t("unifi.empty_data")}</span>
</div> </div>
</div> </div>
</div>} </div>
)}
<div className="flex flex-row ml-3 text-[10px] justify-between"> <div className="flex flex-row ml-3 text-[10px] justify-between">
{uptime && <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"> <div className="pr-0.5 text-theme-800 dark:text-theme-200">
{t("common.number", { {t("common.number", {
value: uptime / 86400, value: uptime / 86400,
@ -70,34 +78,48 @@ export default function Widget({ options }) {
})} })}
</div> </div>
<div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.days")}</div> <div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.days")}</div>
</div>} </div>
{wan.show && <div className="flex flex-row"> )}
{wan.show && (
<div className="flex flex-row">
<div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.wan")}</div> <div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.wan")}</div>
{wan.up {wan.up ? (
? <BiCheckCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" /> <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" /> ) : (
} <BiXCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
</div>} )}
{!wan.show && !lan.show && wlan.show && <div className="flex flex-row"> </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> <div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.wlan")}</div>
{wlan.up {wlan.up ? (
? <BiCheckCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" /> <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" /> ) : (
} <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>
)}
{!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> <div className="pr-1 text-theme-800 dark:text-theme-200">{t("unifi.lan")}</div>
{lan.up {lan.up ? (
? <BiCheckCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" /> <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" /> ) : (
} <BiXCircle className="text-theme-800 dark:text-theme-200 h-4 w-3" />
</div>} )}
</div>
)}
</div> </div>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
{wlan.show && <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" /> <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="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"
title={t("unifi.users")}
>
<div className="pr-0.5"> <div className="pr-0.5">
{t("common.number", { {t("common.number", {
value: wlan.num_user, value: wlan.num_user,
@ -105,10 +127,15 @@ export default function Widget({ options }) {
})} })}
</div> </div>
</div> </div>
</div>} </div>
{lan.show && <div className="flex flex-row ml-3 pb-0.5"> )}
{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" /> <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="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"
title={t("unifi.users")}
>
<div className="pr-0.5"> <div className="pr-0.5">
{t("common.number", { {t("common.number", {
value: lan.num_user, value: lan.num_user,
@ -116,10 +143,15 @@ export default function Widget({ options }) {
})} })}
</div> </div>
</div> </div>
</div>} </div>
{(wlan.show && !lan.show || !wlan.show && lan.show) && <div className="flex flex-row ml-3 py-0.5"> )}
{((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" /> <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="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"
title={t("unifi.devices")}
>
<div className="pr-0.5"> <div className="pr-0.5">
{t("common.number", { {t("common.number", {
value: wlan.show ? wlan.num_adopted : lan.num_adopted, value: wlan.show ? wlan.num_adopted : lan.num_adopted,
@ -127,9 +159,11 @@ export default function Widget({ options }) {
})} })}
</div> </div>
</div> </div>
</div>} </div>
)}
</div> </div>
</div> </div>
</Raw> </Raw>
</Container> </Container>
);
} }

@ -16,26 +16,29 @@ function Widget({ options }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { data, error } = useSWR( const { data, error } = useSWR(
`api/widgets/weather?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}` `api/widgets/weather?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,
); );
if (error || data?.error) { if (error || data?.error) {
return <Error options={options} /> return <Error options={options} />;
} }
if (!data) { if (!data) {
return <Container options={options} additionalClassNames="information-widget-weather"> return (
<Container options={options} additionalClassNames="information-widget-weather">
<PrimaryText>{t("weather.updating")}</PrimaryText> <PrimaryText>{t("weather.updating")}</PrimaryText>
<SecondaryText>{t("weather.wait")}</SecondaryText> <SecondaryText>{t("weather.wait")}</SecondaryText>
<WidgetIcon icon={WiCloudDown} size="l" /> <WidgetIcon icon={WiCloudDown} size="l" />
</Container>; </Container>
);
} }
const unit = options.units === "metric" ? "celsius" : "fahrenheit"; const unit = options.units === "metric" ? "celsius" : "fahrenheit";
const condition = data.current.condition.code; const condition = data.current.condition.code;
const timeOfDay = data.current.is_day ? "day" : "night"; const timeOfDay = data.current.is_day ? "day" : "night";
return <Container options={options} additionalClassNames="information-widget-weather"> return (
<Container options={options} additionalClassNames="information-widget-weather">
<PrimaryText> <PrimaryText>
{options.label && `${options.label}, `} {options.label && `${options.label}, `}
{t("common.number", { {t("common.number", {
@ -46,7 +49,8 @@ function Widget({ options }) {
</PrimaryText> </PrimaryText>
<SecondaryText>{data.current.condition.text}</SecondaryText> <SecondaryText>{data.current.condition.text}</SecondaryText>
<WidgetIcon icon={mapIcon(condition, timeOfDay)} size="xl" /> <WidgetIcon icon={mapIcon(condition, timeOfDay)} size="xl" />
</Container>; </Container>
);
} }
export default function WeatherApi({ options }) { export default function WeatherApi({ options }) {
@ -73,17 +77,19 @@ export default function WeatherApi({ options }) {
enableHighAccuracy: true, enableHighAccuracy: true,
maximumAge: 1000 * 60 * 60 * 3, maximumAge: 1000 * 60 * 60 * 3,
timeout: 1000 * 30, timeout: 1000 * 30,
} },
); );
} }
}; };
if (!location) { if (!location) {
return <ContainerButton options={options} callback={requestLocation} > return (
<ContainerButton options={options} callback={requestLocation}>
<PrimaryText>{t("weather.current")}</PrimaryText> <PrimaryText>{t("weather.current")}</PrimaryText>
<SecondaryText>{t("weather.allow")}</SecondaryText> <SecondaryText>{t("weather.allow")}</SecondaryText>
<WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse /> <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse />
</ContainerButton>; </ContainerButton>
);
} }
return <Widget options={{ ...location, ...options }} />; return <Widget options={{ ...location, ...options }} />;

@ -5,20 +5,20 @@ import PrimaryText from "./primary_text";
import SecondaryText from "./secondary_text"; import SecondaryText from "./secondary_text";
import Raw from "./raw"; import Raw from "./raw";
export function getAllClasses(options, additionalClassNames = '') { export function getAllClasses(options, additionalClassNames = "") {
if (options?.style?.header === "boxedWidgets") { if (options?.style?.header === "boxedWidgets") {
if (options?.style?.cardBlur !== undefined) { if (options?.style?.cardBlur !== undefined) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
additionalClassNames = [ additionalClassNames = [
additionalClassNames, additionalClassNames,
`backdrop-blur${options.style.cardBlur.length ? '-' : ""}${options.style.cardBlur}` `backdrop-blur${options.style.cardBlur.length ? "-" : ""}${options.style.cardBlur}`,
].join(' ') ].join(" ");
} }
return classNames( return classNames(
"flex flex-col justify-center ml-2 mr-2", "flex flex-col justify-center 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", "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",
additionalClassNames additionalClassNames,
); );
} }
@ -27,32 +27,35 @@ export function getAllClasses(options, additionalClassNames = '') {
widgetAlignedClasses = "flex flex-col justify-center first:ml-auto ml-2 mr-2 "; widgetAlignedClasses = "flex flex-col justify-center first:ml-auto ml-2 mr-2 ";
} }
return classNames( return classNames(widgetAlignedClasses, additionalClassNames);
widgetAlignedClasses,
additionalClassNames
);
} }
export function getInnerBlock(children) { export function getInnerBlock(children) {
// children won't be an array if it's Raw component // children won't be an array if it's Raw component
return Array.isArray(children) && <div className="flex flex-row items-center justify-end widget-inner"> return (
<div className="flex flex-col items-center widget-inner-icon">{children.find(child => child.type === WidgetIcon)}</div> Array.isArray(children) && (
<div className="flex flex-row items-center justify-end widget-inner">
<div className="flex flex-col items-center widget-inner-icon">
{children.find((child) => child.type === WidgetIcon)}
</div>
<div className="flex flex-col ml-3 text-left widget-inner-text"> <div className="flex flex-col ml-3 text-left widget-inner-text">
{children.find(child => child.type === PrimaryText)} {children.find((child) => child.type === PrimaryText)}
{children.find(child => child.type === SecondaryText)} {children.find((child) => child.type === SecondaryText)}
</div>
</div> </div>
</div>; )
);
} }
export function getBottomBlock(children) { export function getBottomBlock(children) {
if (children.type !== Raw) { if (children.type !== Raw) {
return children.find(child => child.type === Raw) || []; return children.find((child) => child.type === Raw) || [];
} }
return [children]; return [children];
} }
export default function Container({ children = [], options, additionalClassNames = '' }) { export default function Container({ children = [], options, additionalClassNames = "" }) {
return ( return (
<div className={getAllClasses(options, `${additionalClassNames} widget-container`)}> <div className={getAllClasses(options, `${additionalClassNames} widget-container`)}>
{getInnerBlock(children)} {getInnerBlock(children)}

@ -1,8 +1,12 @@
import { getAllClasses, getInnerBlock, getBottomBlock } from "./container"; import { getAllClasses, getInnerBlock, getBottomBlock } from "./container";
export default function ContainerButton ({ children = [], options, additionalClassNames = '', callback }) { export default function ContainerButton({ children = [], options, additionalClassNames = "", callback }) {
return ( return (
<button type="button" onClick={callback} className={`${ getAllClasses(options, additionalClassNames) } information-widget-container-button`}> <button
type="button"
onClick={callback}
className={`${getAllClasses(options, additionalClassNames)} information-widget-container-button`}
>
{getInnerBlock(children)} {getInnerBlock(children)}
{getBottomBlock(children)} {getBottomBlock(children)}
</button> </button>

@ -1,8 +1,12 @@
import { getAllClasses, getInnerBlock, getBottomBlock } from "./container"; import { getAllClasses, getInnerBlock, getBottomBlock } from "./container";
export default function ContainerForm ({ children = [], options, additionalClassNames = '', callback }) { export default function ContainerForm({ children = [], options, additionalClassNames = "", callback }) {
return ( return (
<form type="button" onSubmit={callback} className={`${ getAllClasses(options, additionalClassNames) } information-widget-form`}> <form
type="button"
onSubmit={callback}
className={`${getAllClasses(options, additionalClassNames)} information-widget-form`}
>
{getInnerBlock(children)} {getInnerBlock(children)}
{getBottomBlock(children)} {getBottomBlock(children)}
</form> </form>

@ -1,8 +1,12 @@
import { getAllClasses, getInnerBlock, getBottomBlock } from "./container"; import { getAllClasses, getInnerBlock, getBottomBlock } from "./container";
export default function ContainerLink ({ children = [], options, additionalClassNames = '', target }) { export default function ContainerLink({ children = [], options, additionalClassNames = "", target }) {
return ( return (
<a href={options.url} target={target} className={`${ getAllClasses(options, additionalClassNames) } information-widget-link`}> <a
href={options.url}
target={target}
className={`${getAllClasses(options, additionalClassNames)} information-widget-link`}
>
{getInnerBlock(children)} {getInnerBlock(children)}
{getBottomBlock(children)} {getBottomBlock(children)}
</a> </a>

@ -8,8 +8,10 @@ import WidgetIcon from "./widget_icon";
export default function Error({ options }) { export default function Error({ options }) {
const { t } = useTranslation(); const { t } = useTranslation();
return <Container options={options} additionalClassNames="information-widget-error"> return (
<Container options={options} additionalClassNames="information-widget-error">
<PrimaryText>{t("widget.api_error")}</PrimaryText> <PrimaryText>{t("widget.api_error")}</PrimaryText>
<WidgetIcon icon={BiError} size="l" /> <WidgetIcon icon={BiError} size="l" />
</Container>; </Container>
);
} }

@ -1,5 +1,3 @@
export default function PrimaryText({ children }) { export default function PrimaryText({ children }) {
return ( return <span className="primary-text text-theme-800 dark:text-theme-200 text-sm">{children}</span>;
<span className="primary-text text-theme-800 dark:text-theme-200 text-sm">{children}</span>
);
} }

@ -1,22 +1,37 @@
import UsageBar from "../resources/usage-bar"; import UsageBar from "../resources/usage-bar";
export default function Resource({ children, icon, value, label, expandedValue = "", expandedLabel = "", percentage, expanded = false, additionalClassNames='' }) { export default function Resource({
children,
icon,
value,
label,
expandedValue = "",
expandedLabel = "",
percentage,
expanded = false,
additionalClassNames = "",
}) {
const Icon = icon; const Icon = icon;
return <div className={`flex-none flex flex-row items-center mr-3 py-1.5 information-widget-resource ${ additionalClassNames }`}> return (
<div
className={`flex-none flex flex-row items-center mr-3 py-1.5 information-widget-resource ${additionalClassNames}`}
>
<Icon className="text-theme-800 dark:text-theme-200 w-5 h-5 resource-icon" /> <Icon className="text-theme-800 dark:text-theme-200 w-5 h-5 resource-icon" />
<div className={ `flex flex-col ml-3 text-left min-w-[85px] ${ expanded ? ' expanded' : ''}`}> <div className={`flex flex-col ml-3 text-left min-w-[85px] ${expanded ? " expanded" : ""}`}>
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">{value}</div> <div className="pl-0.5">{value}</div>
<div className="pr-1">{label}</div> <div className="pr-1">{label}</div>
</div> </div>
{ expanded && <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> {expanded && (
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">{expandedValue}</div> <div className="pl-0.5">{expandedValue}</div>
<div className="pr-1">{expandedLabel}</div> <div className="pr-1">{expandedLabel}</div>
</div> </div>
} )}
{percentage >= 0 && <UsageBar percent={percentage} additionalClassNames="resource-usage" />} {percentage >= 0 && <UsageBar percent={percentage} additionalClassNames="resource-usage" />}
{children} {children}
</div> </div>
</div>; </div>
);
} }

@ -7,14 +7,16 @@ import WidgetLabel from "./widget_label";
export default function Resources({ options, children, target, additionalClassNames }) { export default function Resources({ options, children, target, additionalClassNames }) {
const widgetParts = [].concat(...children); const widgetParts = [].concat(...children);
const addedClassNames = classNames('information-widget-resources', additionalClassNames); const addedClassNames = classNames("information-widget-resources", additionalClassNames);
return <ContainerLink options={options} target={target} additionalClassNames={ addedClassNames }> return (
<ContainerLink options={options} target={target} additionalClassNames={addedClassNames}>
<Raw> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{ widgetParts.filter(child => child && child.type === Resource) } {widgetParts.filter((child) => child && child.type === Resource)}
</div> </div>
{ widgetParts.filter(child => child && child.type === WidgetLabel) } {widgetParts.filter((child) => child && child.type === WidgetLabel)}
</Raw> </Raw>
</ContainerLink>; </ContainerLink>
);
} }

@ -1,5 +1,3 @@
export default function SecondaryText({ children }) { export default function SecondaryText({ children }) {
return ( return <span className="secondary-text text-theme-800 dark:text-theme-200 text-xs">{children}</span>;
<span className="secondary-text text-theme-800 dark:text-theme-200 text-xs">{children}</span>
);
} }

@ -3,10 +3,17 @@ export default function WidgetIcon({ icon, size = "s", pulse = false }) {
let additionalClasses = "information-widget-icon text-theme-800 dark:text-theme-200 "; let additionalClasses = "information-widget-icon text-theme-800 dark:text-theme-200 ";
switch (size) { switch (size) {
case "m": additionalClasses += "w-6 h-6 "; break; case "m":
case "l": additionalClasses += "w-8 h-8 "; break; additionalClasses += "w-6 h-6 ";
case "xl": additionalClasses += "w-10 h-10 "; break; break;
default: additionalClasses += "w-5 h-5 "; case "l":
additionalClasses += "w-8 h-8 ";
break;
case "xl":
additionalClasses += "w-10 h-10 ";
break;
default:
additionalClasses += "w-5 h-5 ";
} }
if (pulse) { if (pulse) {

@ -1,3 +1,5 @@
export default function WidgetLabel({ label = "" }) { export default function WidgetLabel({ label = "" }) {
return <div className="information-widget-label pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{label}</div> return (
<div className="information-widget-label pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{label}</div>
);
} }

@ -23,7 +23,10 @@ function MyApp({ Component, pageProps }) {
> >
<Head> <Head>
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */} {/* 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" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
</Head> </Head>
<ColorProvider> <ColorProvider>
<ThemeProvider> <ThemeProvider>

@ -14,22 +14,21 @@ export default async function handler(req, res) {
const { path: relativePath } = req.query; const { path: relativePath } = req.query;
// only two supported files, for now // only two supported files, for now
if (!['custom.css', 'custom.js'].includes(relativePath)) if (!["custom.css", "custom.js"].includes(relativePath)) {
{ return res.status(422).end("Unsupported file");
return res.status(422).end('Unsupported file');
} }
const filePath = path.join(CONF_DIR, relativePath); const filePath = path.join(CONF_DIR, relativePath);
try { try {
// Read the content of the file or return empty content // Read the content of the file or return empty content
const fileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : ''; const fileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
// hard-coded since we only support two known files for now // hard-coded since we only support two known files for now
const mimeType = (relativePath === 'custom.css') ? 'text/css' : 'text/javascript'; const mimeType = relativePath === "custom.css" ? "text/css" : "text/javascript";
res.setHeader('Content-Type', mimeType); res.setHeader("Content-Type", mimeType);
return res.status(200).send(fileContent); return res.status(200).send(fileContent);
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return res.status(500).end('Internal Server Error'); return res.status(500).end("Internal Server Error");
} }
} }

@ -44,7 +44,8 @@ export default async function handler(req, res) {
// Try with a service deployed in Docker Swarm, if enabled // Try with a service deployed in Docker Swarm, if enabled
if (dockerArgs.swarm) { if (dockerArgs.swarm) {
const tasks = await docker.listTasks({ const tasks = await docker
.listTasks({
filters: { filters: {
service: [containerName], service: [containerName],
// A service can have several offline containers, so we only look for an active one. // A service can have several offline containers, so we only look for an active one.
@ -55,8 +56,8 @@ export default async function handler(req, res) {
// TODO: Show the result for all replicas/containers? // TODO: Show the result for all replicas/containers?
// We can only get stats for 'local' containers so try to find one // We can only get stats for 'local' containers so try to find one
const localContainerIDs = containers.map(c => c.Id); const localContainerIDs = containers.map((c) => c.Id);
const task = tasks.find(t => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0); const task = tasks.find((t) => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0);
const taskContainerId = task?.Status?.ContainerStatus?.ContainerID; const taskContainerId = task?.Status?.ContainerStatus?.ContainerID;
if (taskContainerId) { if (taskContainerId) {
@ -69,8 +70,8 @@ export default async function handler(req, res) {
}); });
} catch (e) { } catch (e) {
return res.status(200).json({ return res.status(200).json({
error: "Unable to retrieve stats" error: "Unable to retrieve stats",
}) });
} }
} }
} }

@ -44,7 +44,9 @@ export default async function handler(req, res) {
} }
if (dockerArgs.swarm) { if (dockerArgs.swarm) {
const serviceInfo = await docker.getService(containerName).inspect() const serviceInfo = await docker
.getService(containerName)
.inspect()
.catch(() => undefined); .catch(() => undefined);
if (!serviceInfo) { if (!serviceInfo) {
@ -77,8 +79,9 @@ export default async function handler(req, res) {
} }
} else { } else {
// Global service, prefer 'local' containers // Global service, prefer 'local' containers
const localContainerIDs = containers.map(c => c.Id); const localContainerIDs = containers.map((c) => c.Id);
const task = tasks.find(t => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0); const task =
tasks.find((t) => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0);
const taskContainerId = task?.Status?.ContainerStatus?.ContainerID; const taskContainerId = task?.Status?.ContainerStatus?.ContainerID;
if (taskContainerId) { if (taskContainerId) {
@ -93,8 +96,8 @@ export default async function handler(req, res) {
} catch (e) { } catch (e) {
if (task) { if (task) {
return res.status(200).json({ return res.status(200).json({
status: task.Status.State status: task.Status.State,
}) });
} }
} }
} }

@ -4,7 +4,15 @@ import { readFileSync } from "fs";
import checkAndCopyConfig, { CONF_DIR } from "utils/config/config"; import checkAndCopyConfig, { CONF_DIR } from "utils/config/config";
const configs = ["docker.yaml", "settings.yaml", "services.yaml", "bookmarks.yaml", "widgets.yaml", "custom.css", "custom.js"]; const configs = [
"docker.yaml",
"settings.yaml",
"services.yaml",
"bookmarks.yaml",
"widgets.yaml",
"custom.css",
"custom.js",
];
function hash(buffer) { function hash(buffer) {
const hashSum = createHash("sha256"); const hashSum = createHash("sha256");
@ -20,7 +28,7 @@ export default async function handler(req, res) {
}); });
// set to date by docker entrypoint, will force revalidation between restarts/recreates // set to date by docker entrypoint, will force revalidation between restarts/recreates
const buildTime = process.env.HOMEPAGE_BUILDTIME?.length ? process.env.HOMEPAGE_BUILDTIME : ''; const buildTime = process.env.HOMEPAGE_BUILDTIME?.length ? process.env.HOMEPAGE_BUILDTIME : "";
const combinedHash = hash(hashes.join("") + buildTime); const combinedHash = hash(hashes.join("") + buildTime);

@ -13,7 +13,7 @@ export default async function handler(req, res) {
const [namespace, appName] = service; const [namespace, appName] = service;
if (!namespace && !appName) { if (!namespace && !appName) {
res.status(400).send({ res.status(400).send({
error: "kubernetes query parameters are required" error: "kubernetes query parameters are required",
}); });
return; return;
} }
@ -23,13 +23,14 @@ export default async function handler(req, res) {
const kc = getKubeConfig(); const kc = getKubeConfig();
if (!kc) { if (!kc) {
res.status(500).send({ res.status(500).send({
error: "No kubernetes configuration" error: "No kubernetes configuration",
}); });
return; return;
} }
const coreApi = kc.makeApiClient(CoreV1Api); const coreApi = kc.makeApiClient(CoreV1Api);
const metricsApi = new Metrics(kc); const metricsApi = new Metrics(kc);
const podsResponse = await coreApi.listNamespacedPod(namespace, null, null, null, null, labelSelector) const podsResponse = await coreApi
.listNamespacedPod(namespace, null, null, null, null, labelSelector)
.then((response) => response.body) .then((response) => response.body)
.catch((err) => { .catch((err) => {
logger.error("Error getting pods: %d %s %s", err.statusCode, err.body, err.response); logger.error("Error getting pods: %d %s %s", err.statusCode, err.body, err.response);
@ -37,7 +38,7 @@ export default async function handler(req, res) {
}); });
if (!podsResponse) { if (!podsResponse) {
res.status(500).send({ res.status(500).send({
error: "Error communicating with kubernetes" error: "Error communicating with kubernetes",
}); });
return; return;
} }
@ -63,10 +64,12 @@ export default async function handler(req, res) {
}); });
}); });
const podStatsList = await Promise.all(pods.map(async (pod) => { const podStatsList = await Promise.all(
pods.map(async (pod) => {
let depMem = 0; let depMem = 0;
let depCpu = 0; let depCpu = 0;
const podMetrics = await metricsApi.getPodMetrics(namespace, pod.metadata.name) const podMetrics = await metricsApi
.getPodMetrics(namespace, pod.metadata.name)
.then((response) => response) .then((response) => response)
.catch((err) => { .catch((err) => {
// 404 generally means that the metrics have not been populated yet // 404 generally means that the metrics have not been populated yet
@ -83,13 +86,14 @@ export default async function handler(req, res) {
} }
return { return {
mem: depMem, mem: depMem,
cpu: depCpu cpu: depCpu,
}; };
})); }),
);
const stats = { const stats = {
mem: 0, mem: 0,
cpu: 0 cpu: 0,
} };
podStatsList.forEach((podStat) => { podStatsList.forEach((podStat) => {
stats.mem += podStat.mem; stats.mem += podStat.mem;
stats.cpu += podStat.cpu; stats.cpu += podStat.cpu;
@ -99,12 +103,12 @@ export default async function handler(req, res) {
stats.cpuUsage = cpuLimit ? stats.cpu / cpuLimit : 0; stats.cpuUsage = cpuLimit ? stats.cpu / cpuLimit : 0;
stats.memUsage = memLimit ? stats.mem / memLimit : 0; stats.memUsage = memLimit ? stats.mem / memLimit : 0;
res.status(200).json({ res.status(200).json({
stats stats,
}); });
} catch (e) { } catch (e) {
logger.error(e); logger.error(e);
res.status(500).send({ res.status(500).send({
error: "unknown error" error: "unknown error",
}); });
} }
} }

@ -21,12 +21,13 @@ export default async function handler(req, res) {
const kc = getKubeConfig(); const kc = getKubeConfig();
if (!kc) { if (!kc) {
res.status(500).send({ res.status(500).send({
error: "No kubernetes configuration" error: "No kubernetes configuration",
}); });
return; return;
} }
const coreApi = kc.makeApiClient(CoreV1Api); const coreApi = kc.makeApiClient(CoreV1Api);
const podsResponse = await coreApi.listNamespacedPod(namespace, null, null, null, null, labelSelector) const podsResponse = await coreApi
.listNamespacedPod(namespace, null, null, null, null, labelSelector)
.then((response) => response.body) .then((response) => response.body)
.catch((err) => { .catch((err) => {
logger.error("Error getting pods: %d %s %s", err.statusCode, err.body, err.response); logger.error("Error getting pods: %d %s %s", err.statusCode, err.body, err.response);
@ -34,7 +35,7 @@ export default async function handler(req, res) {
}); });
if (!podsResponse) { if (!podsResponse) {
res.status(500).send({ res.status(500).send({
error: "Error communicating with kubernetes" error: "Error communicating with kubernetes",
}); });
return; return;
} }
@ -46,7 +47,7 @@ export default async function handler(req, res) {
}); });
return; return;
} }
const someReady = pods.find(pod => pod.status.phase === "Running"); const someReady = pods.find((pod) => pod.status.phase === "Running");
const allReady = pods.every((pod) => pod.status.phase === "Running"); const allReady = pods.every((pod) => pod.status.phase === "Running");
let status = "down"; let status = "down";
if (allReady) { if (allReady) {
@ -55,7 +56,7 @@ export default async function handler(req, res) {
status = "partial"; status = "partial";
} }
res.status(200).json({ res.status(200).json({
status status,
}); });
} catch (e) { } catch (e) {
logger.error(e); logger.error(e);

@ -28,7 +28,7 @@ export default async function handler(req, res) {
try { try {
let startTime = performance.now(); let startTime = performance.now();
let [status] = await httpProxy(pingURL, { let [status] = await httpProxy(pingURL, {
method: "HEAD" method: "HEAD",
}); });
let endTime = performance.now(); let endTime = performance.now();
@ -41,12 +41,12 @@ export default async function handler(req, res) {
return res.status(200).json({ return res.status(200).json({
status, status,
latency: endTime - startTime latency: endTime - startTime,
}); });
} catch (e) { } catch (e) {
logger.debug("Error attempting ping: %s", JSON.stringify(e)); logger.debug("Error attempting ping: %s", JSON.stringify(e));
return res.status(400).send({ return res.status(400).send({
error: 'Error attempting ping, see logs.', error: "Error attempting ping, see logs.",
}); });
} }
} }

@ -44,8 +44,8 @@ export default async function handler(req, res) {
if (req.query.query && (mappingParams || optionalParams)) { if (req.query.query && (mappingParams || optionalParams)) {
const queryParams = JSON.parse(req.query.query); const queryParams = JSON.parse(req.query.query);
let filteredOptionalParams = [] let filteredOptionalParams = [];
if (optionalParams) filteredOptionalParams = optionalParams.filter(p => queryParams[p] !== undefined); if (optionalParams) filteredOptionalParams = optionalParams.filter((p) => queryParams[p] !== undefined);
let params = []; let params = [];
if (mappingParams) params = params.concat(mappingParams); if (mappingParams) params = params.concat(mappingParams);

@ -15,23 +15,25 @@ async function retrieveFromGlancesAPI(privateWidgetOptions, endpoint) {
const apiUrl = `${url}/api/3/${endpoint}`; const apiUrl = `${url}/api/3/${endpoint}`;
const headers = { const headers = {
"Accept-Encoding": "application/json" "Accept-Encoding": "application/json",
}; };
if (privateWidgetOptions.username && privateWidgetOptions.password) { if (privateWidgetOptions.username && privateWidgetOptions.password) {
headers.Authorization = `Basic ${Buffer.from(`${privateWidgetOptions.username}:${privateWidgetOptions.password}`).toString("base64")}` headers.Authorization = `Basic ${Buffer.from(
`${privateWidgetOptions.username}:${privateWidgetOptions.password}`,
).toString("base64")}`;
} }
const params = { method: "GET", headers }; const params = { method: "GET", headers };
const [status, , data] = await httpProxy(apiUrl, params); const [status, , data] = await httpProxy(apiUrl, params);
if (status === 401) { if (status === 401) {
errorMessage = `Authorization failure getting data from glances API. Data: ${data.toString()}` errorMessage = `Authorization failure getting data from glances API. Data: ${data.toString()}`;
logger.error(errorMessage); logger.error(errorMessage);
throw new Error(errorMessage); throw new Error(errorMessage);
} }
if (status !== 200) { if (status !== 200) {
errorMessage = `HTTP ${status} getting data from glances API. Data: ${data.toString()}` errorMessage = `HTTP ${status} getting data from glances API. Data: ${data.toString()}`;
logger.error(errorMessage); logger.error(errorMessage);
throw new Error(errorMessage); throw new Error(errorMessage);
} }
@ -52,7 +54,7 @@ export default async function handler(req, res) {
cpu: cpuData, cpu: cpuData,
load: loadData, load: loadData,
mem: memoryData, mem: memoryData,
} };
// Disabled by default, dont call unless needed // Disabled by default, dont call unless needed
if (includeUptime) { if (includeUptime) {

@ -11,13 +11,14 @@ export default async function handler(req, res) {
const kc = getKubeConfig(); const kc = getKubeConfig();
if (!kc) { if (!kc) {
return res.status(500).send({ return res.status(500).send({
error: "No kubernetes configuration" error: "No kubernetes configuration",
}); });
} }
const coreApi = kc.makeApiClient(CoreV1Api); const coreApi = kc.makeApiClient(CoreV1Api);
const metricsApi = new Metrics(kc); const metricsApi = new Metrics(kc);
const nodes = await coreApi.listNode() const nodes = await coreApi
.listNode()
.then((response) => response.body) .then((response) => response.body)
.catch((error) => { .catch((error) => {
logger.error("Error getting ingresses: %d %s %s", error.statusCode, error.body, error.response); logger.error("Error getting ingresses: %d %s %s", error.statusCode, error.body, error.response);
@ -25,7 +26,7 @@ export default async function handler(req, res) {
}); });
if (!nodes) { if (!nodes) {
return res.status(500).send({ return res.status(500).send({
error: "unknown error" error: "unknown error",
}); });
} }
let cpuTotal = 0; let cpuTotal = 0;
@ -37,16 +38,18 @@ export default async function handler(req, res) {
nodes.items.forEach((node) => { nodes.items.forEach((node) => {
const cpu = Number.parseInt(node.status.capacity.cpu, 10); const cpu = Number.parseInt(node.status.capacity.cpu, 10);
const mem = parseMemory(node.status.capacity.memory); const mem = parseMemory(node.status.capacity.memory);
const ready = node.status.conditions.filter(condition => condition.type === "Ready" && condition.status === "True").length > 0; const ready =
node.status.conditions.filter((condition) => condition.type === "Ready" && condition.status === "True").length >
0;
nodeMap[node.metadata.name] = { nodeMap[node.metadata.name] = {
name: node.metadata.name, name: node.metadata.name,
ready, ready,
cpu: { cpu: {
total: cpu total: cpu,
}, },
memory: { memory: {
total: mem total: mem,
} },
}; };
cpuTotal += cpu; cpuTotal += cpu;
memTotal += mem; memTotal += mem;
@ -68,7 +71,7 @@ export default async function handler(req, res) {
} catch (error) { } catch (error) {
logger.error("Error getting metrics, ensure you have metrics-server installed: s", JSON.stringify(error)); logger.error("Error getting metrics, ensure you have metrics-server installed: s", JSON.stringify(error));
return res.status(500).send({ return res.status(500).send({
error: "Error getting metrics, check logs for more details" error: "Error getting metrics, check logs for more details",
}); });
} }
@ -76,24 +79,24 @@ export default async function handler(req, res) {
cpu: { cpu: {
load: cpuUsage, load: cpuUsage,
total: cpuTotal, total: cpuTotal,
percent: (cpuUsage / cpuTotal) * 100 percent: (cpuUsage / cpuTotal) * 100,
}, },
memory: { memory: {
used: memUsage, used: memUsage,
total: memTotal, total: memTotal,
free: (memTotal - memUsage), free: memTotal - memUsage,
percent: (memUsage / memTotal) * 100 percent: (memUsage / memTotal) * 100,
} },
}; };
return res.status(200).json({ return res.status(200).json({
cluster, cluster,
nodes: Object.entries(nodeMap).map(([name, node]) => ({ name, ...node })) nodes: Object.entries(nodeMap).map(([name, node]) => ({ name, ...node })),
}); });
} catch (e) { } catch (e) {
logger.error("exception %s", e); logger.error("exception %s", e);
return res.status(500).send({ return res.status(500).send({
error: "unknown error" error: "unknown error",
}); });
} }
} }

@ -57,10 +57,10 @@ export default async function handler(req, res) {
const apiUrl = `${url}/v1/nodes`; const apiUrl = `${url}/v1/nodes`;
const headers = { const headers = {
"Accept-Encoding": "application/json" "Accept-Encoding": "application/json",
}; };
if (username && password) { if (username && password) {
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
} }
const params = { method: "GET", headers }; const params = { method: "GET", headers };

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

@ -1,6 +1,6 @@
import { existsSync } from "fs"; import { existsSync } from "fs";
const si = require('systeminformation'); const si = require("systeminformation");
export default async function handler(req, res) { export default async function handler(req, res) {
const { type, target } = req.query; const { type, target } = req.query;
@ -25,7 +25,7 @@ export default async function handler(req, res) {
const fsSize = await si.fsSize(); const fsSize = await si.fsSize();
return res.status(200).json({ return res.status(200).json({
drive: fsSize.find(fs => fs.mount === target) ?? fsSize.find(fs => fs.mount === "/") drive: fsSize.find((fs) => fs.mount === target) ?? fsSize.find((fs) => fs.mount === "/"),
}); });
} }
@ -44,7 +44,7 @@ export default async function handler(req, res) {
if (type === "uptime") { if (type === "uptime") {
const timeData = await si.time(); const timeData = await si.time();
return res.status(200).json({ return res.status(200).json({
uptime: timeData.uptime uptime: timeData.uptime,
}); });
} }

@ -183,7 +183,10 @@ function Home({ initialSettings }) {
const { data: bookmarks } = useSWR("api/bookmarks"); const { data: bookmarks } = useSWR("api/bookmarks");
const { data: widgets } = useSWR("api/widgets"); const { data: widgets } = useSWR("api/widgets");
const servicesAndBookmarks = [...services.map(sg => sg.services).flat(), ...bookmarks.map(bg => bg.bookmarks).flat()].filter(i => i?.href); const servicesAndBookmarks = [
...services.map((sg) => sg.services).flat(),
...bookmarks.map((bg) => bg.bookmarks).flat(),
].filter((i) => i?.href);
useEffect(() => { useEffect(() => {
if (settings.language) { if (settings.language) {
@ -202,15 +205,15 @@ function Home({ initialSettings }) {
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searchString, setSearchString] = useState(""); const [searchString, setSearchString] = useState("");
let searchProvider = null; let searchProvider = null;
const searchWidget = Object.values(widgets).find(w => w.type === "search"); const searchWidget = Object.values(widgets).find((w) => w.type === "search");
if (searchWidget) { if (searchWidget) {
if (Array.isArray(searchWidget.options?.provider)) { if (Array.isArray(searchWidget.options?.provider)) {
// if search provider is a list, try to retrieve from localstorage, fall back to the first // if search provider is a list, try to retrieve from localstorage, fall back to the first
searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]]; searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]];
} else if (searchWidget.options?.provider === 'custom') { } else if (searchWidget.options?.provider === "custom") {
searchProvider = { searchProvider = {
url: searchWidget.options.url url: searchWidget.options.url,
} };
} else { } else {
searchProvider = searchProviders[searchWidget.options?.provider]; searchProvider = searchProviders[searchWidget.options?.provider];
} }
@ -229,35 +232,38 @@ function Home({ initialSettings }) {
} }
} }
document.addEventListener('keydown', handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return function cleanup() { return function cleanup() {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
} };
}) });
const tabs = useMemo( () => [ const tabs = useMemo(
() => [
...new Set( ...new Set(
Object.keys(settings.layout ?? {}).map( Object.keys(settings.layout ?? {})
(groupName) => settings.layout[groupName]?.tab?.toString() .map((groupName) => settings.layout[groupName]?.tab?.toString())
).filter(group => group) .filter((group) => group),
) ),
], [settings.layout]); ],
[settings.layout],
);
useEffect(() => { useEffect(() => {
if (!activeTab) { if (!activeTab) {
const initialTab = decodeURI(asPath.substring(asPath.indexOf("#") + 1)); const initialTab = decodeURI(asPath.substring(asPath.indexOf("#") + 1));
setActiveTab(initialTab === '/' ? slugify(tabs['0']) : initialTab) setActiveTab(initialTab === "/" ? slugify(tabs["0"]) : initialTab);
} }
}) });
const servicesAndBookmarksGroups = useMemo(() => { const servicesAndBookmarksGroups = useMemo(() => {
const tabGroupFilter = g => g && [activeTab, ''].includes(slugify(settings.layout?.[g.name]?.tab)); const tabGroupFilter = (g) => g && [activeTab, ""].includes(slugify(settings.layout?.[g.name]?.tab));
const undefinedGroupFilter = g => settings.layout?.[g.name] === undefined; const undefinedGroupFilter = (g) => settings.layout?.[g.name] === undefined;
const layoutGroups = Object.keys(settings.layout ?? {}).map( const layoutGroups = Object.keys(settings.layout ?? {})
(groupName) => services?.find(g => g.name === groupName) ?? bookmarks?.find(b => b.name === groupName) .map((groupName) => services?.find((g) => g.name === groupName) ?? bookmarks?.find((b) => b.name === groupName))
).filter(tabGroupFilter); .filter(tabGroupFilter);
if (!settings.layout && JSON.stringify(settings.layout) !== JSON.stringify(initialSettings.layout)) { if (!settings.layout && JSON.stringify(settings.layout) !== JSON.stringify(initialSettings.layout)) {
// wait for settings to populate (if different from initial settings), otherwise all the widgets will be requested initially even if we are on a single tab // wait for settings to populate (if different from initial settings), otherwise all the widgets will be requested initially even if we are on a single tab
@ -267,36 +273,51 @@ function Home({ initialSettings }) {
const serviceGroups = services?.filter(tabGroupFilter).filter(undefinedGroupFilter); const serviceGroups = services?.filter(tabGroupFilter).filter(undefinedGroupFilter);
const bookmarkGroups = bookmarks.filter(tabGroupFilter).filter(undefinedGroupFilter); const bookmarkGroups = bookmarks.filter(tabGroupFilter).filter(undefinedGroupFilter);
return <> return (
{tabs.length > 0 && <div key="tabs" id="tabs" className="m-6 sm:m-9 sm:mt-4 sm:mb-0"> <>
<ul className={classNames( {tabs.length > 0 && (
<div key="tabs" id="tabs" className="m-6 sm:m-9 sm:mt-4 sm:mb-0">
<ul
className={classNames(
"sm:flex rounded-md bg-theme-100/20 dark:bg-white/5", "sm:flex rounded-md bg-theme-100/20 dark:bg-white/5",
settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? '-': "" }${settings.cardBlur}` settings.cardBlur !== undefined &&
)} id="myTab" data-tabs-toggle="#myTabContent" role="tablist" > `backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`,
{tabs.map(tab => <Tab key={tab} tab={tab} />)} )}
id="myTab"
data-tabs-toggle="#myTabContent"
role="tablist"
>
{tabs.map((tab) => (
<Tab key={tab} tab={tab} />
))}
</ul> </ul>
</div>} </div>
{layoutGroups.length > 0 && <div key="layoutGroups" id="layout-groups" className="flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2"> )}
{layoutGroups.map((group) => ( {layoutGroups.length > 0 && (
group.services ? <div key="layoutGroups" id="layout-groups" className="flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2">
(<ServicesGroup {layoutGroups.map((group) =>
group.services ? (
<ServicesGroup
key={group.name} key={group.name}
group={group.name} group={group.name}
services={group} services={group}
layout={settings.layout?.[group.name]} layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns} fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse} disableCollapse={settings.disableCollapse}
/>) : />
(<BookmarksGroup ) : (
<BookmarksGroup
key={group.name} key={group.name}
bookmarks={group} bookmarks={group}
layout={settings.layout?.[group.name]} layout={settings.layout?.[group.name]}
disableCollapse={settings.disableCollapse} disableCollapse={settings.disableCollapse}
/>) />
) ),
)}
</div>
)} )}
</div>} {serviceGroups?.length > 0 && (
{serviceGroups?.length > 0 && <div key="services" id="services" className="flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2"> <div key="services" id="services" className="flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2">
{serviceGroups.map((group) => ( {serviceGroups.map((group) => (
<ServicesGroup <ServicesGroup
key={group.name} key={group.name}
@ -307,8 +328,10 @@ function Home({ initialSettings }) {
disableCollapse={settings.disableCollapse} disableCollapse={settings.disableCollapse}
/> />
))} ))}
</div>} </div>
{bookmarkGroups?.length > 0 && <div key="bookmarks" id="bookmarks" className="flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2"> )}
{bookmarkGroups?.length > 0 && (
<div key="bookmarks" id="bookmarks" className="flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2">
{bookmarkGroups.map((group) => ( {bookmarkGroups.map((group) => (
<BookmarksGroup <BookmarksGroup
key={group.name} key={group.name}
@ -317,8 +340,10 @@ function Home({ initialSettings }) {
disableCollapse={settings.disableCollapse} disableCollapse={settings.disableCollapse}
/> />
))} ))}
</div>} </div>
)}
</> </>
);
}, [ }, [
tabs, tabs,
activeTab, activeTab,
@ -328,7 +353,7 @@ function Home({ initialSettings }) {
settings.fiveColumns, settings.fiveColumns,
settings.disableCollapse, settings.disableCollapse,
settings.cardBlur, settings.cardBlur,
initialSettings.layout initialSettings.layout,
]); ]);
return ( return (
@ -355,7 +380,8 @@ function Home({ initialSettings }) {
<link rel="preload" href="api/config/custom.css" as="fetch" crossOrigin="anonymous" /> <link rel="preload" href="api/config/custom.css" as="fetch" crossOrigin="anonymous" />
<style data-name="custom.css"> <style data-name="custom.css">
<FileContent path="custom.css" <FileContent
path="custom.css"
loadingValue="/* Loading custom CSS... */" loadingValue="/* Loading custom CSS... */"
errorValue="/* Failed to load custom CSS... */" errorValue="/* Failed to load custom CSS... */"
emptyValue="/* No custom CSS */" emptyValue="/* No custom CSS */"
@ -378,31 +404,43 @@ function Home({ initialSettings }) {
className={classNames( className={classNames(
"flex flex-row flex-wrap justify-between", "flex flex-row flex-wrap justify-between",
headerStyles[headerStyle], headerStyles[headerStyle],
settings.cardBlur !== undefined && headerStyle === "boxed" && `backdrop-blur${settings.cardBlur.length ? '-' : ""}${settings.cardBlur}` settings.cardBlur !== undefined &&
headerStyle === "boxed" &&
`backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`,
)} )}
> >
<div id="widgets-wrap" <div
style={{width: 'calc(100% + 1rem)'}} id="widgets-wrap"
className={classNames( style={{ width: "calc(100% + 1rem)" }}
"flex flex-row w-full flex-wrap justify-between -ml-2 -mr-2" className={classNames("flex flex-row w-full flex-wrap justify-between -ml-2 -mr-2")}
)}
> >
{widgets && ( {widgets && (
<> <>
{widgets {widgets
.filter((widget) => !rightAlignedWidgets.includes(widget.type)) .filter((widget) => !rightAlignedWidgets.includes(widget.type))
.map((widget, i) => ( .map((widget, i) => (
<Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: false, cardBlur: settings.cardBlur }} /> <Widget
key={i}
widget={widget}
style={{ header: headerStyle, isRightAligned: false, cardBlur: settings.cardBlur }}
/>
))} ))}
<div id="information-widgets-right" className={classNames( <div
id="information-widgets-right"
className={classNames(
"m-auto flex flex-wrap grow sm:basis-auto justify-between md:justify-end", "m-auto flex flex-wrap grow sm:basis-auto justify-between md:justify-end",
headerStyle === "boxedWidgets" ? "sm:ml-4" : "sm:ml-2" headerStyle === "boxedWidgets" ? "sm:ml-4" : "sm:ml-2",
)}> )}
>
{widgets {widgets
.filter((widget) => rightAlignedWidgets.includes(widget.type)) .filter((widget) => rightAlignedWidgets.includes(widget.type))
.map((widget, i) => ( .map((widget, i) => (
<Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: true, cardBlur: settings.cardBlur }} /> <Widget
key={i}
widget={widget}
style={{ header: headerStyle, isRightAligned: true, cardBlur: settings.cardBlur }}
/>
))} ))}
</div> </div>
</> </>
@ -436,7 +474,7 @@ export default function Wrapper({ initialSettings, fallback }) {
if (initialSettings && initialSettings.background) { if (initialSettings && initialSettings.background) {
let opacity = initialSettings.backgroundOpacity ?? 1; let opacity = initialSettings.backgroundOpacity ?? 1;
let backgroundImage = initialSettings.background; let backgroundImage = initialSettings.background;
if (typeof initialSettings.background === 'object') { if (typeof initialSettings.background === "object") {
backgroundImage = initialSettings.background.image; backgroundImage = initialSettings.background.image;
backgroundBlur = initialSettings.background.blur !== undefined; backgroundBlur = initialSettings.background.blur !== undefined;
backgroundSaturate = initialSettings.background.saturate !== undefined; backgroundSaturate = initialSettings.background.saturate !== undefined;
@ -460,7 +498,7 @@ export default function Wrapper({ initialSettings, fallback }) {
className={classNames( className={classNames(
"relative", "relative",
initialSettings.theme && initialSettings.theme, initialSettings.theme && initialSettings.theme,
initialSettings.color && `theme-${initialSettings.color}` initialSettings.color && `theme-${initialSettings.color}`,
)} )}
> >
<div <div
@ -472,11 +510,13 @@ export default function Wrapper({ initialSettings, fallback }) {
id="inner_wrapper" id="inner_wrapper"
tabIndex="-1" tabIndex="-1"
className={classNames( className={classNames(
'fixed overflow-auto w-full h-full', "fixed overflow-auto w-full h-full",
backgroundBlur && `backdrop-blur${initialSettings.background.blur.length ? '-' : ""}${initialSettings.background.blur}`, backgroundBlur &&
`backdrop-blur${initialSettings.background.blur.length ? "-" : ""}${initialSettings.background.blur}`,
backgroundSaturate && `backdrop-saturate-${initialSettings.background.saturate}`, backgroundSaturate && `backdrop-saturate-${initialSettings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`, backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,
)}> )}
>
<Index initialSettings={initialSettings} fallback={fallback} /> <Index initialSettings={initialSettings} fallback={fallback} />
</div> </div>
</div> </div>

@ -9,7 +9,7 @@ import {
servicesFromConfig, servicesFromConfig,
servicesFromDocker, servicesFromDocker,
cleanServiceGroups, cleanServiceGroups,
servicesFromKubernetes servicesFromKubernetes,
} from "utils/config/service-helpers"; } from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers"; import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
@ -59,7 +59,7 @@ export async function bookmarksResponse() {
bookmarksArray.forEach((group) => { bookmarksArray.forEach((group) => {
if (definedLayouts) { if (definedLayouts) {
const layoutIndex = definedLayouts.findIndex(layout => layout === group.name); const layoutIndex = definedLayouts.findIndex((layout) => layout === group.name);
if (layoutIndex > -1) sortedGroups[layoutIndex] = group; if (layoutIndex > -1) sortedGroups[layoutIndex] = group;
else unsortedGroups.push(group); else unsortedGroups.push(group);
} else { } else {
@ -67,7 +67,7 @@ export async function bookmarksResponse() {
} }
}); });
return [...sortedGroups.filter(g => g), ...unsortedGroups]; return [...sortedGroups.filter((g) => g), ...unsortedGroups];
} }
export async function widgetsResponse() { export async function widgetsResponse() {
@ -126,11 +126,13 @@ export async function servicesResponse() {
} }
const mergedGroupsNames = [ const mergedGroupsNames = [
...new Set([ ...new Set(
[
discoveredDockerServices.map((group) => group.name), discoveredDockerServices.map((group) => group.name),
discoveredKubernetesServices.map((group) => group.name), discoveredKubernetesServices.map((group) => group.name),
configuredServices.map((group) => group.name), configuredServices.map((group) => group.name),
].flat()), ].flat(),
),
]; ];
const sortedGroups = []; const sortedGroups = [];
@ -138,22 +140,23 @@ export async function servicesResponse() {
const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null; const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;
mergedGroupsNames.forEach((groupName) => { mergedGroupsNames.forEach((groupName) => {
const discoveredDockerGroup = discoveredDockerServices.find((group) => group.name === groupName) || { services: [] }; const discoveredDockerGroup = discoveredDockerServices.find((group) => group.name === groupName) || {
const discoveredKubernetesGroup = discoveredKubernetesServices.find((group) => group.name === groupName) || { services: [] }; services: [],
};
const discoveredKubernetesGroup = discoveredKubernetesServices.find((group) => group.name === groupName) || {
services: [],
};
const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] }; const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] };
const mergedGroup = { const mergedGroup = {
name: groupName, name: groupName,
services: [ services: [...discoveredDockerGroup.services, ...discoveredKubernetesGroup.services, ...configuredGroup.services]
...discoveredDockerGroup.services, .filter((service) => service)
...discoveredKubernetesGroup.services,
...configuredGroup.services
].filter((service) => service)
.sort(compareServices), .sort(compareServices),
}; };
if (definedLayouts) { if (definedLayouts) {
const layoutIndex = definedLayouts.findIndex(layout => layout === mergedGroup.name); const layoutIndex = definedLayouts.findIndex((layout) => layout === mergedGroup.name);
if (layoutIndex > -1) sortedGroups[layoutIndex] = mergedGroup; if (layoutIndex > -1) sortedGroups[layoutIndex] = mergedGroup;
else unsortedGroups.push(mergedGroup); else unsortedGroups.push(mergedGroup);
} else { } else {
@ -161,5 +164,5 @@ export async function servicesResponse() {
} }
}); });
return [...sortedGroups.filter(g => g), ...unsortedGroups]; return [...sortedGroups.filter((g) => g), ...unsortedGroups];
} }

@ -9,7 +9,9 @@ const cacheKey = "homepageEnvironmentVariables";
const homepageVarPrefix = "HOMEPAGE_VAR_"; const homepageVarPrefix = "HOMEPAGE_VAR_";
const homepageFilePrefix = "HOMEPAGE_FILE_"; const homepageFilePrefix = "HOMEPAGE_FILE_";
export const CONF_DIR = process.env.HOMEPAGE_CONFIG_DIR ? process.env.HOMEPAGE_CONFIG_DIR : join(process.cwd(), "config"); export const CONF_DIR = process.env.HOMEPAGE_CONFIG_DIR
? process.env.HOMEPAGE_CONFIG_DIR
: join(process.cwd(), "config");
export default function checkAndCopyConfig(config) { export default function checkAndCopyConfig(config) {
if (!existsSync(CONF_DIR)) { if (!existsSync(CONF_DIR)) {
@ -20,7 +22,7 @@ export default function checkAndCopyConfig(config) {
if (!existsSync(configYaml)) { if (!existsSync(configYaml)) {
const configSkeleton = join(process.cwd(), "src", "skeleton", config); const configSkeleton = join(process.cwd(), "src", "skeleton", config);
try { try {
copyFileSync(configSkeleton, configYaml) copyFileSync(configSkeleton, configYaml);
console.info("%s was copied to the config folder", config); console.info("%s was copied to the config folder", config);
} catch (err) { } catch (err) {
console.error("error copying config", err); console.error("error copying config", err);
@ -42,7 +44,9 @@ function getCachedEnvironmentVars() {
let cachedVars = cache.get(cacheKey); let cachedVars = cache.get(cacheKey);
if (!cachedVars) { if (!cachedVars) {
// initialize cache // initialize cache
cachedVars = Object.entries(process.env).filter(([key, ]) => key.includes(homepageVarPrefix) || key.includes(homepageFilePrefix)); cachedVars = Object.entries(process.env).filter(
([key]) => key.includes(homepageVarPrefix) || key.includes(homepageFilePrefix),
);
cache.put(cacheKey, cachedVars); cache.put(cacheKey, cachedVars);
} }
return cachedVars; return cachedVars;
@ -50,7 +54,8 @@ function getCachedEnvironmentVars() {
export function substituteEnvironmentVars(str) { export function substituteEnvironmentVars(str) {
let result = str; let result = str;
if (result.includes('{{')) { // crude check if we have vars to replace if (result.includes("{{")) {
// crude check if we have vars to replace
const cachedVars = getCachedEnvironmentVars(); const cachedVars = getCachedEnvironmentVars();
cachedVars.forEach(([key, value]) => { cachedVars.forEach(([key, value]) => {
if (key.startsWith(homepageVarPrefix)) { if (key.startsWith(homepageVarPrefix)) {
@ -77,13 +82,13 @@ export function getSettings() {
// support yaml list but old spec was object so convert to that // support yaml list but old spec was object so convert to that
// see https://github.com/gethomepage/homepage/issues/1546 // see https://github.com/gethomepage/homepage/issues/1546
if (Array.isArray(initialSettings.layout)) { if (Array.isArray(initialSettings.layout)) {
const layoutItems = initialSettings.layout const layoutItems = initialSettings.layout;
initialSettings.layout = {} initialSettings.layout = {};
layoutItems.forEach(i => { layoutItems.forEach((i) => {
const name = Object.keys(i)[0] const name = Object.keys(i)[0];
initialSettings.layout[name] = i[name] initialSettings.layout[name] = i[name];
}) });
} }
} }
return initialSettings return initialSettings;
} }

@ -30,7 +30,7 @@ export default function getDockerArguments(server) {
const res = { const res = {
conn: { host: servers[server].host }, conn: { host: servers[server].host },
swarm: !!servers[server].swarm, swarm: !!servers[server].swarm,
} };
if (servers[server].port) { if (servers[server].port) {
res.conn.port = servers[server].port; res.conn.port = servers[server].port;

@ -16,13 +16,13 @@ export default function getKubeConfig() {
const kc = new KubeConfig(); const kc = new KubeConfig();
switch (config?.mode) { switch (config?.mode) {
case 'cluster': case "cluster":
kc.loadFromCluster(); kc.loadFromCluster();
break; break;
case 'default': case "default":
kc.loadFromDefault(); kc.loadFromDefault();
break; break;
case 'disabled': case "disabled":
default: default:
return null; return null;
} }

@ -92,7 +92,7 @@ export async function servicesFromDocker() {
shvl.set( shvl.set(
constructedService, constructedService,
label.replace("homepage.", ""), label.replace("homepage.", ""),
substituteEnvironmentVars(containerLabels[label]) substituteEnvironmentVars(containerLabels[label]),
); );
} }
}); });
@ -105,7 +105,7 @@ export async function servicesFromDocker() {
// a server failed, but others may succeed // a server failed, but others may succeed
return { server: serverName, services: [] }; return { server: serverName, services: [] };
} }
}) }),
); );
const mappedServiceGroups = []; const mappedServiceGroups = [];
@ -152,13 +152,13 @@ export async function checkCRD(kc, name) {
"Error checking if CRD %s exists. Make sure to add the following permission to your RBAC: %d %s %s", "Error checking if CRD %s exists. Make sure to add the following permission to your RBAC: %d %s %s",
name, name,
error.statusCode, error.statusCode,
error.body.message error.body.message,
); );
} }
return false return false;
}); });
return exist return exist;
} }
export async function servicesFromKubernetes() { export async function servicesFromKubernetes() {
@ -195,7 +195,7 @@ export async function servicesFromKubernetes() {
"Error getting traefik ingresses from traefik.containo.us: %d %s %s", "Error getting traefik ingresses from traefik.containo.us: %d %s %s",
error.statusCode, error.statusCode,
error.body, error.body,
error.response error.response,
); );
} }
@ -211,18 +211,18 @@ export async function servicesFromKubernetes() {
"Error getting traefik ingresses from traefik.io: %d %s %s", "Error getting traefik ingresses from traefik.io: %d %s %s",
error.statusCode, error.statusCode,
error.body, error.body,
error.response error.response,
); );
} }
return []; return [];
}); });
const traefikIngressList = [...traefikIngressListContaino?.items ?? [], ...traefikIngressListIo?.items ?? []]; const traefikIngressList = [...(traefikIngressListContaino?.items ?? []), ...(traefikIngressListIo?.items ?? [])];
if (traefikIngressList.length > 0) { if (traefikIngressList.length > 0) {
const traefikServices = traefikIngressList.filter( const traefikServices = traefikIngressList.filter(
(ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`] (ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`],
); );
ingressList.items.push(...traefikServices); ingressList.items.push(...traefikServices);
} }
@ -233,7 +233,7 @@ export async function servicesFromKubernetes() {
const services = ingressList.items const services = ingressList.items
.filter( .filter(
(ingress) => (ingress) =>
ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/enabled`] === "true" ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/enabled`] === "true",
) )
.map((ingress) => { .map((ingress) => {
let constructedService = { let constructedService = {
@ -266,7 +266,7 @@ export async function servicesFromKubernetes() {
shvl.set( shvl.set(
constructedService, constructedService,
annotation.replace(`${ANNOTATION_BASE}/`, ""), annotation.replace(`${ANNOTATION_BASE}/`, ""),
ingress.metadata.annotations[annotation] ingress.metadata.annotations[annotation],
); );
} }
}); });

@ -27,15 +27,13 @@ SOFTWARE.
*/ */
export function get(object, path, def) { export function get(object, path, def) {
return (
// Split the path into keys and reduce the object to the target value // Split the path into keys and reduce the object to the target value
object = path.split(/[.[\]]+/).reduce(function (obj, p) { return (object = path.split(/[.[\]]+/).reduce(function (obj, p) {
// Check each nested object to see if the key exists // Check each nested object to see if the key exists
return obj && obj[p] !== undefined ? obj[p] : undefined; return obj && obj[p] !== undefined ? obj[p] : undefined;
}, object) }, object)) === undefined
) === undefined ? // If the final value is undefined, return the default value
// If the final value is undefined, return the default value def
? def
: object; // Otherwise, return the value found : object; // Otherwise, return the value found
} }
@ -58,9 +56,7 @@ export function set(obj, path, val) {
const isIndex = /^\d+$/.test(keys[i + 1]); const isIndex = /^\d+$/.test(keys[i + 1]);
// If current key doesn't exist, initialise it as an array or object // If current key doesn't exist, initialise it as an array or object
acc[key] = Array.isArray(acc[key]) acc[key] = Array.isArray(acc[key]) ? acc[key] : isIndex ? [] : acc[key] || {};
? acc[key]
: (isIndex ? [] : acc[key] || {});
// Return nested object for next iteration // Return nested object for next iteration
return acc[key]; return acc[key];

@ -20,7 +20,7 @@ export async function widgetsFromConfig() {
type: Object.keys(group)[0], type: Object.keys(group)[0],
options: { options: {
index, index,
...group[Object.keys(group)[0]] ...group[Object.keys(group)[0]],
}, },
})); }));
return widgetsArray; return widgetsArray;
@ -47,9 +47,9 @@ export async function cleanWidgetGroups(widgets) {
type: widget.type, type: widget.type,
options: { options: {
index, index,
...sanitizedOptions ...sanitizedOptions,
}, },
} };
}); });
} }
@ -57,13 +57,7 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
const widgets = await widgetsFromConfig(); const widgets = await widgetsFromConfig();
const privateOptions = widgets.map((widget) => { const privateOptions = widgets.map((widget) => {
const { const { index, url, username, password, key } = widget.options;
index,
url,
username,
password,
key
} = widget.options;
return { return {
type: widget.type, type: widget.type,
@ -72,10 +66,12 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
url, url,
username, username,
password, password,
key key,
}, },
} };
}); });
return (type !== undefined && widgetIndex !== undefined) ? privateOptions.find(o => o.type === type && o.options.index === parseInt(widgetIndex, 10))?.options : privateOptions; return type !== undefined && widgetIndex !== undefined
? privateOptions.find((o) => o.type === type && o.options.index === parseInt(widgetIndex, 10))?.options
: privateOptions;
} }

@ -4,11 +4,11 @@ export function parseCpu(cpuStr) {
const units = cpuStr.substring(cpuStr.length - unitLength); const units = cpuStr.substring(cpuStr.length - unitLength);
if (Number.isNaN(Number(units))) { if (Number.isNaN(Number(units))) {
switch (units) { switch (units) {
case 'n': case "n":
return base / 1000000000; return base / 1000000000;
case 'u': case "u":
return base / 1000000; return base / 1000000;
case 'm': case "m":
return base / 1000; return base / 1000;
default: default:
return base; return base;
@ -19,22 +19,22 @@ export function parseCpu(cpuStr) {
} }
export function parseMemory(memStr) { export function parseMemory(memStr) {
const unitLength = (memStr.substring(memStr.length - 1) === 'i' ? 2 : 1); const unitLength = memStr.substring(memStr.length - 1) === "i" ? 2 : 1;
const base = Number.parseInt(memStr, 10); const base = Number.parseInt(memStr, 10);
const units = memStr.substring(memStr.length - unitLength); const units = memStr.substring(memStr.length - unitLength);
if (Number.isNaN(Number(units))) { if (Number.isNaN(Number(units))) {
switch (units) { switch (units) {
case 'Ki': case "Ki":
return base * 1000; return base * 1000;
case 'K': case "K":
return base * 1024; return base * 1024;
case 'Mi': case "Mi":
return base * 1000000; return base * 1000000;
case 'M': case "M":
return base * 1024 * 1024; return base * 1024 * 1024;
case 'Gi': case "Gi":
return base * 1000000000; return base * 1000000000;
case 'G': case "G":
return base * 1024 * 1024 * 1024; return base * 1024 * 1024 * 1024;
default: default:
return base; return base;

@ -5,7 +5,6 @@ import winston from "winston";
import checkAndCopyConfig, { getSettings, CONF_DIR } from "utils/config/config"; import checkAndCopyConfig, { getSettings, CONF_DIR } from "utils/config/config";
let winstonLogger; let winstonLogger;
function init() { function init() {
@ -48,7 +47,7 @@ function init() {
combineMessageAndSplat(), combineMessageAndSplat(),
winston.format.timestamp(), winston.format.timestamp(),
winston.format.colorize(), winston.format.colorize(),
winston.format.printf(messageFormatter) winston.format.printf(messageFormatter),
), ),
handleExceptions: true, handleExceptions: true,
handleRejections: true, handleRejections: true,
@ -59,7 +58,7 @@ function init() {
winston.format.errors({ stack: true }), winston.format.errors({ stack: true }),
combineMessageAndSplat(), combineMessageAndSplat(),
winston.format.timestamp(), winston.format.timestamp(),
winston.format.printf(messageFormatter) winston.format.printf(messageFormatter),
), ),
filename: `${logpath}/logs/homepage.log`, filename: `${logpath}/logs/homepage.log`,
handleExceptions: true, handleExceptions: true,

@ -57,8 +57,8 @@ export function jsonArrayFilter(data, filter) {
export function sanitizeErrorURL(errorURL) { export function sanitizeErrorURL(errorURL) {
// Dont display sensitive params on frontend // Dont display sensitive params on frontend
const url = new URL(errorURL); const url = new URL(errorURL);
["apikey", "api_key", "token", "t"].forEach(key => { ["apikey", "api_key", "token", "t"].forEach((key) => {
if (url.searchParams.has(key)) url.searchParams.set(key, "***") if (url.searchParams.has(key)) url.searchParams.set(key, "***");
}); });
return url.toString(); return url.toString();
} }

@ -28,16 +28,11 @@ export default async function credentialedProxyHandler(req, res, map) {
headers["X-CMC_PRO_API_KEY"] = `${widget.key}`; headers["X-CMC_PRO_API_KEY"] = `${widget.key}`;
} else if (widget.type === "gotify") { } else if (widget.type === "gotify") {
headers["X-gotify-Key"] = `${widget.key}`; headers["X-gotify-Key"] = `${widget.key}`;
} else if ([ } else if (
"authentik", ["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "truenas", "pterodactyl"].includes(
"cloudflared", widget.type,
"ghostfolio", )
"mealie", ) {
"tailscale",
"truenas",
"pterodactyl",
].includes(widget.type))
{
headers.Authorization = `Bearer ${widget.key}`; headers.Authorization = `Bearer ${widget.key}`;
} else if (widget.type === "proxmox") { } else if (widget.type === "proxmox") {
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`; headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
@ -62,8 +57,7 @@ export default async function credentialedProxyHandler(req, res, map) {
} else { } else {
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`; headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
} }
} } else if (widget.type === "azuredevops") {
else if (widget.type === "azuredevops") {
headers.Authorization = `Basic ${Buffer.from(`$:${widget.key}`).toString("base64")}`; headers.Authorization = `Basic ${Buffer.from(`$:${widget.key}`).toString("base64")}`;
} else if (widget.type === "glances") { } else if (widget.type === "glances") {
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`; headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
@ -94,7 +88,9 @@ export default async function credentialedProxyHandler(req, res, map) {
if (status === 200) { if (status === 200) {
if (!validateWidgetData(widget, endpoint, resultData)) { if (!validateWidgetData(widget, endpoint, resultData)) {
return res.status(500).json({error: {message: "Invalid data", url: sanitizeErrorURL(url), data: resultData}}); return res
.status(500)
.json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } });
} }
if (map) resultData = map(resultData); if (map) resultData = map(resultData);
} }

@ -19,7 +19,9 @@ export default async function genericProxyHandler(req, res, map) {
if (widget) { if (widget) {
// if there are more than one question marks, replace others to & // if there are more than one question marks, replace others to &
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }).replace(/(?<=\?.*)\?/g, '&')); const url = new URL(
formatApiCall(widgets[widget.type].api, { endpoint, ...widget }).replace(/(?<=\?.*)\?/g, "&"),
);
const headers = req.extraHeaders ?? widget.headers ?? {}; const headers = req.extraHeaders ?? widget.headers ?? {};
@ -30,7 +32,7 @@ export default async function genericProxyHandler(req, res, map) {
const params = { const params = {
method: widget.method ?? req.method, method: widget.method ?? req.method,
headers, headers,
} };
if (req.body) { if (req.body) {
params.body = req.body; params.body = req.body;
} }
@ -45,7 +47,9 @@ export default async function genericProxyHandler(req, res, map) {
if (status === 200) { if (status === 200) {
if (!validateWidgetData(widget, endpoint, resultData)) { if (!validateWidgetData(widget, endpoint, resultData)) {
return res.status(status).json({error: {message: "Invalid data", url: sanitizeErrorURL(url), data: resultData}}); return res
.status(status)
.json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } });
} }
if (map) resultData = map(resultData); if (map) resultData = map(resultData);
} }
@ -62,8 +66,8 @@ export default async function genericProxyHandler(req, res, map) {
status, status,
url.protocol, url.protocol,
url.hostname, url.hostname,
url.port ? `:${url.port}` : '', url.port ? `:${url.port}` : "",
url.pathname url.pathname,
); );
return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url), resultData } }); return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url), resultData } });
} }

@ -11,8 +11,8 @@ const logger = createLogger("jsonrpcProxyHandler");
export async function sendJsonRpcRequest(url, method, params, username, password) { export async function sendJsonRpcRequest(url, method, params, username, password) {
const headers = { const headers = {
"content-type": "application/json", "content-type": "application/json",
"accept": "application/json" accept: "application/json",
} };
if (username && password) { if (username && password) {
headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
@ -23,7 +23,7 @@ export async function sendJsonRpcRequest(url, method, params, username, password
const httpRequestParams = { const httpRequestParams = {
method: "POST", method: "POST",
headers, headers,
body body,
}; };
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@ -33,7 +33,7 @@ export async function sendJsonRpcRequest(url, method, params, username, password
// in order to get access to the underlying error object in the JSON response // in order to get access to the underlying error object in the JSON response
// you must set `result` equal to undefined // you must set `result` equal to undefined
if (json.error && (json.result === null)) { if (json.error && json.result === null) {
json.result = undefined; json.result = undefined;
} }
return client.receive(json); return client.receive(json);
@ -45,8 +45,7 @@ export async function sendJsonRpcRequest(url, method, params, username, password
try { try {
const response = await client.request(method, params); const response = await client.request(method, params);
return [200, "application/json", JSON.stringify(response)]; return [200, "application/json", JSON.stringify(response)];
} } catch (e) {
catch (e) {
if (e instanceof JSONRPCErrorException) { if (e instanceof JSONRPCErrorException) {
logger.debug("Error calling JSONPRC endpoint: %s. %s", url, e.message); logger.debug("Error calling JSONPRC endpoint: %s. %s", url, e.message);
return [200, "application/json", JSON.stringify({ result: null, error: { code: e.code, message: e.message } })]; return [200, "application/json", JSON.stringify({ result: null, error: { code: e.code, message: e.message } })];

@ -7,7 +7,8 @@ import createLogger from "utils/logger";
import widgets from "widgets/widgets"; import widgets from "widgets/widgets";
const INFO_ENDPOINT = "{url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query"; const INFO_ENDPOINT = "{url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query";
const AUTH_ENDPOINT = "{url}/webapi/{path}?api=SYNO.API.Auth&version={maxVersion}&method=login&account={username}&passwd={password}&session=DownloadStation&format=cookie"; const AUTH_ENDPOINT =
"{url}/webapi/{path}?api=SYNO.API.Auth&version={maxVersion}&method=login&account={username}&passwd={password}&session=DownloadStation&format=cookie";
const AUTH_API_NAME = "SYNO.API.Auth"; const AUTH_API_NAME = "SYNO.API.Auth";
const proxyName = "synologyProxyHandler"; const proxyName = "synologyProxyHandler";
@ -40,7 +41,7 @@ async function login(loginUrl) {
} }
async function getApiInfo(serviceWidget, apiName, serviceName) { async function getApiInfo(serviceWidget, apiName, serviceName) {
const cacheKey = `${proxyName}__${apiName}__${serviceName}` const cacheKey = `${proxyName}__${apiName}__${serviceName}`;
let { cgiPath, maxVersion } = cache.get(cacheKey) ?? {}; let { cgiPath, maxVersion } = cache.get(cacheKey) ?? {};
if (cgiPath && maxVersion) { if (cgiPath && maxVersion) {
return [cgiPath, maxVersion]; return [cgiPath, maxVersion];
@ -56,12 +57,13 @@ async function getApiInfo(serviceWidget, apiName, serviceName) {
if (json?.data?.[apiName]) { if (json?.data?.[apiName]) {
cgiPath = json.data[apiName].path; cgiPath = json.data[apiName].path;
maxVersion = json.data[apiName].maxVersion; maxVersion = json.data[apiName].maxVersion;
logger.debug(`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`); logger.debug(
`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`,
);
cache.put(cacheKey, { cgiPath, maxVersion }); cache.put(cacheKey, { cgiPath, maxVersion });
return [cgiPath, maxVersion]; return [cgiPath, maxVersion];
} }
} } catch {
catch {
logger.warn(`Error ${status} obtaining ${apiName} info`); logger.warn(`Error ${status} obtaining ${apiName} info`);
} }
} }
@ -124,7 +126,7 @@ function toError(url, synologyError) {
error.error = synologyError.message ?? "Unknown error."; error.error = synologyError.message ?? "Unknown error.";
break; break;
} }
logger.warn(`Unable to call ${url}. code: ${code}, error: ${error.error}.`) logger.warn(`Unable to call ${url}. code: ${code}, error: ${error.error}.`);
return error; return error;
} }
@ -144,7 +146,7 @@ export default async function synologyProxyHandler(req, res) {
const [cgiPath, maxVersion] = await getApiInfo(serviceWidget, mapping.apiName, service); const [cgiPath, maxVersion] = await getApiInfo(serviceWidget, mapping.apiName, service);
if (!cgiPath || !maxVersion) { if (!cgiPath || !maxVersion) {
return res.status(400).json({ error: `Unrecognized API name: ${mapping.apiName}`}) return res.status(400).json({ error: `Unrecognized API name: ${mapping.apiName}` });
} }
const url = formatApiCall(widget.api, { const url = formatApiCall(widget.api, {
@ -152,7 +154,7 @@ export default async function synologyProxyHandler(req, res) {
apiMethod: mapping.apiMethod, apiMethod: mapping.apiMethod,
cgiPath, cgiPath,
maxVersion, maxVersion,
...serviceWidget ...serviceWidget,
}); });
let [status, contentType, data] = await httpProxy(url); let [status, contentType, data] = await httpProxy(url);
if (status !== 200) { if (status !== 200) {

@ -25,21 +25,21 @@ function handleRequest(requestor, url, params) {
addCookieHandler(url, params); addCookieHandler(url, params);
if (params?.body) { if (params?.body) {
params.headers = params.headers ?? {}; params.headers = params.headers ?? {};
params.headers['content-length'] = Buffer.byteLength(params.body); params.headers["content-length"] = Buffer.byteLength(params.body);
} }
const request = requestor.request(url, params, (response) => { const request = requestor.request(url, params, (response) => {
const data = []; const data = [];
const contentEncoding = response.headers['content-encoding']?.trim().toLowerCase(); const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase();
let responseContent = response; let responseContent = response;
if (contentEncoding === 'gzip' || contentEncoding === 'deflate') { if (contentEncoding === "gzip" || contentEncoding === "deflate") {
// https://github.com/request/request/blob/3c0cddc7c8eb60b470e9519da85896ed7ee0081e/request.js#L1018-L1025 // https://github.com/request/request/blob/3c0cddc7c8eb60b470e9519da85896ed7ee0081e/request.js#L1018-L1025
// Be more lenient with decoding compressed responses, in case of invalid gzip responses that are still accepted // Be more lenient with decoding compressed responses, in case of invalid gzip responses that are still accepted
// by common browsers. // by common browsers.
responseContent = createUnzip({ responseContent = createUnzip({
flush: zlibConstants.Z_SYNC_FLUSH, flush: zlibConstants.Z_SYNC_FLUSH,
finishFlush: zlibConstants.Z_SYNC_FLUSH finishFlush: zlibConstants.Z_SYNC_FLUSH,
}); });
// zlib errors // zlib errors
@ -100,14 +100,13 @@ export async function httpProxy(url, params = {}) {
try { try {
const [status, contentType, data, responseHeaders] = await request; const [status, contentType, data, responseHeaders] = await request;
return [status, contentType, data, responseHeaders]; return [status, contentType, data, responseHeaders];
} } catch (err) {
catch (err) {
logger.error( logger.error(
"Error calling %s//%s%s%s...", "Error calling %s//%s%s%s...",
constructedUrl.protocol, constructedUrl.protocol,
constructedUrl.hostname, constructedUrl.hostname,
constructedUrl.port ? `:${constructedUrl.port}` : '', constructedUrl.port ? `:${constructedUrl.port}` : "",
constructedUrl.pathname constructedUrl.pathname,
); );
logger.error(err); logger.error(err);
return [500, "application/json", { error: { message: err?.message ?? "Unknown error", url, rawError: err } }, null]; return [500, "application/json", { error: { message: err?.message ?? "Unknown error", url, rawError: err } }, null];

@ -7,11 +7,11 @@ export default function useWidgetAPI(widget, ...options) {
if (options && options[1]?.refreshInterval) { if (options && options[1]?.refreshInterval) {
config.refreshInterval = options[1].refreshInterval; config.refreshInterval = options[1].refreshInterval;
} }
let url = formatProxyUrl(widget, ...options) let url = formatProxyUrl(widget, ...options);
if (options[0] === "") { if (options[0] === "") {
url = null url = null;
} }
const { data, error, mutate } = useSWR(url, config); const { data, error, mutate } = useSWR(url, config);
// make the data error the top-level error // make the data error the top-level error
return { data, error: data?.error ?? error, mutate } return { data, error: data?.error ?? error, mutate };
} }

@ -18,8 +18,8 @@ export default function validateWidgetData(widget, endpoint, data) {
if (dataParsed && Object.entries(dataParsed).length) { if (dataParsed && Object.entries(dataParsed).length) {
const mappings = widgets[widget.type]?.mappings; const mappings = widgets[widget.type]?.mappings;
if (mappings) { if (mappings) {
mapping = Object.values(mappings).find(m => m.endpoint === endpoint); mapping = Object.values(mappings).find((m) => m.endpoint === endpoint);
mapping?.validate?.forEach(key => { mapping?.validate?.forEach((key) => {
if (dataParsed[key] === undefined) { if (dataParsed[key] === undefined) {
valid = false; valid = false;
} }
@ -28,7 +28,11 @@ export default function validateWidgetData(widget, endpoint, data) {
} }
if (!valid) { if (!valid) {
console.warn(`Invalid data for widget '${widget.type}' endpoint '${endpoint}':\nExpected:${mapping?.validate}\nParse error: ${error ?? "none"}\nData: ${JSON.stringify(data)}`); console.warn(
`Invalid data for widget '${widget.type}' endpoint '${endpoint}':\nExpected:${mapping?.validate}\nParse error: ${
error ?? "none"
}\nData: ${JSON.stringify(data)}`,
);
} }
return valid; return valid;

@ -6,8 +6,8 @@ const widget = {
mappings: { mappings: {
info: { info: {
endpoint: "info" endpoint: "info",
} },
}, },
}; };

@ -10,7 +10,6 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: librariesData, error: librariesError } = useWidgetAPI(widget, "libraries"); const { data: librariesData, error: librariesError } = useWidgetAPI(widget, "libraries");
if (librariesError) { if (librariesError) {
return <Container service={service} error={librariesError} />; return <Container service={service} error={librariesError} />;
} }
@ -26,8 +25,8 @@ export default function Component({ service }) {
); );
} }
const podcastLibraries = librariesData.filter(l => l.mediaType === "podcast"); const podcastLibraries = librariesData.filter((l) => l.mediaType === "podcast");
const bookLibraries = librariesData.filter(l => l.mediaType === "book"); const bookLibraries = librariesData.filter((l) => l.mediaType === "book");
const totalPodcasts = podcastLibraries.reduce((total, pL) => parseInt(pL.stats?.totalItems, 10) + total, 0); const totalPodcasts = podcastLibraries.reduce((total, pL) => parseInt(pL.stats?.totalItems, 10) + total, 0);
const totalBooks = bookLibraries.reduce((total, bL) => parseInt(bL.stats?.totalItems, 10) + total, 0); const totalBooks = bookLibraries.reduce((total, bL) => parseInt(bL.stats?.totalItems, 10) + total, 0);
@ -38,9 +37,25 @@ export default function Component({ service }) {
return ( return (
<Container service={service}> <Container service={service}>
<Block label="audiobookshelf.podcasts" value={t("common.number", { value: totalPodcasts })} /> <Block label="audiobookshelf.podcasts" value={t("common.number", { value: totalPodcasts })} />
<Block label="audiobookshelf.podcastsDuration" value={t("common.number", { value: totalPodcastsDuration / 60, maximumFractionDigits: 0, style: "unit", unit: "minute" })} /> <Block
label="audiobookshelf.podcastsDuration"
value={t("common.number", {
value: totalPodcastsDuration / 60,
maximumFractionDigits: 0,
style: "unit",
unit: "minute",
})}
/>
<Block label="audiobookshelf.books" value={t("common.number", { value: totalBooks })} /> <Block label="audiobookshelf.books" value={t("common.number", { value: totalBooks })} />
<Block label="audiobookshelf.booksDuration" value={t("common.number", { value: totalBooksDuration / 60, maximumFractionDigits: 0, style: "unit", unit: "minute" })} /> <Block
label="audiobookshelf.booksDuration"
value={t("common.number", {
value: totalBooksDuration / 60,
maximumFractionDigits: 0,
style: "unit",
unit: "minute",
})}
/>
</Container> </Container>
); );
} }

@ -10,7 +10,7 @@ const logger = createLogger(proxyName);
async function retrieveFromAPI(url, key) { async function retrieveFromAPI(url, key) {
const headers = { const headers = {
"content-type": "application/json", "content-type": "application/json",
"Authorization": `Bearer ${key}` Authorization: `Bearer ${key}`,
}; };
const [status, , data] = await httpProxy(url, { headers }); const [status, , data] = await httpProxy(url, { headers });
@ -48,13 +48,18 @@ export default async function audiobookshelfProxyHandler(req, res) {
const url = new URL(formatApiCall(apiURL, { endpoint, ...widget })); const url = new URL(formatApiCall(apiURL, { endpoint, ...widget }));
const libraryData = await retrieveFromAPI(url, widget.key); const libraryData = await retrieveFromAPI(url, widget.key);
const libraryStats = await Promise.all(libraryData.libraries.map(async l => { const libraryStats = await Promise.all(
const stats = await retrieveFromAPI(new URL(formatApiCall(apiURL, { endpoint: `libraries/${l.id}/stats`, ...widget })), widget.key); libraryData.libraries.map(async (l) => {
const stats = await retrieveFromAPI(
new URL(formatApiCall(apiURL, { endpoint: `libraries/${l.id}/stats`, ...widget })),
widget.key,
);
return { return {
...l, ...l,
stats stats,
}; };
})); }),
);
return res.status(200).send(libraryStats); return res.status(200).send(libraryStats);
} catch (e) { } catch (e) {

@ -31,11 +31,11 @@ export default function Component({ service }) {
const yesterday = new Date(Date.now()).setHours(-24); const yesterday = new Date(Date.now()).setHours(-24);
const loginsLast24H = loginsData.reduce( const loginsLast24H = loginsData.reduce(
(total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total), (total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),
0 0,
); );
const failedLoginsLast24H = failedLoginsData.reduce( const failedLoginsLast24H = failedLoginsData.reduce(
(total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total), (total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),
0 0,
); );
return ( return (

@ -7,10 +7,7 @@ const widget = {
mappings: { mappings: {
stats: { stats: {
endpoint: "release/stats", endpoint: "release/stats",
validate: [ validate: ["push_approved_count", "push_rejected_count"],
"push_approved_count",
"push_rejected_count"
]
}, },
filters: { filters: {
endpoint: "filters", endpoint: "filters",

@ -12,14 +12,11 @@ export default function Component({ service }) {
const { data: prData, error: prError } = useWidgetAPI(widget, includePR ? "pr" : null); const { data: prData, error: prError } = useWidgetAPI(widget, includePR ? "pr" : null);
const { data: pipelineData, error: pipelineError } = useWidgetAPI(widget, "pipeline"); const { data: pipelineData, error: pipelineError } = useWidgetAPI(widget, "pipeline");
if ( if (pipelineError || (includePR && (prError || prData?.errorCode !== undefined))) {
pipelineError ||
(includePR && (prError || prData?.errorCode !== undefined))
) {
let finalError = pipelineError ?? prError; let finalError = pipelineError ?? prError;
if (includePR && prData?.errorCode !== null) { if (includePR && prData?.errorCode !== null) {
// pr call failed possibly with more specific message // pr call failed possibly with more specific message
finalError = { message: prData?.message ?? 'Error communicating with Azure API' } finalError = { message: prData?.message ?? "Error communicating with Azure API" };
} }
return <Container service={service} error={finalError} />; return <Container service={service} error={finalError} />;
} }
@ -44,22 +41,25 @@ export default function Component({ service }) {
)} )}
{includePR && <Block label="azuredevops.totalPrs" value={t("common.number", { value: prData.count })} />} {includePR && <Block label="azuredevops.totalPrs" value={t("common.number", { value: prData.count })} />}
{includePR && <Block {includePR && (
<Block
label="azuredevops.myPrs" label="azuredevops.myPrs"
value={t("common.number", { value={t("common.number", {
value: prData.value?.filter((item) => item.createdBy.uniqueName.toLowerCase() === userEmail.toLowerCase()) value: prData.value?.filter((item) => item.createdBy.uniqueName.toLowerCase() === userEmail.toLowerCase())
.length, .length,
})} })}
/>} />
{includePR && <Block )}
{includePR && (
<Block
label="azuredevops.approved" label="azuredevops.approved"
value={t("common.number", { value={t("common.number", {
value: prData.value value: prData.value
?.filter((item) => item.createdBy.uniqueName.toLowerCase() === userEmail.toLowerCase()) ?.filter((item) => item.createdBy.uniqueName.toLowerCase() === userEmail.toLowerCase())
.filter((item) => item.reviewers.some((reviewer) => [5,10].includes(reviewer.vote))).length .filter((item) => item.reviewers.some((reviewer) => [5, 10].includes(reviewer.vote))).length,
})} })}
/>} />
)}
</Container> </Container>
); );
} }

@ -6,11 +6,11 @@ const widget = {
mappings: { mappings: {
pr: { pr: {
endpoint: "git/repositories/{repositoryId}/pullrequests" endpoint: "git/repositories/{repositoryId}/pullrequests",
}, },
pipeline: { pipeline: {
endpoint: "build/Builds?branchName={branchName}&definitions={definitionId}" endpoint: "build/Builds?branchName={branchName}&definitions={definitionId}",
}, },
}, },
}; };

@ -10,14 +10,14 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: resultData, error: resultError } = useWidgetAPI(widget, "result"); const { data: resultData, error: resultError } = useWidgetAPI(widget, "result");
if (resultError) { if (resultError) {
return <Container service={service} error={resultError} />; return <Container service={service} error={resultError} />;
} }
if (!resultData) { if (!resultData) {
return ( return (
<Container service={service}>, <Container service={service}>
,
<Block label="caddy.upstreams" /> <Block label="caddy.upstreams" />
<Block label="caddy.requests" /> <Block label="caddy.requests" />
<Block label="caddy.requests_failed" /> <Block label="caddy.requests_failed" />

@ -20,28 +20,40 @@ export default function Component({ service }) {
return { return {
start: showDate.minus({ months: 3 }).toFormat("yyyy-MM-dd"), start: showDate.minus({ months: 3 }).toFormat("yyyy-MM-dd"),
end: showDate.plus({ months: 3 }).toFormat("yyyy-MM-dd"), end: showDate.plus({ months: 3 }).toFormat("yyyy-MM-dd"),
unmonitored: 'false', unmonitored: "false",
}; };
}, [showDate]); }, [showDate]);
// Load active integrations // Load active integrations
const integrations = useMemo(() => widget.integrations?.map(integration => ({ const integrations = useMemo(
() =>
widget.integrations?.map((integration) => ({
service: dynamic(() => import(`./integrations/${integration?.type}`)), service: dynamic(() => import(`./integrations/${integration?.type}`)),
widget: integration, widget: integration,
})) ?? [], [widget.integrations]); })) ?? [],
[widget.integrations],
);
return <Container service={service}> return (
<Container service={service}>
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="sticky top-0"> <div className="sticky top-0">
{integrations.map(integration => { {integrations.map((integration) => {
const Integration = integration.service; const Integration = integration.service;
const key = integration.widget.type + integration.widget.service_name + integration.widget.service_group; const key = integration.widget.type + integration.widget.service_name + integration.widget.service_group;
return <Integration key={key} config={integration.widget} params={params} return (
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12" /> <Integration
key={key}
config={integration.widget}
params={params}
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
/>
);
})} })}
</div> </div>
<MonthlyView service={service} className="flex" /> <MonthlyView service={service} className="flex" />
</div> </div>
</Container>; </Container>
);
} }

@ -7,9 +7,11 @@ import Error from "../../../components/services/widget/error";
export default function Integration({ config, params }) { export default function Integration({ config, params }) {
const { setEvents } = useContext(EventContext); const { setEvents } = useContext(EventContext);
const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", {
{ ...params, includeArtist: 'false', ...config?.params ?? {} } ...params,
); includeArtist: "false",
...(config?.params ?? {}),
});
useEffect(() => { useEffect(() => {
if (!lidarrData || lidarrError) { if (!lidarrData || lidarrError) {
@ -18,19 +20,19 @@ export default function Integration({ config, params }) {
const eventsToAdd = {}; const eventsToAdd = {};
lidarrData?.forEach(event => { lidarrData?.forEach((event) => {
const title = `${event.artist.artistName} - ${event.title}`; const title = `${event.artist.artistName} - ${event.title}`;
eventsToAdd[title] = { eventsToAdd[title] = {
title, title,
date: DateTime.fromISO(event.releaseDate), date: DateTime.fromISO(event.releaseDate),
color: config?.color ?? 'green' color: config?.color ?? "green",
}; };
}) });
setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd })); setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));
}, [lidarrData, lidarrError, config, setEvents]); }, [lidarrData, lidarrError, config, setEvents]);
const error = lidarrError ?? lidarrData?.error; const error = lidarrError ?? lidarrData?.error;
return error && <Error error={{ message: `${config.type}: ${error.message ?? error}`}} /> return error && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
} }

@ -9,9 +9,10 @@ import Error from "../../../components/services/widget/error";
export default function Integration({ config, params }) { export default function Integration({ config, params }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { setEvents } = useContext(EventContext); const { setEvents } = useContext(EventContext);
const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", {
{ ...params, ...config?.params ?? {} } ...params,
); ...(config?.params ?? {}),
});
useEffect(() => { useEffect(() => {
if (!radarrData || radarrError) { if (!radarrData || radarrError) {
return; return;
@ -19,7 +20,7 @@ export default function Integration({ config, params }) {
const eventsToAdd = {}; const eventsToAdd = {};
radarrData?.forEach(event => { radarrData?.forEach((event) => {
const cinemaTitle = `${event.title} - ${t("calendar.inCinemas")}`; const cinemaTitle = `${event.title} - ${t("calendar.inCinemas")}`;
const physicalTitle = `${event.title} - ${t("calendar.physicalRelease")}`; const physicalTitle = `${event.title} - ${t("calendar.physicalRelease")}`;
const digitalTitle = `${event.title} - ${t("calendar.digitalRelease")}`; const digitalTitle = `${event.title} - ${t("calendar.digitalRelease")}`;
@ -27,23 +28,23 @@ export default function Integration({ config, params }) {
eventsToAdd[cinemaTitle] = { eventsToAdd[cinemaTitle] = {
title: cinemaTitle, title: cinemaTitle,
date: DateTime.fromISO(event.inCinemas), date: DateTime.fromISO(event.inCinemas),
color: config?.color ?? 'amber' color: config?.color ?? "amber",
}; };
eventsToAdd[physicalTitle] = { eventsToAdd[physicalTitle] = {
title: physicalTitle, title: physicalTitle,
date: DateTime.fromISO(event.physicalRelease), date: DateTime.fromISO(event.physicalRelease),
color: config?.color ?? 'cyan' color: config?.color ?? "cyan",
}; };
eventsToAdd[digitalTitle] = { eventsToAdd[digitalTitle] = {
title: digitalTitle, title: digitalTitle,
date: DateTime.fromISO(event.digitalRelease), date: DateTime.fromISO(event.digitalRelease),
color: config?.color ?? 'emerald' color: config?.color ?? "emerald",
}; };
}) });
setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd })); setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));
}, [radarrData, radarrError, config, setEvents, t]); }, [radarrData, radarrError, config, setEvents, t]);
const error = radarrError ?? radarrData?.error; const error = radarrError ?? radarrData?.error;
return error && <Error error={{ message: `${config.type}: ${error.message ?? error}`}} /> return error && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
} }

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

Loading…
Cancel
Save