mirror of
https://github.com/karl0ss/homepage.git
synced 2025-05-02 13:33:40 +01:00
Merge remote-tracking branch 'origin/benphelpsMain' into LocalMain
This commit is contained in:
commit
6ba7e38d67
@ -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"
|
||||
|
@ -1,27 +1,26 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"VARIANT": "18-bullseye"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"mhutchie.git-graph",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
],
|
||||
"settings": {
|
||||
"eslint.format.enable": true,
|
||||
"eslint.lintTask.enable": true,
|
||||
"eslint.packageManager": "pnpm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"postCreateCommand": ".devcontainer/setup.sh",
|
||||
"forwardPorts": [
|
||||
3000
|
||||
]
|
||||
"name": "homepage",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"VARIANT": "18-bullseye",
|
||||
},
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"postCreateCommand": ".devcontainer/setup.sh",
|
||||
"forwardPorts": [3000],
|
||||
}
|
||||
|
@ -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"
|
||||
|
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@ -13,3 +13,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
|
87
.github/workflows/repo-maintenance.yml
vendored
87
.github/workflows/repo-maintenance.yml
vendored
@ -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);
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
```
|
||||
|
@ -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,
|
||||
|
@ -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!
|
||||
|
17
docs/widgets/services/gitea.md
Normal file
17
docs/widgets/services/gitea.md
Normal 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
|
||||
```
|
@ -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:_
|
||||
|
@ -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
|
||||
```
|
||||
|
15
docs/widgets/services/planit.md
Normal file
15
docs/widgets/services/planit.md
Normal 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
|
||||
```
|
@ -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
|
||||
```
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -803,5 +803,11 @@
|
||||
"netdata": {
|
||||
"warnings": "Warnings",
|
||||
"criticals": "Criticals"
|
||||
},
|
||||
"plantit": {
|
||||
"events": "Eventi",
|
||||
"plants": "Piante",
|
||||
"species": "Specie",
|
||||
"images": "Immagini"
|
||||
}
|
||||
}
|
||||
|
@ -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 && (
|
||||
|
@ -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;
|
||||
}
|
@ -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"
|
||||
|
@ -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 && (
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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,20 +119,27 @@ export default function Search({ options }) {
|
||||
};
|
||||
}, [selectedProvider, options, query, searchSuggestions]);
|
||||
|
||||
const submitCallback = useCallback(
|
||||
(value) => {
|
||||
const q = encodeURIComponent(value);
|
||||
const { url } = selectedProvider;
|
||||
if (url) {
|
||||
window.open(`${url}${q}`, options.target || "_blank");
|
||||
} else {
|
||||
window.open(`${options.url}${q}`, options.target || "_blank");
|
||||
}
|
||||
let currentSuggestion;
|
||||
|
||||
setQuery("");
|
||||
},
|
||||
[selectedProvider, options.url, options.target],
|
||||
);
|
||||
function doSearch(value) {
|
||||
const q = encodeURIComponent(value);
|
||||
const { url } = selectedProvider;
|
||||
if (url) {
|
||||
window.open(`${url}${q}`, options.target || "_blank");
|
||||
} else {
|
||||
window.open(`${options.url}${q}`, options.target || "_blank");
|
||||
}
|
||||
|
||||
setQuery("");
|
||||
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,20 +240,30 @@ 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 }) => (
|
||||
<div
|
||||
className={classNames(
|
||||
"px-2 py-1 rounded-md w-full flex-nowrap",
|
||||
active ? "bg-theme-300/20 dark:bg-white/10" : "",
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
|
||||
<span className="mr-4 whitespace-pre opacity-50">
|
||||
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<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",
|
||||
active ? "bg-theme-300/20 dark:bg-white/10" : "",
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
|
||||
<span className="mr-4 whitespace-pre opacity-50">
|
||||
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</div>
|
||||
|
@ -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
|
||||
|
@ -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") {
|
||||
|
@ -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();
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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 }),
|
||||
|
@ -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")),
|
||||
|
@ -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,24 +143,73 @@ export default function Component({ service }) {
|
||||
}
|
||||
|
||||
if (!customData) {
|
||||
return (
|
||||
<Container service={service}>
|
||||
{mappings.slice(0, 4).map((item) => (
|
||||
<Block label={item.label} key={item.label} />
|
||||
))}
|
||||
</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 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) => (
|
||||
<Block label={item.label} key={item.label} />
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container service={service}>
|
||||
{mappings.slice(0, 4).map((mapping) => (
|
||||
<Block
|
||||
label={mapping.label}
|
||||
key={mapping.label}
|
||||
value={formatValue(t, mapping, getValue(mapping.field, customData))}
|
||||
/>
|
||||
))}
|
||||
</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) => (
|
||||
<Block
|
||||
label={mapping.label}
|
||||
key={mapping.label}
|
||||
value={formatValue(t, mapping, getValue(mapping.field, customData))}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
32
src/widgets/gitea/component.jsx
Normal file
32
src/widgets/gitea/component.jsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/widgets/gitea/widget.js
Normal file
22
src/widgets/gitea/widget.js
Normal 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;
|
@ -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,
|
||||
})}{" "}
|
||||
|
@ -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")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Block label="healthchecks.up" value={upCount} />
|
||||
<Block label="healthchecks.down" value={downCount} />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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: {
|
||||
|
37
src/widgets/plantit/component.jsx
Normal file
37
src/widgets/plantit/component.jsx
Normal 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>
|
||||
);
|
||||
}
|
21
src/widgets/plantit/widget.js
Normal file
21
src/widgets/plantit/widget.js
Normal 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;
|
@ -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>
|
||||
<>
|
||||
<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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
31
src/widgets/truenas/pool.jsx
Normal file
31
src/widgets/truenas/pool.jsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user