Merge branch 'gethomepage:main' into main
This commit is contained in:
commit
6eb86cecb4
87
.github/workflows/repo-maintenance.yml
vendored
87
.github/workflows/repo-maintenance.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
|||||||
stale-issue-message: >
|
stale-issue-message: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
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
|
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:
|
lock-threads:
|
||||||
name: 'Lock Old Threads'
|
name: 'Lock Old Threads'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -42,14 +42,17 @@ jobs:
|
|||||||
This issue has been automatically locked since there
|
This issue has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion for related concerns.
|
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: >
|
pr-comment: >
|
||||||
This pull request has been automatically locked since there
|
This pull request has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion for related concerns.
|
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: >
|
discussion-comment: >
|
||||||
This discussion has been automatically locked since there
|
This discussion has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion for related concerns.
|
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:
|
close-answered-discussions:
|
||||||
name: 'Close Answered Discussions'
|
name: 'Close Answered Discussions'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -89,7 +92,7 @@ jobs:
|
|||||||
}`;
|
}`;
|
||||||
const commentVariables = {
|
const commentVariables = {
|
||||||
discussion: discussion.id,
|
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)
|
await github.graphql(addCommentMutation, commentVariables)
|
||||||
|
|
||||||
@ -179,7 +182,85 @@ jobs:
|
|||||||
}`;
|
}`;
|
||||||
const commentVariables = {
|
const commentVariables = {
|
||||||
discussion: discussion.id,
|
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);
|
await github.graphql(addCommentMutation, commentVariables);
|
||||||
|
|
||||||
|
|||||||
@ -51,3 +51,18 @@ By contributing, you agree that your contributions will be licensed under its GN
|
|||||||
## References
|
## References
|
||||||
|
|
||||||
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/main/CONTRIBUTING.md)
|
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/main/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|||||||
@ -16,6 +16,8 @@ widget:
|
|||||||
password: password # auth - optional
|
password: password # auth - optional
|
||||||
method: GET # optional, e.g. POST
|
method: GET # optional, e.g. POST
|
||||||
headers: # optional, must be object, see below
|
headers: # optional, must be object, see below
|
||||||
|
requestBody: # optional, can be string or object, see below
|
||||||
|
display: # optional, default to block, see below
|
||||||
mappings:
|
mappings:
|
||||||
- field: key # needs to be YAML string or object
|
- field: key # needs to be YAML string or object
|
||||||
label: Field 1
|
label: Field 1
|
||||||
@ -43,6 +45,15 @@ widget:
|
|||||||
locale: nl # optional
|
locale: nl # optional
|
||||||
style: short # optional - defaults to "long". Allowed values: `["long", "short", "narrow"]`.
|
style: short # optional - defaults to "long". Allowed values: `["long", "short", "narrow"]`.
|
||||||
numeric: auto # optional - defaults to "always". Allowed values `["always", "auto"]`.
|
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`.
|
Supported formats for the values are `text`, `number`, `float`, `percent`, `bytes`, `bitrate`, `date` and `relativeDate`.
|
||||||
@ -93,7 +104,7 @@ mappings:
|
|||||||
|
|
||||||
## Data Transformation
|
## 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
|
```yaml
|
||||||
- field: key4
|
- field: key4
|
||||||
@ -110,7 +121,42 @@ You can manipulate data with the following tools `remap`, `scale` and `suffix`,
|
|||||||
label: Power
|
label: Power
|
||||||
format: float
|
format: float
|
||||||
scale: 0.001 # can be number or string e.g. 1/16
|
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
|
## Custom Headers
|
||||||
@ -121,3 +167,16 @@ Pass custom headers using the `headers` option, for example:
|
|||||||
headers:
|
headers:
|
||||||
X-API-Token: token
|
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!
|
||||||
|
|||||||
@ -12,3 +12,12 @@ widget:
|
|||||||
type: moonraker
|
type: moonraker
|
||||||
url: http://moonraker.host.or.ip:port
|
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/).
|
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
|
```yaml
|
||||||
widget:
|
widget:
|
||||||
type: truenas
|
type: truenas
|
||||||
@ -16,4 +18,5 @@ widget:
|
|||||||
username: user # not required if using api key
|
username: user # not required if using api key
|
||||||
password: pass # not required if using api key
|
password: pass # not required if using api key
|
||||||
key: yourtruenasapikey # not required if using username / password
|
key: yourtruenasapikey # not required if using username / password
|
||||||
|
enablePools: true # optional, defaults to false
|
||||||
```
|
```
|
||||||
|
|||||||
@ -103,6 +103,7 @@ nav:
|
|||||||
- widgets/services/photoprism.md
|
- widgets/services/photoprism.md
|
||||||
- widgets/services/pialert.md
|
- widgets/services/pialert.md
|
||||||
- widgets/services/pihole.md
|
- widgets/services/pihole.md
|
||||||
|
- widgets/services/plantit.md
|
||||||
- widgets/services/plex-tautulli.md
|
- widgets/services/plex-tautulli.md
|
||||||
- widgets/services/plex.md
|
- widgets/services/plex.md
|
||||||
- widgets/services/portainer.md
|
- widgets/services/portainer.md
|
||||||
|
|||||||
@ -825,5 +825,11 @@
|
|||||||
"netdata": {
|
"netdata": {
|
||||||
"warnings": "Warnings",
|
"warnings": "Warnings",
|
||||||
"criticals": "Criticals"
|
"criticals": "Criticals"
|
||||||
|
},
|
||||||
|
"plantit": {
|
||||||
|
"events": "Events",
|
||||||
|
"plants": "Plants",
|
||||||
|
"photos": "Photos",
|
||||||
|
"species": "Species"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -803,5 +803,11 @@
|
|||||||
"netdata": {
|
"netdata": {
|
||||||
"warnings": "Warnings",
|
"warnings": "Warnings",
|
||||||
"criticals": "Criticals"
|
"criticals": "Criticals"
|
||||||
|
},
|
||||||
|
"plantit": {
|
||||||
|
"events": "Eventi",
|
||||||
|
"plants": "Piante",
|
||||||
|
"species": "Specie",
|
||||||
|
"images": "Immagini"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@ import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import Tab, { slugify } from "components/tab";
|
import Tab, { slugify } from "components/tab";
|
||||||
import FileContent from "components/filecontent";
|
|
||||||
import ServicesGroup from "components/services/group";
|
import ServicesGroup from "components/services/group";
|
||||||
import BookmarksGroup from "components/bookmarks/group";
|
import BookmarksGroup from "components/bookmarks/group";
|
||||||
import Widget from "components/widgets/widget";
|
import Widget from "components/widgets/widget";
|
||||||
@ -391,17 +390,10 @@ function Home({ initialSettings }) {
|
|||||||
)}
|
)}
|
||||||
<meta name="msapplication-TileColor" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
<meta name="msapplication-TileColor" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
||||||
<meta name="theme-color" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
<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>
|
</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>
|
|
||||||
<Script src="/api/config/custom.js" />
|
<Script src="/api/config/custom.js" />
|
||||||
|
|
||||||
<div className="relative container m-auto flex flex-col justify-start z-10 h-full">
|
<div className="relative container m-auto flex flex-col justify-start z-10 h-full">
|
||||||
|
|||||||
@ -378,6 +378,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
|
|
||||||
// customapi
|
// customapi
|
||||||
mappings,
|
mappings,
|
||||||
|
display,
|
||||||
|
|
||||||
// diskstation
|
// diskstation
|
||||||
volume,
|
volume,
|
||||||
@ -441,6 +442,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
// sonarr, radarr
|
// sonarr, radarr
|
||||||
enableQueue,
|
enableQueue,
|
||||||
|
|
||||||
|
// truenas
|
||||||
|
enablePools,
|
||||||
|
|
||||||
// unifi
|
// unifi
|
||||||
site,
|
site,
|
||||||
} = cleanedService.widget;
|
} = cleanedService.widget;
|
||||||
@ -510,6 +514,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
if (["sonarr", "radarr"].includes(type)) {
|
if (["sonarr", "radarr"].includes(type)) {
|
||||||
if (enableQueue !== undefined) cleanedService.widget.enableQueue = JSON.parse(enableQueue);
|
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 (["diskstation", "qnap"].includes(type)) {
|
||||||
if (volume) cleanedService.widget.volume = volume;
|
if (volume) cleanedService.widget.volume = volume;
|
||||||
}
|
}
|
||||||
@ -539,6 +546,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
}
|
}
|
||||||
if (type === "customapi") {
|
if (type === "customapi") {
|
||||||
if (mappings) cleanedService.widget.mappings = mappings;
|
if (mappings) cleanedService.widget.mappings = mappings;
|
||||||
|
if (display) cleanedService.widget.display = display;
|
||||||
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
|
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
|
||||||
}
|
}
|
||||||
if (type === "calendar") {
|
if (type === "calendar") {
|
||||||
|
|||||||
@ -29,11 +29,15 @@ export default async function credentialedProxyHandler(req, res, map) {
|
|||||||
} else if (widget.type === "gotify") {
|
} else if (widget.type === "gotify") {
|
||||||
headers["X-gotify-Key"] = `${widget.key}`;
|
headers["X-gotify-Key"] = `${widget.key}`;
|
||||||
} else if (
|
} else if (
|
||||||
["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "truenas", "pterodactyl"].includes(
|
["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "pterodactyl"].includes(widget.type)
|
||||||
widget.type,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
headers.Authorization = `Bearer ${widget.key}`;
|
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") {
|
} else if (widget.type === "proxmox") {
|
||||||
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
|
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
|
||||||
} else if (widget.type === "proxmoxbackupserver") {
|
} 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")}`;
|
headers.Authorization = `Basic ${Buffer.from(`$:${widget.key}`).toString("base64")}`;
|
||||||
} else if (widget.type === "glances") {
|
} else if (widget.type === "glances") {
|
||||||
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
|
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
|
||||||
|
} else if (widget.type === "plantit") {
|
||||||
|
headers.Key = `${widget.key}`;
|
||||||
} else {
|
} else {
|
||||||
headers["X-API-Key"] = `${widget.key}`;
|
headers["X-API-Key"] = `${widget.key}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,12 @@ export default async function genericProxyHandler(req, res, map) {
|
|||||||
};
|
};
|
||||||
if (req.body) {
|
if (req.body) {
|
||||||
params.body = 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);
|
const [status, contentType, data] = await httpProxy(url, params);
|
||||||
|
|||||||
@ -78,6 +78,7 @@ const components = {
|
|||||||
proxmoxbackupserver: dynamic(() => import("./proxmoxbackupserver/component")),
|
proxmoxbackupserver: dynamic(() => import("./proxmoxbackupserver/component")),
|
||||||
pialert: dynamic(() => import("./pialert/component")),
|
pialert: dynamic(() => import("./pialert/component")),
|
||||||
pihole: dynamic(() => import("./pihole/component")),
|
pihole: dynamic(() => import("./pihole/component")),
|
||||||
|
plantit: dynamic(() => import("./plantit/component")),
|
||||||
plex: dynamic(() => import("./plex/component")),
|
plex: dynamic(() => import("./plex/component")),
|
||||||
portainer: dynamic(() => import("./portainer/component")),
|
portainer: dynamic(() => import("./portainer/component")),
|
||||||
prometheus: dynamic(() => import("./prometheus/component")),
|
prometheus: dynamic(() => import("./prometheus/component")),
|
||||||
|
|||||||
@ -90,6 +90,12 @@ function formatValue(t, mapping, rawValue) {
|
|||||||
// nothing
|
// nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply fixed prefix.
|
||||||
|
const prefix = mapping?.prefix;
|
||||||
|
if (prefix) {
|
||||||
|
value = `${prefix} ${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply fixed suffix.
|
// Apply fixed suffix.
|
||||||
const suffix = mapping?.suffix;
|
const suffix = mapping?.suffix;
|
||||||
if (suffix) {
|
if (suffix) {
|
||||||
@ -99,12 +105,35 @@ function formatValue(t, mapping, rawValue) {
|
|||||||
return value;
|
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 }) {
|
export default function Component({ service }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
|
|
||||||
const { mappings = [], refreshInterval = 10000 } = widget;
|
const { mappings = [], refreshInterval = 10000, display = "block" } = widget;
|
||||||
const { data: customData, error: customError } = useWidgetAPI(widget, null, {
|
const { data: customData, error: customError } = useWidgetAPI(widget, null, {
|
||||||
refreshInterval: Math.max(1000, refreshInterval),
|
refreshInterval: Math.max(1000, refreshInterval),
|
||||||
});
|
});
|
||||||
@ -114,24 +143,73 @@ export default function Component({ service }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!customData) {
|
if (!customData) {
|
||||||
return (
|
switch (display) {
|
||||||
<Container service={service}>
|
case "list":
|
||||||
{mappings.slice(0, 4).map((item) => (
|
return (
|
||||||
<Block label={item.label} key={item.label} />
|
<Container service={service}>
|
||||||
))}
|
<div className="flex flex-col w-full">
|
||||||
</Container>
|
{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 (
|
switch (display) {
|
||||||
<Container service={service}>
|
case "list":
|
||||||
{mappings.slice(0, 4).map((mapping) => (
|
return (
|
||||||
<Block
|
<Container service={service}>
|
||||||
label={mapping.label}
|
<div className="flex flex-col w-full">
|
||||||
key={mapping.label}
|
{mappings.map((mapping) => (
|
||||||
value={formatValue(t, mapping, getValue(mapping.field, customData))}
|
<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"
|
||||||
</Container>
|
>
|
||||||
);
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import genericProxyHandler from "utils/proxy/handlers/generic";
|
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||||
|
|
||||||
const widget = {
|
const widget = {
|
||||||
api: "{url}/printer/objects/query?{endpoint}",
|
api: "{url}/printer/objects/query?{endpoint}",
|
||||||
proxyHandler: genericProxyHandler,
|
proxyHandler: credentialedProxyHandler,
|
||||||
|
|
||||||
mappings: {
|
mappings: {
|
||||||
print_stats: {
|
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 Container from "components/services/widget/container";
|
||||||
import Block from "components/services/widget/block";
|
import Block from "components/services/widget/block";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
import Pool from "widgets/truenas/pool";
|
||||||
|
|
||||||
export default function Component({ service }) {
|
export default function Component({ service }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -11,9 +12,10 @@ export default function Component({ service }) {
|
|||||||
|
|
||||||
const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts");
|
const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts");
|
||||||
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
|
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
|
||||||
|
const { data: poolsData, error: poolsError } = useWidgetAPI(widget, "pools");
|
||||||
|
|
||||||
if (alertError || statusError) {
|
if (alertError || statusError || poolsError) {
|
||||||
const finalError = alertError ?? statusError;
|
const finalError = alertError ?? statusError ?? poolsError;
|
||||||
return <Container service={service} error={finalError} />;
|
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 (
|
return (
|
||||||
<Container service={service}>
|
<>
|
||||||
<Block label="truenas.load" value={t("common.number", { value: statusData.loadavg[0] })} />
|
<Container service={service}>
|
||||||
<Block label="truenas.uptime" value={t("common.uptime", { value: statusData.uptime_seconds })} />
|
<Block label="truenas.load" value={t("common.number", { value: statusData.loadavg[0] })} />
|
||||||
<Block label="truenas.alerts" value={t("common.number", { value: alertData.pending })} />
|
<Block label="truenas.uptime" value={t("common.uptime", { value: statusData.uptime_seconds })} />
|
||||||
</Container>
|
<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 credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||||
import genericProxyHandler from "utils/proxy/handlers/generic";
|
import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers";
|
||||||
import getServiceWidget from "utils/config/service-helpers";
|
|
||||||
|
|
||||||
const widget = {
|
const widget = {
|
||||||
api: "{url}/api/v2.0/{endpoint}",
|
api: "{url}/api/v2.0/{endpoint}",
|
||||||
proxyHandler: async (req, res, map) => {
|
proxyHandler: credentialedProxyHandler,
|
||||||
// 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" });
|
|
||||||
},
|
|
||||||
|
|
||||||
mappings: {
|
mappings: {
|
||||||
alerts: {
|
alerts: {
|
||||||
@ -39,6 +16,17 @@ const widget = {
|
|||||||
endpoint: "system/info",
|
endpoint: "system/info",
|
||||||
validate: ["loadavg", "uptime_seconds"],
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,7 @@ import photoprism from "./photoprism/widget";
|
|||||||
import proxmoxbackupserver from "./proxmoxbackupserver/widget";
|
import proxmoxbackupserver from "./proxmoxbackupserver/widget";
|
||||||
import pialert from "./pialert/widget";
|
import pialert from "./pialert/widget";
|
||||||
import pihole from "./pihole/widget";
|
import pihole from "./pihole/widget";
|
||||||
|
import plantit from "./plantit/widget";
|
||||||
import plex from "./plex/widget";
|
import plex from "./plex/widget";
|
||||||
import portainer from "./portainer/widget";
|
import portainer from "./portainer/widget";
|
||||||
import prometheus from "./prometheus/widget";
|
import prometheus from "./prometheus/widget";
|
||||||
@ -180,6 +181,7 @@ const widgets = {
|
|||||||
proxmoxbackupserver,
|
proxmoxbackupserver,
|
||||||
pialert,
|
pialert,
|
||||||
pihole,
|
pihole,
|
||||||
|
plantit,
|
||||||
plex,
|
plex,
|
||||||
portainer,
|
portainer,
|
||||||
prometheus,
|
prometheus,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user