Merge remote-tracking branch 'origin/benphelpsMain' into LocalMain

This commit is contained in:
Karl Hudgell 2024-02-21 10:03:24 +00:00
commit 6ba7e38d67
47 changed files with 706 additions and 180 deletions

View File

@ -3,4 +3,10 @@ FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:${VARIANT}
RUN npm install -g pnpm
RUN apt-get update \
&& apt-get -y install --no-install-recommends \
python3-pip \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="${PATH}:./node_modules/.bin"

View File

@ -3,8 +3,8 @@
"build": {
"dockerfile": "Dockerfile",
"args": {
"VARIANT": "18-bullseye"
}
"VARIANT": "18-bullseye",
},
},
"customizations": {
"vscode": {
@ -12,16 +12,15 @@
"dbaeumer.vscode-eslint",
"mhutchie.git-graph",
"streetsidesoftware.code-spell-checker",
"esbenp.prettier-vscode",
],
"settings": {
"eslint.format.enable": true,
"eslint.lintTask.enable": true,
"eslint.packageManager": "pnpm"
}
}
"eslint.packageManager": "pnpm",
},
},
},
"postCreateCommand": ".devcontainer/setup.sh",
"forwardPorts": [
3000
]
"forwardPorts": [3000],
}

View File

@ -3,6 +3,8 @@
# Install Node packages
pnpm install
python3 -m pip install -r requirements.txt
# Copy in skeleton configuration if there is no existing configuration
if [ ! -d "config/" ]; then
echo "Adding skeleton config"

View File

@ -13,3 +13,7 @@ updates:
directory: "/"
schedule:
interval: "monthly"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "monthly"

View File

@ -27,7 +27,7 @@ jobs:
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
for your contributions. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
lock-threads:
name: 'Lock Old Threads'
runs-on: ubuntu-latest
@ -42,14 +42,17 @@ jobs:
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new discussion for related concerns.
See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new discussion for related concerns.
See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
discussion-comment: >
This discussion has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new discussion for related concerns.
See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
close-answered-discussions:
name: 'Close Answered Discussions'
runs-on: ubuntu-latest
@ -89,7 +92,7 @@ jobs:
}`;
const commentVariables = {
discussion: discussion.id,
body: 'This discussion has been automatically closed because it was marked as answered.',
body: 'This discussion has been automatically closed because it was marked as answered. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.',
}
await github.graphql(addCommentMutation, commentVariables)
@ -179,7 +182,85 @@ jobs:
}`;
const commentVariables = {
discussion: discussion.id,
body: 'This discussion has been automatically closed due to inactivity.',
body: 'This discussion has been automatically closed due to inactivity. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.',
}
await github.graphql(addCommentMutation, commentVariables);
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
clientMutationId
}
}`;
const closeVariables = {
discussion: discussion.id,
reason: "OUTDATED",
}
await github.graphql(closeDiscussionMutation, closeVariables);
await sleep(1000);
}
}
close-unsupported-feature-requests:
name: 'Close Unsupported Feature Requests'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const CUTOFF_1_DAYS = 180;
const CUTOFF_1_COUNT = 5;
const CUTOFF_2_DAYS = 365;
const CUTOFF_2_COUNT = 10;
const cutoff1Date = new Date();
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
const cutoff2Date = new Date();
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
const query = `query(
$owner:String!,
$name:String!,
$featureRequestsCategory:ID!,
) {
repository(owner:$owner, name:$name){
discussions(
categoryId:$featureRequestsCategory,
last:100,
states:[OPEN],
) {
nodes {
id,
number,
updatedAt,
upvoteCount,
}
},
}
}`;
const variables = {
owner: context.repo.owner,
name: context.repo.repo,
featureRequestsCategory: "DIC_kwDOH31rQM4CRErS"
}
const result = await github.graphql(query, variables);
for (const discussion of result.repository.discussions.nodes) {
const discussionDate = new Date(discussion.updatedAt);
if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
(discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
clientMutationId
}
}`;
const commentVariables = {
discussion: discussion.id,
body: 'This discussion has been automatically closed due to lack of community support. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.',
}
await github.graphql(addCommentMutation, commentVariables);

View File

@ -51,3 +51,18 @@ By contributing, you agree that your contributions will be licensed under its GN
## 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)
# Automatic Respoistory Maintenance
The homepage team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas:
- Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further days of inactivity.
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
- Discussions with a marked answer will be automatically closed.
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
- Feature requests that do not meet the following thresholds will be closed: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days.
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
Thank you all for your contributions.

View File

@ -101,7 +101,7 @@ To use a local icon, first create a Docker mount to `/app/public/icons` and then
## Ping
Services may have an optional `ping` property that allows you to monitor the availability of an external host. As of v0.8.0, the ping feature attempts to use a true (ICMP) ping command on the underlying host.
Services may have an optional `ping` property that allows you to monitor the availability of an external host. As of v0.8.0, the ping feature attempts to use a true (ICMP) ping command on the underlying host. Currently, only IPv4 is supported.
```yaml
- Group A:

View File

@ -229,6 +229,26 @@ disableCollapse: true
By default the feature is enabled.
### Initially collapsed sections
You can initially collapse sections by adding the `initiallyCollapsed` option to the layout group.
```yaml
layout:
Section A:
initiallyCollapsed: true
```
This can also be set globaly using the `groupsInitiallyCollapsed` option.
```yaml
groupsInitiallyCollapsed: true
```
The value set on a group will overwrite the global setting.
By default the feature is disabled.
### Use Equal Height Cards
You can enable equal height cards for groups of services, this will make all cards in a row the same height.

View File

@ -51,6 +51,7 @@ To ensure cohesiveness of various widgets, the following should be used as a gui
- Please only submit widgets that have been requested and have at least 10 'up-votes'. The purpose of this requirement is to avoid the addition (and maintenance) of service widgets that might only benefit a small number of users.
- Widgets should be only one row of blocks
- Widgets should be no more than 4 blocks wide
- Widgets should be no more than 4 blocks wide and generally conform to the styling / design choices of other widgets
- Minimize the number of API calls
- Avoid the use of custom proxy unless absolutely necessary
- Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself.

View File

@ -17,6 +17,7 @@ The Glances widget allows you to monitor the resources (CPU, memory, storage, te
cputemp: true # disabled by default
uptime: true # disabled by default
disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below)
diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk
expanded: true # show the expanded view
label: MyMachine # optional
```

View File

@ -22,6 +22,7 @@ _Note: unfortunately, the package used for getting CPU temp ([systeminformation]
uptime: true
units: imperial # only used by cpu temp
refresh: 3000 # optional, in ms
diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk
```
You can also pass a `label` option, which allows you to group resources under named sections,

View File

@ -16,6 +16,8 @@ widget:
password: password # auth - optional
method: GET # optional, e.g. POST
headers: # optional, must be object, see below
requestBody: # optional, can be string or object, see below
display: # optional, default to block, see below
mappings:
- field: key # needs to be YAML string or object
label: Field 1
@ -43,6 +45,15 @@ widget:
locale: nl # optional
style: short # optional - defaults to "long". Allowed values: `["long", "short", "narrow"]`.
numeric: auto # optional - defaults to "always". Allowed values `["always", "auto"]`.
- field: key
label: Field 6
format: text
additionalField: # optional
field:
hourly:
time: other key
color: theme # optional - defaults to "". Allowed values: `["theme", "adaptive", "black", "white"]`.
format: date # optional
```
Supported formats for the values are `text`, `number`, `float`, `percent`, `bytes`, `bitrate`, `date` and `relativeDate`.
@ -93,7 +104,7 @@ mappings:
## Data Transformation
You can manipulate data with the following tools `remap`, `scale` and `suffix`, for example:
You can manipulate data with the following tools `remap`, `scale`, `prefix` and `suffix`, for example:
```yaml
- field: key4
@ -110,7 +121,42 @@ You can manipulate data with the following tools `remap`, `scale` and `suffix`,
label: Power
format: float
scale: 0.001 # can be number or string e.g. 1/16
suffix: kW
suffix: "kW"
- field: key6
label: Price
format: float
prefix: "$"
```
## List View
You can change the default block view to a list view by setting the `display` option to `list`.
The list view can optionally display an additional field next to the primary field.
`additionalField`: Similar to `field`, but only used in list view. Displays additional information for the mapping object on the right.
`field`: Defined the same way as other custom api widget fields.
`color`: Allowed options: `"theme", "adaptive", "black", "white"`. The option `adaptive` will apply a color using the value of the `additionalField`, green for positive numbers, red for negative numbers.
```yaml
- field: key
label: Field
format: text
remap:
- value: 0
to: None
- value: 1
to: Connected
- any: true # will map all other values
to: Unknown
additionalField:
field:
hourly:
time: key
color: theme
format: date
```
## Custom Headers
@ -121,3 +167,16 @@ Pass custom headers using the `headers` option, for example:
headers:
X-API-Token: token
```
## Custom Request Body
Pass custom request body using the `requestBody` option in either a string or object format. Objects will automatically be converted to a JSON string.
```yaml
requestBody:
foo: bar
# or
requestBody: "{\"foo\":\"bar\"}"
```
Both formats result in `{"foo":"bar"}` being sent as the request body. Don't forget to set your `Content-Type` headers!

View File

@ -0,0 +1,17 @@
---
title: Gitea
description: Gitea Widget Configuration
---
Learn more about [Gitea](https://gitea.com).
API token requires `notifications` and `repository` permissions. See the [gitea documentation](https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens) for details on generating tokens.
Allowed fields: ["notifications", "issues", "pulls"]
```yaml
widget:
type: gitea
url: http://gitea.host.or.ip:port
key: giteaapitoken
```

View File

@ -18,6 +18,7 @@ widget:
username: user # optional if auth enabled in Glances
password: pass # optional if auth enabled in Glances
metric: cpu
diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk
```
_Please note, this widget does not need an `href`, `icon` or `description` on its parent service. To achieve the same effect as the examples above, see as an example:_

View File

@ -12,3 +12,12 @@ widget:
type: moonraker
url: http://moonraker.host.or.ip:port
```
If your moonraker instance has an active authorization and your homepage ip isn't whitelisted you need to add your api key ([Authorization Documentation](https://moonraker.readthedocs.io/en/latest/web_api/#authorization)).
```yaml
widget:
type: moonraker
url: http://moonraker.host.or.ip:port
key: api_keymoonraker
```

View File

@ -0,0 +1,15 @@
---
title: Plant-it
description: Plant-it Widget Configuration
---
Learn more about [Plantit](https://github.com/MDeLuise/plant-it).
API key can be created from the REST API.
```yaml
widget:
type: plantit
url: http://plant-it.host.or.ip:port # api port
key: plantit-api-key
```

View File

@ -9,6 +9,8 @@ Allowed fields: `["load", "uptime", "alerts"]`.
To create an API Key, follow [the official TrueNAS documentation](https://www.truenas.com/docs/scale/scaletutorials/toptoolbar/managingapikeys/).
A detailed pool listing is disabled by default, but can be enabled with the `enablePools` option.
```yaml
widget:
type: truenas
@ -16,4 +18,5 @@ widget:
username: user # not required if using api key
password: pass # not required if using api key
key: yourtruenasapikey # not required if using username / password
enablePools: true # optional, defaults to false
```

View File

@ -57,6 +57,7 @@ nav:
- widgets/services/gamedig.md
- widgets/services/gatus.md
- widgets/services/ghostfolio.md
- widgets/services/gitea.md
- widgets/services/glances.md
- widgets/services/gluetun.md
- widgets/services/gotify.md
@ -103,6 +104,7 @@ nav:
- widgets/services/photoprism.md
- widgets/services/pialert.md
- widgets/services/pihole.md
- widgets/services/plantit.md
- widgets/services/plex-tautulli.md
- widgets/services/plex.md
- widgets/services/portainer.md

View File

@ -853,5 +853,16 @@
"wled": {
"deviceName": "Device Name",
"deviceState": "Device State"
},
"plantit": {
"events": "Events",
"plants": "Plants",
"photos": "Photos",
"species": "Species"
},
"gitea": {
"notifications": "Notifications",
"issues": "Issues",
"pulls": "Pull Requests"
}
}

View File

@ -803,5 +803,11 @@
"netdata": {
"warnings": "Warnings",
"criticals": "Criticals"
},
"plantit": {
"events": "Eventi",
"plants": "Piante",
"species": "Specie",
"images": "Immagini"
}
}

View File

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useEffect } from "react";
import classNames from "classnames";
import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md";
@ -7,8 +7,13 @@ import ErrorBoundary from "components/errorboundry";
import List from "components/bookmarks/list";
import ResolvedIcon from "components/resolvedicon";
export default function BookmarksGroup({ bookmarks, layout, disableCollapse }) {
export default function BookmarksGroup({ bookmarks, layout, disableCollapse, groupsInitiallyCollapsed }) {
const panel = useRef();
useEffect(() => {
if (layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) panel.current.style.height = `0`;
}, [layout, groupsInitiallyCollapsed]);
return (
<div
key={bookmarks.name}
@ -18,7 +23,7 @@ export default function BookmarksGroup({ bookmarks, layout, disableCollapse }) {
layout?.header === false ? "flex-1 px-1 -my-1" : "flex-1 p-1",
)}
>
<Disclosure defaultOpen>
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
{({ open }) => (
<>
{layout?.header !== false && (

View File

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

View File

@ -120,7 +120,7 @@ export default function QuickLaunch({
});
if (showSearchSuggestions && searchProvider.suggestionUrl) {
if (searchString.trim() !== searchSuggestions[0]) {
if (searchString.trim() !== searchSuggestions[0]?.trim()) {
fetch(
`/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
searchProvider.name ?? "Custom"

View File

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useEffect } from "react";
import classNames from "classnames";
import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md";
@ -6,9 +6,21 @@ import { MdKeyboardArrowDown } from "react-icons/md";
import List from "components/services/list";
import ResolvedIcon from "components/resolvedicon";
export default function ServicesGroup({ group, services, layout, fiveColumns, disableCollapse, useEqualHeights }) {
export default function ServicesGroup({
group,
services,
layout,
fiveColumns,
disableCollapse,
useEqualHeights,
groupsInitiallyCollapsed,
}) {
const panel = useRef();
useEffect(() => {
if (layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) panel.current.style.height = `0`;
}, [layout, groupsInitiallyCollapsed]);
return (
<div
key={services.name}
@ -19,7 +31,7 @@ export default function ServicesGroup({ group, services, layout, fiveColumns, di
layout?.header === false ? "flex-1 px-1 -my-1" : "flex-1 p-1",
)}
>
<Disclosure defaultOpen>
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
{({ open }) => (
<>
{layout?.header !== false && (

View File

@ -21,6 +21,7 @@ function convertToFahrenheit(t) {
export default function Widget({ options }) {
const { t, i18n } = useTranslation();
const { settings } = useContext(SettingsContext);
const diskUnits = options.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
const { data, error } = useSWR(
`/api/widgets/glances?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,
@ -132,9 +133,9 @@ export default function Widget({ options }) {
<Resource
key={`disk_${disk.mnt_point ?? disk.device_name}`}
icon={FiHardDrive}
value={t("common.bytes", { value: disk.free })}
value={t(diskUnits, { value: disk.free })}
label={t("glances.free")}
expandedValue={t("common.bytes", { value: disk.size })}
expandedValue={t(diskUnits, { value: disk.size })}
expandedLabel={t("glances.total")}
percentage={disk.percent}
expanded={options.expanded}

View File

@ -5,8 +5,9 @@ import { useTranslation } from "next-i18next";
import Resource from "../widget/resource";
import Error from "../widget/error";
export default function Disk({ options, expanded, refresh = 1500 }) {
export default function Disk({ options, expanded, diskUnits, refresh = 1500 }) {
const { t } = useTranslation();
const diskUnitsName = diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
const { data, error } = useSWR(`/api/widgets/resources?type=disk&target=${options.disk}`, {
refreshInterval: refresh,
@ -36,9 +37,9 @@ export default function Disk({ options, expanded, refresh = 1500 }) {
return (
<Resource
icon={FiHardDrive}
value={t("common.bytes", { value: data.drive.available })}
value={t(diskUnitsName, { value: data.drive.available })}
label={t("resources.free")}
expandedValue={t("common.bytes", { value: data.drive.size })}
expandedValue={t(diskUnitsName, { value: data.drive.size })}
expandedLabel={t("resources.total")}
percentage={percent}
expanded={expanded}

View File

@ -8,7 +8,7 @@ import CpuTemp from "./cputemp";
import Uptime from "./uptime";
export default function Resources({ options }) {
const { expanded, units } = options;
const { expanded, units, diskUnits } = options;
let { refresh } = options;
if (!refresh) refresh = 1500;
refresh = Math.max(refresh, 1000);
@ -19,8 +19,10 @@ export default function Resources({ options }) {
{options.cpu && <Cpu expanded={expanded} refresh={refresh} />}
{options.memory && <Memory expanded={expanded} refresh={refresh} />}
{Array.isArray(options.disk)
? options.disk.map((disk) => <Disk key={disk} options={{ disk }} expanded={expanded} refresh={refresh} />)
: options.disk && <Disk options={options} expanded={expanded} refresh={refresh} />}
? options.disk.map((disk) => (
<Disk key={disk} options={{ disk }} expanded={expanded} diskUnits={diskUnits} refresh={refresh} />
))
: options.disk && <Disk options={options} expanded={expanded} diskUnits={diskUnits} refresh={refresh} />}
{options.cputemp && <CpuTemp expanded={expanded} units={units} refresh={refresh} />}
{options.uptime && <Uptime refresh={refresh} />}
</div>

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, Fragment } from "react";
import { useState, useEffect, Fragment } from "react";
import { useTranslation } from "next-i18next";
import { FiSearch } from "react-icons/fi";
import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si";
@ -119,8 +119,9 @@ export default function Search({ options }) {
};
}, [selectedProvider, options, query, searchSuggestions]);
const submitCallback = useCallback(
(value) => {
let currentSuggestion;
function doSearch(value) {
const q = encodeURIComponent(value);
const { url } = selectedProvider;
if (url) {
@ -130,9 +131,15 @@ export default function Search({ options }) {
}
setQuery("");
},
[selectedProvider, options.url, options.target],
);
currentSuggestion = null;
}
const handleSearchKeyDown = (event) => {
const useSuggestion = searchSuggestions.length && currentSuggestion;
if (event.key === "Enter") {
doSearch(useSuggestion ? currentSuggestion : event.target.value);
}
};
if (!availableProviderIds) {
return null;
@ -148,7 +155,7 @@ export default function Search({ options }) {
<Raw>
<div className="flex-col relative h-8 my-4 min-w-fit z-20">
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
<Combobox value={query} onChange={submitCallback}>
<Combobox value={query}>
<Combobox.Input
type="text"
className="
@ -160,13 +167,17 @@ export default function Search({ options }) {
focus:border-theme-500 dark:focus:border-white/50
border border-theme-300 dark:border-theme-200/50"
placeholder={t("search.placeholder")}
onChange={(event) => setQuery(event.target.value)}
onChange={(event) => {
setQuery(event.target.value);
}}
required
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus}
onBlur={(e) => e.preventDefault()}
onKeyDown={handleSearchKeyDown}
/>
<Listbox
as="div"
@ -229,8 +240,17 @@ export default function Search({ options }) {
<div className="p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs">
<Combobox.Option key={query} value={query} />
{searchSuggestions[1].map((suggestion) => (
<Combobox.Option key={suggestion} value={suggestion} className="flex w-full">
{({ active }) => (
<Combobox.Option
key={suggestion}
value={suggestion}
onClick={() => {
doSearch(suggestion);
}}
className="flex w-full"
>
{({ active }) => {
if (active) currentSuggestion = suggestion;
return (
<div
className={classNames(
"px-2 py-1 rounded-md w-full flex-nowrap",
@ -242,7 +262,8 @@ export default function Search({ options }) {
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
</span>
</div>
)}
);
}}
</Combobox.Option>
))}
</div>

View File

@ -1,6 +1,7 @@
/* eslint-disable react/no-array-index-key */
import useSWR, { SWRConfig } from "swr";
import Head from "next/head";
import Script from "next/script";
import dynamic from "next/dynamic";
import classNames from "classnames";
import { useTranslation } from "next-i18next";
@ -10,7 +11,6 @@ import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useRouter } from "next/router";
import Tab, { slugify } from "components/tab";
import FileContent from "components/filecontent";
import ServicesGroup from "components/services/group";
import BookmarksGroup from "components/bookmarks/group";
import Widget from "components/widgets/widget";
@ -311,6 +311,7 @@ function Home({ initialSettings }) {
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse}
useEqualHeights={settings.useEqualHeights}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
) : (
<BookmarksGroup
@ -318,6 +319,7 @@ function Home({ initialSettings }) {
bookmarks={group}
layout={settings.layout?.[group.name]}
disableCollapse={settings.disableCollapse}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
),
)}
@ -333,6 +335,7 @@ function Home({ initialSettings }) {
layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
))}
</div>
@ -345,6 +348,7 @@ function Home({ initialSettings }) {
bookmarks={group}
layout={settings.layout?.[group.name]}
disableCollapse={settings.disableCollapse}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
))}
</div>
@ -361,6 +365,7 @@ function Home({ initialSettings }) {
settings.disableCollapse,
settings.useEqualHeights,
settings.cardBlur,
settings.groupsInitiallyCollapsed,
initialSettings.layout,
]);
@ -385,19 +390,11 @@ function Home({ initialSettings }) {
)}
<meta name="msapplication-TileColor" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
<meta name="theme-color" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
<link rel="preload" href="/api/config/custom.css" as="style" />
<link rel="stylesheet" href="/api/config/custom.css" /> {/* eslint-disable-line @next/next/no-css-tags */}
</Head>
<link rel="preload" href="/api/config/custom.css" as="fetch" crossOrigin="anonymous" />
<style data-name="custom.css">
<FileContent
path="custom.css"
loadingValue="/* Loading custom CSS... */"
errorValue="/* Failed to load custom CSS... */"
emptyValue="/* No custom CSS */"
/>
</style>
<link rel="preload" href="/api/config/custom.js" as="fetch" crossOrigin="anonymous" />
<script data-name="custom.js" src="/api/config/custom.js" async />
<Script src="/api/config/custom.js" />
<div className="relative container m-auto flex flex-col justify-start z-10 h-full">
<QuickLaunch

View File

@ -378,6 +378,7 @@ export function cleanServiceGroups(groups) {
// customapi
mappings,
display,
// diskstation
volume,
@ -394,6 +395,7 @@ export function cleanServiceGroups(groups) {
chart,
metric,
pointsLimit,
diskUnits,
// glances, customapi, iframe
refreshInterval,
@ -441,6 +443,9 @@ export function cleanServiceGroups(groups) {
// sonarr, radarr
enableQueue,
// truenas
enablePools,
// unifi
site,
} = cleanedService.widget;
@ -510,6 +515,9 @@ export function cleanServiceGroups(groups) {
if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) cleanedService.widget.enableQueue = JSON.parse(enableQueue);
}
if (type === "truenas") {
if (enablePools !== undefined) cleanedService.widget.enablePools = JSON.parse(enablePools);
}
if (["diskstation", "qnap"].includes(type)) {
if (volume) cleanedService.widget.volume = volume;
}
@ -526,6 +534,7 @@ export function cleanServiceGroups(groups) {
}
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
if (pointsLimit) cleanedService.widget.pointsLimit = pointsLimit;
if (diskUnits) cleanedService.widget.diskUnits = diskUnits;
}
if (type === "mjpeg") {
if (stream) cleanedService.widget.stream = stream;
@ -539,6 +548,7 @@ export function cleanServiceGroups(groups) {
}
if (type === "customapi") {
if (mappings) cleanedService.widget.mappings = mappings;
if (display) cleanedService.widget.display = display;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
}
if (type === "calendar") {

View File

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

View File

@ -29,11 +29,15 @@ export default async function credentialedProxyHandler(req, res, map) {
} else if (widget.type === "gotify") {
headers["X-gotify-Key"] = `${widget.key}`;
} else if (
["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "truenas", "pterodactyl"].includes(
widget.type,
)
["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "pterodactyl"].includes(widget.type)
) {
headers.Authorization = `Bearer ${widget.key}`;
} else if (widget.type === "truenas") {
if (widget.key) {
headers.Authorization = `Bearer ${widget.key}`;
} else {
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
}
} else if (widget.type === "proxmox") {
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
} else if (widget.type === "proxmoxbackupserver") {
@ -61,6 +65,8 @@ export default async function credentialedProxyHandler(req, res, map) {
headers.Authorization = `Basic ${Buffer.from(`$:${widget.key}`).toString("base64")}`;
} else if (widget.type === "glances") {
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
} else if (widget.type === "plantit") {
headers.Key = `${widget.key}`;
} else {
headers["X-API-Key"] = `${widget.key}`;
}

View File

@ -35,6 +35,12 @@ export default async function genericProxyHandler(req, res, map) {
};
if (req.body) {
params.body = req.body;
} else if (widget.requestBody) {
if (typeof widget.requestBody === "object") {
params.body = JSON.stringify(widget.requestBody);
} else {
params.body = widget.requestBody;
}
}
const [status, contentType, data] = await httpProxy(url, params);

View File

@ -52,15 +52,15 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
}
const eventToAdd = (date, i, type) => {
const duration = event.dtend.value - event.dtstart.value;
const days = duration / (1000 * 60 * 60 * 24);
// 'dtend' is null for all-day events
const { dtstart, dtend = { value: 0 } } = event;
const days = dtend.value === 0 ? 1 : (dtend.value - dtstart.value) / (1000 * 60 * 60 * 24);
const eventDate = timezone ? DateTime.fromJSDate(date, { zone: timezone }) : DateTime.fromJSDate(date);
for (let j = 0; j < days; j += 1) {
// See https://github.com/gethomepage/homepage/issues/2753 uid is not stable
// assumption is that the event is the same if the start, end and title are all the same
const hash = simpleHash(`${event?.dtstart?.value}${event?.dtend?.value}${title}${i}${j}${type}}`);
const hash = simpleHash(`${dtstart?.value}${dtend?.value}${title}${i}${j}${type}}`);
eventsToAdd[hash] = {
title,
date: eventDate.plus({ days: j }),

View File

@ -31,6 +31,7 @@ const components = {
gamedig: dynamic(() => import("./gamedig/component")),
gatus: dynamic(() => import("./gatus/component")),
ghostfolio: dynamic(() => import("./ghostfolio/component")),
gitea: dynamic(() => import("./gitea/component")),
glances: dynamic(() => import("./glances/component")),
gluetun: dynamic(() => import("./gluetun/component")),
gotify: dynamic(() => import("./gotify/component")),
@ -80,6 +81,7 @@ const components = {
proxmoxbackupserver: dynamic(() => import("./proxmoxbackupserver/component")),
pialert: dynamic(() => import("./pialert/component")),
pihole: dynamic(() => import("./pihole/component")),
plantit: dynamic(() => import("./plantit/component")),
plex: dynamic(() => import("./plex/component")),
portainer: dynamic(() => import("./portainer/component")),
prometheus: dynamic(() => import("./prometheus/component")),

View File

@ -90,6 +90,12 @@ function formatValue(t, mapping, rawValue) {
// nothing
}
// Apply fixed prefix.
const prefix = mapping?.prefix;
if (prefix) {
value = `${prefix} ${value}`;
}
// Apply fixed suffix.
const suffix = mapping?.suffix;
if (suffix) {
@ -99,12 +105,35 @@ function formatValue(t, mapping, rawValue) {
return value;
}
function getColor(mapping, customData) {
const value = getValue(mapping.additionalField.field, customData);
const { color } = mapping.additionalField;
switch (color) {
case "adaptive":
try {
const number = parseFloat(value);
return number > 0 ? "text-emerald-300" : "text-rose-300";
} catch (e) {
return "";
}
case "black":
return `text-black`;
case "white":
return `text-white`;
case "theme":
return `text-theme-500`;
default:
return "";
}
}
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { mappings = [], refreshInterval = 10000 } = widget;
const { mappings = [], refreshInterval = 10000, display = "block" } = widget;
const { data: customData, error: customError } = useWidgetAPI(widget, null, {
refreshInterval: Math.max(1000, refreshInterval),
});
@ -114,6 +143,27 @@ export default function Component({ service }) {
}
if (!customData) {
switch (display) {
case "list":
return (
<Container service={service}>
<div className="flex flex-col w-full">
{mappings.map((mapping) => (
<div
key={mapping.label}
className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs animate-pulse"
>
<div className="font-thin pl-2">{mapping.label}</div>
<div className="flex flex-row text-right">
<div className="font-bold mr-2">-</div>
</div>
</div>
))}
</div>
</Container>
);
default:
return (
<Container service={service}>
{mappings.slice(0, 4).map((item) => (
@ -122,7 +172,34 @@ export default function Component({ service }) {
</Container>
);
}
}
switch (display) {
case "list":
return (
<Container service={service}>
<div className="flex flex-col w-full">
{mappings.map((mapping) => (
<div
key={mapping.label}
className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs"
>
<div className="font-thin pl-2">{mapping.label}</div>
<div className="flex flex-row text-right">
<div className="font-bold mr-2">{formatValue(t, mapping, getValue(mapping.field, customData))}</div>
{mapping.additionalField && (
<div className={`font-bold mr-2 ${getColor(mapping, customData)}`}>
{formatValue(t, mapping.additionalField, getValue(mapping.additionalField.field, customData))}
</div>
)}
</div>
</div>
))}
</div>
</Container>
);
default:
return (
<Container service={service}>
{mappings.slice(0, 4).map((mapping) => (
@ -134,4 +211,5 @@ export default function Component({ service }) {
))}
</Container>
);
}
}

View File

@ -0,0 +1,32 @@
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { widget } = service;
const { data: giteaNotifications, error: giteaNotificationsError } = useWidgetAPI(widget, "notifications");
const { data: giteaIssues, error: giteaIssuesError } = useWidgetAPI(widget, "issues");
if (giteaNotificationsError || giteaIssuesError) {
return <Container service={service} error={giteaNotificationsError ?? giteaIssuesError} />;
}
if (!giteaNotifications || !giteaIssues) {
return (
<Container service={service}>
<Block label="gitea.notifications" />
<Block label="gitea.issues" />
<Block label="gitea.pulls" />
</Container>
);
}
return (
<Container service={service}>
<Block label="gitea.notifications" value={giteaNotifications.length} />
<Block label="gitea.issues" value={giteaIssues.issues.length} />
<Block label="gitea.pulls" value={giteaIssues.pulls.length} />
</Container>
);
}

View File

@ -0,0 +1,22 @@
import { asJson } from "utils/proxy/api-helpers";
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/v1/{endpoint}?access_token={key}",
proxyHandler: genericProxyHandler,
mappings: {
notifications: {
endpoint: "notifications",
},
issues: {
endpoint: "repos/issues/search",
map: (data) => ({
pulls: asJson(data).filter((issue) => issue.pull_request),
issues: asJson(data).filter((issue) => !issue.pull_request),
}),
},
},
};
export default widget;

View File

@ -13,6 +13,7 @@ export default function Component({ service }) {
const { widget } = service;
const { chart, refreshInterval = defaultInterval } = widget;
const [, fsName] = widget.metric.split("fs:");
const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
const { data, error } = useWidgetAPI(widget, "fs", {
refreshInterval: Math.max(defaultInterval, refreshInterval),
@ -60,7 +61,7 @@ export default function Component({ service }) {
<Block position="bottom-3 left-3">
{fsData.used && chart && (
<div className="text-xs opacity-50">
{t("common.bbytes", {
{t(diskUnits, {
value: fsData.used,
maximumFractionDigits: 0,
})}{" "}
@ -69,7 +70,7 @@ export default function Component({ service }) {
)}
<div className="text-xs opacity-75">
{t("common.bbytes", {
{t(diskUnits, {
value: fsData.free,
maximumFractionDigits: 1,
})}{" "}
@ -81,7 +82,7 @@ export default function Component({ service }) {
<Block position="top-3 right-3">
{fsData.used && (
<div className="text-xs opacity-50">
{t("common.bbytes", {
{t(diskUnits, {
value: fsData.used,
maximumFractionDigits: 0,
})}{" "}
@ -93,7 +94,7 @@ export default function Component({ service }) {
<Block position="bottom-3 right-3">
<div className="text-xs opacity-75">
{t("common.bbytes", {
{t(diskUnits, {
value: fsData.size,
maximumFractionDigits: 1,
})}{" "}

View File

@ -63,26 +63,22 @@ export default function Component({ service }) {
);
}
const hasUuid = widget?.uuid;
const hasUuid = !!widget?.uuid;
const { upCount, downCount } = countStatus(data);
return (
return hasUuid ? (
<Container service={service}>
{hasUuid ? (
<>
<Block label="healthchecks.status" value={t(`healthchecks.${data.status}`)} />
<Block
label="healthchecks.last_ping"
value={data.last_ping ? formatDate(data.last_ping) : t("healthchecks.never")}
/>
</>
</Container>
) : (
<>
<Container service={service}>
<Block label="healthchecks.up" value={upCount} />
<Block label="healthchecks.down" value={downCount} />
</>
)}
</Container>
);
}

View File

@ -1,8 +1,8 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/printer/objects/query?{endpoint}",
proxyHandler: genericProxyHandler,
proxyHandler: credentialedProxyHandler,
mappings: {
print_stats: {

View File

@ -0,0 +1,37 @@
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: plantitData, error: plantitError } = useWidgetAPI(widget, "plantit");
if (plantitError) {
return <Container service={service} error={plantitError} />;
}
if (!plantitData) {
return (
<Container service={service}>
<Block label="plantit.events" />
<Block label="plantit.plants" />
<Block label="plantit.photos" />
<Block label="plantit.species" />
</Container>
);
}
return (
<Container service={service}>
<Block label="plantit.events" value={t("common.number", { value: plantitData.diaryEntryCount })} />
<Block label="plantit.plants" value={t("common.number", { value: plantitData.plantCount })} />
<Block label="plantit.photos" value={t("common.number", { value: plantitData.imageCount })} />
<Block label="plantit.species" value={t("common.number", { value: plantitData.botanicalInfoCount })} />
</Container>
);
}

View File

@ -0,0 +1,21 @@
import { asJson } from "utils/proxy/api-helpers";
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
plantit: {
endpoint: "stats",
},
map: (data) => ({
events: Object.values(asJson(data).diaryEntryCount).reduce((acc, i) => acc + i, 0),
plants: Object.values(asJson(data).plantCount).reduce((acc, i) => acc + i, 0),
photos: Object.values(asJson(data).imageCount).reduce((acc, i) => acc + i, 0),
species: Object.values(asJson(data).botanicalInfoCount).reduce((acc, i) => acc + i, 0),
}),
},
};
export default widget;

View File

@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
import Pool from "widgets/truenas/pool";
export default function Component({ service }) {
const { t } = useTranslation();
@ -11,9 +12,10 @@ export default function Component({ service }) {
const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts");
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
const { data: poolsData, error: poolsError } = useWidgetAPI(widget, "pools");
if (alertError || statusError) {
const finalError = alertError ?? statusError;
if (alertError || statusError || poolsError) {
const finalError = alertError ?? statusError ?? poolsError;
return <Container service={service} error={finalError} />;
}
@ -27,11 +29,19 @@ export default function Component({ service }) {
);
}
const enablePools = widget?.enablePools && Array.isArray(poolsData) && poolsData.length > 0;
return (
<>
<Container service={service}>
<Block label="truenas.load" value={t("common.number", { value: statusData.loadavg[0] })} />
<Block label="truenas.uptime" value={t("common.uptime", { value: statusData.uptime_seconds })} />
<Block label="truenas.alerts" value={t("common.number", { value: alertData.pending })} />
</Container>
{enablePools &&
poolsData.map((pool) => (
<Pool key={pool.id} name={pool.name} healthy={pool.healthy} allocated={pool.allocated} free={pool.free} />
))}
</>
);
}

View File

@ -0,0 +1,31 @@
import classNames from "classnames";
import prettyBytes from "pretty-bytes";
export default function Pool({ name, free, allocated, healthy }) {
const total = free + allocated;
const usedPercent = Math.round((allocated / total) * 100);
const statusColor = healthy ? "bg-green-500" : "bg-yellow-500";
return (
<div className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
style={{
width: `${usedPercent}%`,
}}
/>
<span className="ml-2 h-2 w-2 z-10">
<span className={classNames("block w-2 h-2 rounded", statusColor)} />
</span>
<div className="text-xs z-10 self-center ml-2 relative h-4 grow mr-2">
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left">{name}</div>
</div>
<div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap">
<span>
{prettyBytes(allocated)} / {prettyBytes(total)}
</span>
<span className="pl-2">({usedPercent}%)</span>
</div>
</div>
);
}

View File

@ -1,32 +1,9 @@
import { jsonArrayFilter } from "utils/proxy/api-helpers";
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
import genericProxyHandler from "utils/proxy/handlers/generic";
import getServiceWidget from "utils/config/service-helpers";
import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers";
const widget = {
api: "{url}/api/v2.0/{endpoint}",
proxyHandler: async (req, res, map) => {
// choose proxy handler based on widget settings
const { group, service } = req.query;
if (group && service) {
const widgetOpts = await getServiceWidget(group, service);
let handler;
if (widgetOpts.username && widgetOpts.password) {
handler = genericProxyHandler;
} else if (widgetOpts.key) {
handler = credentialedProxyHandler;
}
if (handler) {
return handler(req, res, map);
}
return res.status(500).json({ error: "Username / password or API key required" });
}
return res.status(500).json({ error: "Error parsing widget request" });
},
proxyHandler: credentialedProxyHandler,
mappings: {
alerts: {
@ -39,6 +16,17 @@ const widget = {
endpoint: "system/info",
validate: ["loadavg", "uptime_seconds"],
},
pools: {
endpoint: "pool",
map: (data) =>
asJson(data).map((entry) => ({
id: entry.name,
name: entry.name,
healthy: entry.healthy,
allocated: entry.allocated,
free: entry.free,
})),
},
},
};

View File

@ -25,6 +25,7 @@ import fritzbox from "./fritzbox/widget";
import gamedig from "./gamedig/widget";
import gatus from "./gatus/widget";
import ghostfolio from "./ghostfolio/widget";
import gitea from "./gitea/widget";
import glances from "./glances/widget";
import gluetun from "./gluetun/widget";
import gotify from "./gotify/widget";
@ -72,6 +73,7 @@ import photoprism from "./photoprism/widget";
import proxmoxbackupserver from "./proxmoxbackupserver/widget";
import pialert from "./pialert/widget";
import pihole from "./pihole/widget";
import plantit from "./plantit/widget";
import plex from "./plex/widget";
import portainer from "./portainer/widget";
import prometheus from "./prometheus/widget";
@ -137,6 +139,7 @@ const widgets = {
gamedig,
gatus,
ghostfolio,
gitea,
glances,
gluetun,
gotify,
@ -187,6 +190,7 @@ const widgets = {
proxmoxbackupserver,
pialert,
pihole,
plantit,
plex,
portainer,
prometheus,