mirror of
https://github.com/gethomepage/homepage.git
synced 2025-12-05 21:47:48 +01:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b019295a06 | ||
|
|
5fa790e9fe | ||
|
|
7719ea17be | ||
|
|
885a4051f3 | ||
|
|
67f5ee8df5 | ||
|
|
ebd384c62d | ||
|
|
5512d05f00 | ||
|
|
654f16dbb5 | ||
|
|
bec1e5fff2 | ||
|
|
1da9255578 | ||
|
|
cc887214cf | ||
|
|
98c3ca6dac | ||
|
|
3c4818a2b4 | ||
|
|
f773e026d5 | ||
|
|
3f1229555e | ||
|
|
6898faa3de | ||
|
|
792f768a7f | ||
|
|
0c8c759f8a | ||
|
|
241c981444 | ||
|
|
56349e57e5 | ||
|
|
6763da57a6 |
2
.github/workflows/docs-publish.yml
vendored
2
.github/workflows/docs-publish.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- run: sudo apt-get install pngquant
|
||||
- run: pip install mike
|
||||
- run: pip install mike==1.1.2
|
||||
- run: pip install git+https://${GH_TOKEN}@github.com/benphelps/mkdocs-material-insiders.git
|
||||
- name: Set Git config
|
||||
run: |
|
||||
|
||||
@@ -42,7 +42,7 @@ Please see the [documentation regarding development](https://gethomepage.dev/lat
|
||||
|
||||
## Use a Consistent Coding Style
|
||||
|
||||
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/latest/more/development.md#code-formatting-with-pre-commit-hooks).
|
||||
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/latest/more/development/#code-formatting-with-pre-commit-hooks).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -45,11 +45,11 @@ Homepage has built-in support for Docker, and can automatically discover and add
|
||||
|
||||
## Service Widgets
|
||||
|
||||
Homepage also has support for over 100 3rd party services, including all popular starr apps, and most popular self-hosted apps. Some examples include: Radarr, Sonarr, Lidarr, Bazarr, Ombi, Tautulli, Plex, Jellyfin, Emby, Transmission, qBittorrent, Deluge, Jackett, NZBGet, SABnzbd, etc. As well as service integrations, Homepage also has a number of information providers, sourcing information from a variety of external 3rd party APIs. See the [Service](https://gethomepage.dev/latest/configs/service-widgets/) page for more information.
|
||||
Homepage also has support for over 100 3rd party services, including all popular starr apps, and most popular self-hosted apps. Some examples include: Radarr, Sonarr, Lidarr, Bazarr, Ombi, Tautulli, Plex, Jellyfin, Emby, Transmission, qBittorrent, Deluge, Jackett, NZBGet, SABnzbd, etc. As well as service integrations, Homepage also has a number of information providers, sourcing information from a variety of external 3rd party APIs. See the [Service](https://gethomepage.dev/latest/widgets/) page for more information.
|
||||
|
||||
## Information Widgets
|
||||
|
||||
Homepage has built-in support for a number of information providers, including weather, time, date, search, glances and more. System and status information presented at the top of the page. See the [Information Providers](https://gethomepage.dev/latest/configs/widgets/) page for more information.
|
||||
Homepage has built-in support for a number of information providers, including weather, time, date, search, glances and more. System and status information presented at the top of the page. See the [Information Providers](https://gethomepage.dev/latest/widgets/) page for more information.
|
||||
|
||||
## Customization
|
||||
|
||||
@@ -166,7 +166,7 @@ If you have any questions, suggestions, or general issues, please start a discus
|
||||
|
||||
For bug reports, please open an issue on the [Issues](https://github.com/gethomepage/homepage/issues) page.
|
||||
|
||||
## Contributing & Contributers
|
||||
## Contributing & Contributors
|
||||
|
||||
Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Service Widget Configuration
|
||||
|
||||
Unless otherwise noted, URLs should not end with a `/` or other API path. Each widget will handle the path on its own.
|
||||
|
||||
Each service can have one widget attached to it (often matching the service type, but thats not forced).
|
||||
Each service can have one widget attached to it (often matching the service type, but that's not forced).
|
||||
|
||||
In addition to the href of the service, you can also specify the target location in which to open that link. See [Link Target](settings.md#link-target) for more details.
|
||||
|
||||
|
||||
@@ -101,30 +101,50 @@ 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 endpoint you chose and have the response time displayed. You do not need to set your ping URL equal to your href URL.
|
||||
|
||||
!!! note
|
||||
|
||||
The ping feature works by making an http `HEAD` request to the URL, and falls back to `GET` in case that fails. It will not, for example, login if the URL requires auth or is behind e.g. Authelia. In the case of a reverse proxy and/or auth this usually requires the use of an 'internal' URL to make the ping feature correctly display status.
|
||||
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.
|
||||
|
||||
```yaml
|
||||
- Group A:
|
||||
- Sonarr:
|
||||
icon: sonarr.png
|
||||
href: http://sonarr.host/
|
||||
ping: http://sonarr.host/
|
||||
ping: sonarr.host
|
||||
|
||||
- Group B:
|
||||
- Radarr:
|
||||
icon: radarr.png
|
||||
href: http://radarr.host/
|
||||
ping: http://some.other.host/
|
||||
ping: some.other.host
|
||||
```
|
||||
|
||||
<img width="1038" alt="Ping" src="https://github.com/gethomepage/homepage/assets/88257202/7bc13bd3-0d0b-44e3-888c-a20e069a3233">
|
||||
|
||||
You can also apply different styles to the ping indicator by using the `statusStyle` property, see [settings](settings.md#status-style).
|
||||
|
||||
## Site Monitor
|
||||
|
||||
Services may have an optional `siteMonitor` property (formerly `ping`) that allows you to monitor the availability of a URL you chose and have the response time displayed. You do not need to set your monitor URL equal to your href or ping URL.
|
||||
|
||||
!!! note
|
||||
|
||||
The site monitor feature works by making an http `HEAD` request to the URL, and falls back to `GET` in case that fails. It will not, for example, login if the URL requires auth or is behind e.g. Authelia. In the case of a reverse proxy and/or auth this usually requires the use of an 'internal' URL to make the site monitor feature correctly display status.
|
||||
|
||||
```yaml
|
||||
- Group A:
|
||||
- Sonarr:
|
||||
icon: sonarr.png
|
||||
href: http://sonarr.host/
|
||||
siteMonitor: http://sonarr.host/
|
||||
|
||||
- Group B:
|
||||
- Radarr:
|
||||
icon: radarr.png
|
||||
href: http://radarr.host/
|
||||
siteMonitor: http://some.other.host/
|
||||
```
|
||||
|
||||
You can also apply different styles to the site monitor indicator by using the `statusStyle` property, see [settings](settings.md#status-style).
|
||||
|
||||
## Docker Integration
|
||||
|
||||
Services may be connected to a Docker container, either running on the local machine, or a remote machine.
|
||||
|
||||
@@ -67,7 +67,7 @@ background:
|
||||
|
||||
### Card Background Blur
|
||||
|
||||
You can apply a blur filter to the service & bookmark cards. Note this option is incompatible with the backround blur, saturate and brightness filters.
|
||||
You can apply a blur filter to the service & bookmark cards. Note this option is incompatible with the background blur, saturate and brightness filters.
|
||||
|
||||
```yaml
|
||||
cardBlur: sm # sm, "", md, etc... see https://tailwindcss.com/docs/backdrop-blur
|
||||
@@ -329,7 +329,7 @@ You can then pass `provider` instead of `apiKey` in your widget configuration.
|
||||
|
||||
## Quick Launch
|
||||
|
||||
You can use the 'Quick Launch' feature to search services, perform a web search or open a URL. To use Quick Launch, just start typing while on your homepage (as long as the search widget doesnt have focus).
|
||||
You can use the 'Quick Launch' feature to search services, perform a web search or open a URL. To use Quick Launch, just start typing while on your homepage (as long as the search widget doesn't have focus).
|
||||
|
||||
<img width="1000" alt="quicklaunch" src="https://user-images.githubusercontent.com/4887959/216880811-90ff72cb-2990-4475-889b-7c3a31e6beef.png">
|
||||
|
||||
@@ -382,11 +382,11 @@ If you have both set the per-service settings take precedence.
|
||||
|
||||
## Status Style
|
||||
|
||||
You can choose from the following styles for docker or k8s status and ping: `dot` or `basic`
|
||||
You can choose from the following styles for docker or k8s status, site monitor and ping: `dot` or `basic`
|
||||
|
||||
- The default is no value, and displays the ping response time in ms and the docker / k8s container status
|
||||
- `dot` shows a green dot for a successful ping or healthy status.
|
||||
- `basic` shows either UP or DOWN for ping
|
||||
- The default is no value, and displays the montior and ping response time in ms and the docker / k8s container status
|
||||
- `dot` shows a green dot for a successful monitor ping or healthy status.
|
||||
- `basic` shows either UP or DOWN for monitor & ping
|
||||
|
||||
For example:
|
||||
|
||||
@@ -422,4 +422,4 @@ or per service widget (`services.yaml`) with:
|
||||
hideErrors: true
|
||||
```
|
||||
|
||||
If either value is set to true, the errror message will be hidden.
|
||||
If either value is set to true, the error message will be hidden.
|
||||
|
||||
@@ -34,11 +34,10 @@ pnpm lint
|
||||
## Code formatting with pre-commit hooks
|
||||
|
||||
To ensure a consistent style and formatting across the project source, the project utilizes Git [`pre-commit`](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) hooks to perform some formatting and linting before a commit is allowed.
|
||||
That way, everyone uses the same style and some common issues can be caught early on.
|
||||
|
||||
Once installed, hooks will run when you commit. If the formatting isn't quite right or a linter catches something, the commit will be rejected.
|
||||
You'll need to look at the output and fix the issue. Some hooks will format failing files, so all you need to do is `git add` those files again
|
||||
and retry your commit.
|
||||
Once installed, hooks will run when you commit. If the formatting isn't quite right, the commit will be rejected and you'll need to look at the output and fix the issue. Most hooks will automatically format failing files, so all you need to do is `git add` those files again and retry your commit.
|
||||
|
||||
See the [pre-commit documentation](https://pre-commit.com/#install) to get started.
|
||||
|
||||
## Service Widget Guidelines
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: OpenWeatherMap
|
||||
description: OpenWeatherMap Information Widget Configuration
|
||||
---
|
||||
|
||||
The free tier "One Call API" is all thats required, you will need to [subscribe](https://home.openweathermap.org/subscriptions/unauth_subscribe/onecall_30/base) and grab your API key.
|
||||
The free tier "One Call API" is all that's required, you will need to [subscribe](https://home.openweathermap.org/subscriptions/unauth_subscribe/onecall_30/base) and grab your API key.
|
||||
|
||||
```yaml
|
||||
- openweathermap:
|
||||
|
||||
@@ -9,7 +9,7 @@ The disk path is the path reported by `df` (Mounted On), or the mount point of t
|
||||
|
||||
The cpu and memory resource information are the container's usage while [glances](glances.md) displays statistics for the host machine on which it is installed.
|
||||
|
||||
_Note: unfortunately, the package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatibile with some setups and will not report any value(s) for CPU temp._
|
||||
_Note: unfortunately, the package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp._
|
||||
|
||||
**Any disk you wish to access must be mounted to your container as a volume.**
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Weather API Information Widget Configuration
|
||||
|
||||
**Note: this widget is considered 'deprecated' since there is no longer a free Weather API tier for new members. See the openmeteo or openweathermap widgets for alternatives.**
|
||||
|
||||
The free tier is all thats required, you will need to [register](https://www.weatherapi.com/signup.aspx) and grab your API key.
|
||||
The free tier is all that's required, you will need to [register](https://www.weatherapi.com/signup.aspx) and grab your API key.
|
||||
|
||||
```yaml
|
||||
- weatherapi:
|
||||
|
||||
@@ -8,7 +8,7 @@ This widget has 2 functions:
|
||||
1. Pipelines: checks if the relevant pipeline is running or not, and if not, reports the last status.\
|
||||
Allowed fields: `["result", "status"]`.
|
||||
|
||||
2. Pull Requests: returns the amount of open PRs, the amount of the PRs you have open, and how many PRs that you open are marked as 'Approved' by atleast 1 person and not yet completed.\
|
||||
2. Pull Requests: returns the amount of open PRs, the amount of the PRs you have open, and how many PRs that you open are marked as 'Approved' by at least 1 person and not yet completed.\
|
||||
Allowed fields: `["totalPrs", "myPrs", "approved"]`.
|
||||
|
||||
You will need to generate a personal access token for an existing user, see the [azure documentation](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows#create-a-pat)
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Calendar
|
||||
description: Calendar widget
|
||||
---
|
||||
|
||||
## Monthly view
|
||||
|
||||
<img alt="calendar" src="https://user-images.githubusercontent.com/5442891/271131282-6767a3ea-573e-4005-aeb9-6e14ee01e845.png">
|
||||
|
||||
This widget shows monthly calendar, with optional integrations to show events from supported widgets.
|
||||
@@ -11,6 +13,8 @@ This widget shows monthly calendar, with optional integrations to show events fr
|
||||
widget:
|
||||
type: calendar
|
||||
firstDayInWeek: sunday # optional - defaults to monday
|
||||
view: monthly # optional - possible values monthly, agenda
|
||||
maxEvents: 10 # optional - defaults to 10
|
||||
integrations: # optional
|
||||
- type: sonarr # active widget type that is currently enabled on homepage - possible values: radarr, sonarr, lidarr, readarr
|
||||
service_group: Media # group name where widget exists
|
||||
@@ -20,6 +24,20 @@ widget:
|
||||
unmonitored: true # optional - defaults to false, used with *arr stack
|
||||
```
|
||||
|
||||
## Agenda
|
||||
|
||||
This view shows only list of events from configured integrations
|
||||
|
||||
```yaml
|
||||
widget:
|
||||
type: calendar
|
||||
view: agenda
|
||||
maxEvents: 10 # optional - defaults to 10
|
||||
integrations: # same as in Monthly view example
|
||||
```
|
||||
|
||||
## Integrations
|
||||
|
||||
Currently integrated widgets are [sonarr](sonarr.md), [radarr](radarr.md), [lidarr](lidarr.md) and [readarr](readarr.md).
|
||||
|
||||
Supported colors can be found on [color palette](../../configs/settings.md#color-palette).
|
||||
|
||||
@@ -31,9 +31,13 @@ widget:
|
||||
another: key3
|
||||
label: Field 3
|
||||
format: percent # optional - defaults to text
|
||||
- field: key # needs to be YAML string or object
|
||||
label: Field 4
|
||||
format: date # optional - defaults to text
|
||||
dateStyle: long # optional - defaults to "long". Allowed values: `["full", "long", "medium", "short"]`.
|
||||
```
|
||||
|
||||
Supported formats for the values are `text`, `number`, `float`, `percent`, `bytes` and `bitrate`.
|
||||
Supported formats for the values are `text`, `number`, `float`, `percent`, `bytes`, `bitrate` and `date`.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -60,7 +64,7 @@ For the following JSON object from the API:
|
||||
}
|
||||
```
|
||||
|
||||
Define the `mappings` section as an aray, for example:
|
||||
Define the `mappings` section as an array, for example:
|
||||
|
||||
```yaml
|
||||
mappings:
|
||||
|
||||
@@ -18,7 +18,7 @@ widget:
|
||||
metric: cpu
|
||||
```
|
||||
|
||||
_Please note, this widget does not need an `href`, `icon` or `description` on its parent service. To achive the same effect as the examples above, see as an example:_
|
||||
_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:_
|
||||
|
||||
```yaml
|
||||
- CPU Usage:
|
||||
@@ -45,15 +45,15 @@ The metric field in the configuration determines the type of system monitoring d
|
||||
|
||||
`process`: Top 5 processes based on CPU usage. Gives an overview of which processes are consuming the most resources.
|
||||
|
||||
`network:<interface_name>`: Network data usage for the specified interface. Replace `<interface_name>` with the name of your network interface, e.g., `network:enp0s25`, as specificed in glances.
|
||||
`network:<interface_name>`: Network data usage for the specified interface. Replace `<interface_name>` with the name of your network interface, e.g., `network:enp0s25`, as specified in glances.
|
||||
|
||||
`sensor:<sensor_id>`: Temperature of the specified sensor, typically used to monitor CPU temperature. Replace `<sensor_id>` with the name of your sensor, e.g., `sensor:Package id 0` as specificed in glances.
|
||||
`sensor:<sensor_id>`: Temperature of the specified sensor, typically used to monitor CPU temperature. Replace `<sensor_id>` with the name of your sensor, e.g., `sensor:Package id 0` as specified in glances.
|
||||
|
||||
`disk:<disk_id>`: Disk I/O data for the specified disk. Replace `<disk_id>` with the id of your disk, e.g., `disk:sdb`, as specificed in glances.
|
||||
`disk:<disk_id>`: Disk I/O data for the specified disk. Replace `<disk_id>` with the id of your disk, e.g., `disk:sdb`, as specified in glances.
|
||||
|
||||
`gpu:<gpu_id>`: GPU usage for the specified GPU. Replace `<gpu_id>` with the id of your GPU, e.g., `gpu:0`, as specificed in glances.
|
||||
`gpu:<gpu_id>`: GPU usage for the specified GPU. Replace `<gpu_id>` with the id of your GPU, e.g., `gpu:0`, as specified in glances.
|
||||
|
||||
`fs:<mnt_point>`: Disk usage for the specified mount point. Replace `<mnt_point>` with the path of your disk, e.g., `/mnt/storage`, as specificed in glances.
|
||||
`fs:<mnt_point>`: Disk usage for the specified mount point. Replace `<mnt_point>` with the path of your disk, e.g., `/mnt/storage`, as specified in glances.
|
||||
|
||||
## Views
|
||||
|
||||
@@ -61,7 +61,7 @@ All widgets offer an alternative to the full or "graph" view, which is the compa
|
||||
|
||||
<img width="970" alt="Screenshot 2023-09-06 at 1 51 48 PM" src="https://github-production-user-asset-6210df.s3.amazonaws.com/82196/265985295-cc6b9adc-4218-4274-96ca-36c3e64de5d0.png">
|
||||
|
||||
To switch to the alternative "graphless" view, simply passs `chart: false` as an option to the widget, like so:
|
||||
To switch to the alternative "graphless" view, simply pass `chart: false` as an option to the widget, like so:
|
||||
|
||||
```yaml
|
||||
- Network Usage:
|
||||
|
||||
35
docs/widgets/services/iframe.md
Normal file
35
docs/widgets/services/iframe.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: iFrame
|
||||
Description: Add a custom iFrame Widget
|
||||
---
|
||||
|
||||
A basic iFrame widget to show external content, see the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) for more details about some of the options.
|
||||
|
||||
!!! warning
|
||||
|
||||
Requests made via the iFrame widget are inherently **not proxied** as they are made from the browser itself.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```yaml
|
||||
widget:
|
||||
type: iframe
|
||||
name: myIframe
|
||||
src: http://example.com
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
```yaml
|
||||
widget:
|
||||
type: iframe
|
||||
name: myIframe
|
||||
src: http://example.com
|
||||
classes: h-60 sm:h-60 md:h-60 lg:h-60 xl:h-60 2xl:h-72 # optional, use tailwind height classes, see https://tailwindcss.com/docs/height
|
||||
referrerPolicy: same-origin # optional, no default
|
||||
allowPolicy: autoplay fullscreen gamepad # optional, no default
|
||||
allowFullscreen: false # optional, default: true
|
||||
loadingStrategy: eager # optional, default: eager
|
||||
allowScrolling: no # optional, default: yes
|
||||
refreshInterval: 2000 # optional, no default
|
||||
```
|
||||
@@ -14,4 +14,4 @@ widget:
|
||||
key: yourpiholeapikey # optional
|
||||
```
|
||||
|
||||
_Added in v0.1.0, udpated in v0.6.18_
|
||||
_Added in v0.1.0, updated in v0.6.18_
|
||||
|
||||
@@ -63,6 +63,7 @@ nav:
|
||||
- widgets/services/healthchecks.md
|
||||
- widgets/services/homeassistant.md
|
||||
- widgets/services/homebridge.md
|
||||
- widgets/services/iframe.md
|
||||
- widgets/services/immich.md
|
||||
- widgets/services/jackett.md
|
||||
- widgets/services/jdownloader.md
|
||||
|
||||
@@ -123,6 +123,9 @@ module.exports = {
|
||||
i18next.services.formatter.add("percent", (value, lng, options) =>
|
||||
new Intl.NumberFormat(lng, { style: "percent", ...options }).format(parseFloat(value) / 100.0),
|
||||
);
|
||||
i18next.services.formatter.add("date", (value, lng, options) =>
|
||||
new Intl.DateTimeFormat(lng, { ...options }).format(new Date(value)),
|
||||
);
|
||||
},
|
||||
type: "3rdParty",
|
||||
},
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"minecraft-ping-js": "^1.0.2",
|
||||
"next": "^12.3.1",
|
||||
"next-i18next": "^12.0.1",
|
||||
"ping": "^0.4.4",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"raw-body": "^2.5.1",
|
||||
"react": "^18.2.0",
|
||||
@@ -4861,6 +4862,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ping": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/ping/-/ping-0.4.4.tgz",
|
||||
"integrity": "sha512-56ZMC0j7SCsMMLdOoUg12VZCfj/+ZO+yfOSjaNCRrmZZr6GLbN2X/Ui56T15dI8NhiHckaw5X2pvyfAomanwqQ==",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"minecraft-ping-js": "^1.0.2",
|
||||
"next": "^12.3.1",
|
||||
"next-i18next": "^12.0.1",
|
||||
"ping": "^0.4.4",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"raw-body": "^2.5.1",
|
||||
"react": "^18.2.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -50,6 +50,9 @@ dependencies:
|
||||
next-i18next:
|
||||
specifier: ^12.0.1
|
||||
version: 12.1.0(next@12.3.4)(react-dom@18.2.0)(react@18.2.0)
|
||||
ping:
|
||||
specifier: ^0.4.4
|
||||
version: 0.4.4
|
||||
pretty-bytes:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.0
|
||||
@@ -3103,6 +3106,11 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/ping@0.4.4:
|
||||
resolution: {integrity: sha512-56ZMC0j7SCsMMLdOoUg12VZCfj/+ZO+yfOSjaNCRrmZZr6GLbN2X/Ui56T15dI8NhiHckaw5X2pvyfAomanwqQ==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
dev: false
|
||||
|
||||
/pirates@4.0.5:
|
||||
resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"bibitrate": "{{value, rate(bits: true; binary: true)}}",
|
||||
"percent": "{{value, percent}}",
|
||||
"number": "{{value, number}}",
|
||||
"ms": "{{value, number}}"
|
||||
"ms": "{{value, number}}",
|
||||
"date": "{{value, date}}"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Missing Widget Type: {{type}}",
|
||||
@@ -79,13 +80,20 @@
|
||||
"partial": "Partial"
|
||||
},
|
||||
"ping": {
|
||||
"http_status": "HTTP status",
|
||||
"error": "Error",
|
||||
"ping": "Ping",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
"not_available": "Not Available"
|
||||
},
|
||||
"siteMonitor": {
|
||||
"http_status": "HTTP status",
|
||||
"error": "Error",
|
||||
"response": "Response",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
"not_available": "Not Available"
|
||||
},
|
||||
"emby": {
|
||||
"playing": "Playing",
|
||||
"transcoding": "Transcoding",
|
||||
@@ -756,6 +764,7 @@
|
||||
"calendar": {
|
||||
"inCinemas": "In cinemas",
|
||||
"physicalRelease": "Physical release",
|
||||
"digitalRelease": "Digital release"
|
||||
"digitalRelease": "Digital release",
|
||||
"noEventsToday": "No events for today!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useContext, useState } from "react";
|
||||
import Status from "./status";
|
||||
import Widget from "./widget";
|
||||
import Ping from "./ping";
|
||||
import SiteMonitor from "./site-monitor";
|
||||
import KubernetesStatus from "./kubernetes-status";
|
||||
|
||||
import Docker from "widgets/docker/component";
|
||||
@@ -93,6 +94,13 @@ export default function Item({ service, group }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.siteMonitor && (
|
||||
<div className="flex-shrink-0 flex items-center justify-center service-tag service-site-monitor">
|
||||
<SiteMonitor group={group} service={service.name} style={statusStyle} />
|
||||
<span className="sr-only">Site monitor status</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.container && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function Ping({ group, service, style }) {
|
||||
|
||||
let colorClass = "text-black/20 dark:text-white/40 opacity-20";
|
||||
let backgroundClass = "bg-theme-500/10 dark:bg-theme-900/50 px-1.5 py-0.5";
|
||||
let statusTitle = t("ping.http_status");
|
||||
let statusTitle = t("ping.ping");
|
||||
let statusText = "";
|
||||
|
||||
if (error) {
|
||||
@@ -19,18 +19,13 @@ export default function Ping({ group, service, style }) {
|
||||
} else if (!data) {
|
||||
statusText = t("ping.ping");
|
||||
statusTitle += ` ${t("ping.not_available")}`;
|
||||
} else if (data.status > 403) {
|
||||
} else if (!data.alive) {
|
||||
colorClass = "text-rose-500/80";
|
||||
statusTitle += ` ${data.status}`;
|
||||
|
||||
if (style === "basic") {
|
||||
statusText = t("ping.down");
|
||||
} else {
|
||||
statusText = data.status;
|
||||
}
|
||||
} else if (data) {
|
||||
const ping = t("common.ms", { value: data.latency, style: "unit", unit: "millisecond", maximumFractionDigits: 0 });
|
||||
statusTitle += ` ${data.status} (${ping})`;
|
||||
statusTitle += ` ${t("ping.down")}`;
|
||||
statusText = t("ping.down");
|
||||
} else if (data.alive) {
|
||||
const ping = t("common.ms", { value: data.time, style: "unit", unit: "millisecond", maximumFractionDigits: 0 });
|
||||
statusTitle += ` ${t("ping.up")} (${ping})`;
|
||||
colorClass = "text-emerald-500/80";
|
||||
|
||||
if (style === "basic") {
|
||||
|
||||
63
src/components/services/site-monitor.jsx
Normal file
63
src/components/services/site-monitor.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function SiteMonitor({ group, service, style }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, error } = useSWR(`/api/siteMonitor?${new URLSearchParams({ group, service }).toString()}`, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
let colorClass = "text-black/20 dark:text-white/40 opacity-20";
|
||||
let backgroundClass = "bg-theme-500/10 dark:bg-theme-900/50 px-1.5 py-0.5";
|
||||
let statusTitle = t("siteMonitor.http_status");
|
||||
let statusText = "";
|
||||
|
||||
if (error) {
|
||||
colorClass = "text-rose-500";
|
||||
statusText = t("siteMonitor.error");
|
||||
statusTitle += ` ${t("siteMonitor.error")}`;
|
||||
} else if (!data) {
|
||||
statusText = t("siteMonitor.response");
|
||||
statusTitle += ` ${t("siteMonitor.not_available")}`;
|
||||
} else if (data.status > 403) {
|
||||
colorClass = "text-rose-500/80";
|
||||
statusTitle += ` ${data.status}`;
|
||||
|
||||
if (style === "basic") {
|
||||
statusText = t("siteMonitor.down");
|
||||
} else {
|
||||
statusText = data.status;
|
||||
}
|
||||
} else if (data) {
|
||||
const responseTime = t("common.ms", {
|
||||
value: data.latency,
|
||||
style: "unit",
|
||||
unit: "millisecond",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
statusTitle += ` ${data.status} (${responseTime})`;
|
||||
colorClass = "text-emerald-500/80";
|
||||
|
||||
if (style === "basic") {
|
||||
statusText = t("siteMonitor.up");
|
||||
} else {
|
||||
statusText = responseTime;
|
||||
colorClass += " lowercase";
|
||||
}
|
||||
}
|
||||
|
||||
if (style === "dot") {
|
||||
backgroundClass = "p-4";
|
||||
colorClass = colorClass.replace(/text-/g, "bg-").replace(/\/\d\d/g, "");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-auto text-center rounded-b-[3px] overflow-hidden site-monitor-status ${backgroundClass}`}
|
||||
title={statusTitle}
|
||||
>
|
||||
{style !== "dot" && <div className={`font-bold uppercase text-[8px] ${colorClass}`}>{statusText}</div>}
|
||||
{style === "dot" && <div className={`rounded-full h-3 w-3 ${colorClass}`} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function Tab({ tab }) {
|
||||
<li
|
||||
key={tab}
|
||||
role="presentation"
|
||||
className={classNames("text-theme-700 dark:text-theme-200 relative h-8 w-full rounded-md flex m-1")}
|
||||
className={classNames("text-theme-700 dark:text-theme-200 relative h-10 w-full rounded-md flex")}
|
||||
>
|
||||
<button
|
||||
id={`${tab}-tab`}
|
||||
@@ -23,7 +23,7 @@ export default function Tab({ tab }) {
|
||||
aria-controls={`#${tab}`}
|
||||
aria-selected={activeTab === slugify(tab) ? "true" : "false"}
|
||||
className={classNames(
|
||||
"h-full w-full rounded-md",
|
||||
"w-full rounded-md m-1",
|
||||
activeTab === slugify(tab)
|
||||
? "bg-theme-300/20 dark:bg-white/10"
|
||||
: "hover:bg-theme-100/20 dark:hover:bg-white/5",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ColorProvider } from "utils/contexts/color";
|
||||
import { ThemeProvider } from "utils/contexts/theme";
|
||||
import { SettingsProvider } from "utils/contexts/settings";
|
||||
import { TabProvider } from "utils/contexts/tab";
|
||||
import { EventProvider, ShowDateProvider } from "utils/contexts/calendar";
|
||||
import { EventProvider } from "utils/contexts/calendar";
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
return (
|
||||
@@ -33,9 +33,7 @@ function MyApp({ Component, pageProps }) {
|
||||
<SettingsProvider>
|
||||
<TabProvider>
|
||||
<EventProvider>
|
||||
<ShowDateProvider>
|
||||
<Component {...pageProps} />
|
||||
</ShowDateProvider>
|
||||
<Component {...pageProps} />
|
||||
</EventProvider>
|
||||
</TabProvider>
|
||||
</SettingsProvider>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { performance } from "perf_hooks";
|
||||
import { promise as ping } from "ping";
|
||||
|
||||
import { getServiceItem } from "utils/config/service-helpers";
|
||||
import createLogger from "utils/logger";
|
||||
import { httpProxy } from "utils/proxy/http";
|
||||
|
||||
const logger = createLogger("ping");
|
||||
|
||||
@@ -16,35 +15,28 @@ export default async function handler(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
const { ping: pingURL } = serviceItem;
|
||||
const { ping: pingHostOrURL } = serviceItem;
|
||||
|
||||
if (!pingURL) {
|
||||
logger.debug("No ping URL specified");
|
||||
if (!pingHostOrURL) {
|
||||
logger.debug("No ping host specified");
|
||||
return res.status(400).send({
|
||||
error: "No ping URL given",
|
||||
error: "No ping host given",
|
||||
});
|
||||
}
|
||||
|
||||
let hostname = pingHostOrURL;
|
||||
try {
|
||||
let startTime = performance.now();
|
||||
let [status] = await httpProxy(pingURL, {
|
||||
method: "HEAD",
|
||||
});
|
||||
let endTime = performance.now();
|
||||
|
||||
if (status > 403) {
|
||||
// try one more time as a GET in case HEAD is rejected for whatever reason
|
||||
startTime = performance.now();
|
||||
[status] = await httpProxy(pingURL);
|
||||
endTime = performance.now();
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
status,
|
||||
latency: endTime - startTime,
|
||||
});
|
||||
// maintain backwards compatibility with old ping where may be http://...
|
||||
hostname = new URL(pingHostOrURL).hostname;
|
||||
} catch (e) {
|
||||
logger.debug("Error attempting ping: %s", JSON.stringify(e));
|
||||
// eslint-disable-line no-empty
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ping.probe(hostname);
|
||||
return res.status(200).json(response);
|
||||
} catch (e) {
|
||||
logger.debug("Error attempting ping: %s", e);
|
||||
return res.status(400).send({
|
||||
error: "Error attempting ping, see logs.",
|
||||
});
|
||||
|
||||
52
src/pages/api/siteMonitor.js
Normal file
52
src/pages/api/siteMonitor.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { performance } from "perf_hooks";
|
||||
|
||||
import { getServiceItem } from "utils/config/service-helpers";
|
||||
import createLogger from "utils/logger";
|
||||
import { httpProxy } from "utils/proxy/http";
|
||||
|
||||
const logger = createLogger("siteMonitor");
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const { group, service } = req.query;
|
||||
const serviceItem = await getServiceItem(group, service);
|
||||
if (!serviceItem) {
|
||||
logger.debug(`No service item found for group ${group} named ${service}`);
|
||||
return res.status(400).send({
|
||||
error: "Unable to find service, see log for details.",
|
||||
});
|
||||
}
|
||||
|
||||
const { siteMonitor: monitorURL } = serviceItem;
|
||||
|
||||
if (!monitorURL) {
|
||||
logger.debug("No http monitor URL specified");
|
||||
return res.status(400).send({
|
||||
error: "No http monitor URL given",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let startTime = performance.now();
|
||||
let [status] = await httpProxy(monitorURL, {
|
||||
method: "HEAD",
|
||||
});
|
||||
let endTime = performance.now();
|
||||
|
||||
if (status > 403) {
|
||||
// try one more time as a GET in case HEAD is rejected for whatever reason
|
||||
startTime = performance.now();
|
||||
[status] = await httpProxy(monitorURL);
|
||||
endTime = performance.now();
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
status,
|
||||
latency: endTime - startTime,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug("Error attempting http monitor: %s", e);
|
||||
return res.status(400).send({
|
||||
error: "Error attempting http monitor, see logs.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,33 @@
|
||||
.theme-white {
|
||||
--color-50: 255 255 255;
|
||||
--color-100: 255 255 255;
|
||||
--color-200: 255 255 255;
|
||||
--color-300: 255 255 255;
|
||||
--color-400: 255 255 255;
|
||||
--color-100: 120 120 120;
|
||||
--color-200: 120 120 120;
|
||||
--color-300: 120 120 120;
|
||||
--color-400: 120 120 120;
|
||||
--color-500: 60 60 60;
|
||||
--color-600: 255 255 255;
|
||||
--color-600: 120 120 120;
|
||||
--color-700: 40 40 40;
|
||||
--color-800: 255 255 255;
|
||||
--color-900: 255 255 255;
|
||||
--color-900: 120 120 120;
|
||||
|
||||
--color-logo-start: 128 128 128 / 20%;
|
||||
--color-logo-stop: 128 128 128 / 40%;
|
||||
}
|
||||
|
||||
.theme-white .bg-theme-100\/20,
|
||||
.theme-white .dark\:bg-white\/5 {
|
||||
background-color: rgb(245, 245, 245);
|
||||
}
|
||||
|
||||
.theme-white .bg-theme-100\/20:hover,
|
||||
.theme-white .dark\:bg-white\/5:hover {
|
||||
background-color: rgb(250, 250, 250);
|
||||
}
|
||||
|
||||
.theme-white .text-theme-800 {
|
||||
color: rgb(120, 120, 120);
|
||||
}
|
||||
|
||||
.theme-slate {
|
||||
--color-50: 248 250 252;
|
||||
--color-100: 241 245 249;
|
||||
|
||||
@@ -258,6 +258,9 @@ export async function servicesFromKubernetes() {
|
||||
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/ping`]) {
|
||||
constructedService.ping = ingress.metadata.annotations[`${ANNOTATION_BASE}/ping`];
|
||||
}
|
||||
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]) {
|
||||
constructedService.siteMonitor = ingress.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`];
|
||||
}
|
||||
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]) {
|
||||
constructedService.statusStyle = ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`];
|
||||
}
|
||||
@@ -361,6 +364,15 @@ export function cleanServiceGroups(groups) {
|
||||
refreshInterval,
|
||||
integrations, // calendar widget
|
||||
firstDayInWeek,
|
||||
view,
|
||||
maxEvents,
|
||||
src, // iframe widget
|
||||
classes,
|
||||
referrerPolicy,
|
||||
allowPolicy,
|
||||
allowFullscreen,
|
||||
loadingStrategy,
|
||||
allowScrolling,
|
||||
} = cleanedService.widget;
|
||||
|
||||
let fieldsList = fields;
|
||||
@@ -408,6 +420,16 @@ export function cleanServiceGroups(groups) {
|
||||
if (app) cleanedService.widget.app = app;
|
||||
if (podSelector) cleanedService.widget.podSelector = podSelector;
|
||||
}
|
||||
if (type === "iframe") {
|
||||
if (src) cleanedService.widget.src = src;
|
||||
if (classes) cleanedService.widget.classes = classes;
|
||||
if (referrerPolicy) cleanedService.widget.referrerPolicy = referrerPolicy;
|
||||
if (allowPolicy) cleanedService.widget.allowPolicy = allowPolicy;
|
||||
if (allowFullscreen) cleanedService.widget.allowFullscreen = allowFullscreen;
|
||||
if (loadingStrategy) cleanedService.widget.loadingStrategy = loadingStrategy;
|
||||
if (allowScrolling) cleanedService.widget.allowScrolling = allowScrolling;
|
||||
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
|
||||
}
|
||||
if (["opnsense", "pfsense"].includes(type)) {
|
||||
if (wan) cleanedService.widget.wan = wan;
|
||||
}
|
||||
@@ -447,6 +469,8 @@ export function cleanServiceGroups(groups) {
|
||||
if (type === "calendar") {
|
||||
if (integrations) cleanedService.widget.integrations = integrations;
|
||||
if (firstDayInWeek) cleanedService.widget.firstDayInWeek = firstDayInWeek;
|
||||
if (view) cleanedService.widget.view = view;
|
||||
if (maxEvents) cleanedService.widget.maxEvents = maxEvents;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createContext, useState, useMemo } from "react";
|
||||
|
||||
export const EventContext = createContext();
|
||||
export const ShowDateContext = createContext();
|
||||
|
||||
export function EventProvider({ initialEvent, children }) {
|
||||
const [events, setEvents] = useState({});
|
||||
@@ -14,15 +13,3 @@ export function EventProvider({ initialEvent, children }) {
|
||||
|
||||
return <EventContext.Provider value={value}>{children}</EventContext.Provider>;
|
||||
}
|
||||
|
||||
export function ShowDateProvider({ initialDate, children }) {
|
||||
const [showDate, setShowDate] = useState(null);
|
||||
|
||||
if (initialDate) {
|
||||
setShowDate(initialDate);
|
||||
}
|
||||
|
||||
const value = useMemo(() => ({ showDate, setShowDate }), [showDate]);
|
||||
|
||||
return <ShowDateContext.Provider value={value}>{children}</ShowDateContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -85,16 +85,20 @@ export async function httpProxy(url, params = {}) {
|
||||
|
||||
let request = null;
|
||||
if (constructedUrl.protocol === "https:") {
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
request = httpsRequest(constructedUrl, {
|
||||
agent: httpsAgent,
|
||||
agent: new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
autoSelectFamily: true,
|
||||
}),
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
request = httpRequest(constructedUrl, params);
|
||||
request = httpRequest(constructedUrl, {
|
||||
agent: new http.Agent({
|
||||
autoSelectFamily: true,
|
||||
}),
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ const widget = {
|
||||
|
||||
mappings: {
|
||||
users: {
|
||||
endpoint: "core/users?page_size=1",
|
||||
endpoint: "core/users/?page_size=1",
|
||||
},
|
||||
login: {
|
||||
endpoint: "events/events/per_month/?action=login",
|
||||
|
||||
100
src/widgets/calendar/agenda.jsx
Normal file
100
src/widgets/calendar/agenda.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { DateTime } from "luxon";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
|
||||
|
||||
import { EventContext } from "../../utils/contexts/calendar";
|
||||
|
||||
export function Event({ event, colorVariants, showDate = false }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs text-left h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
|
||||
onMouseEnter={() => setHover(!hover)}
|
||||
onMouseLeave={() => setHover(!hover)}
|
||||
>
|
||||
<span className="ml-2 w-10">
|
||||
<span>
|
||||
{showDate &&
|
||||
event.date.setLocale(i18n.language).startOf("day").toLocaleString({ month: "short", day: "numeric" })}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 h-2 w-2">
|
||||
<span className={classNames("block w-2 h-2 rounded", colorVariants[event.color] ?? "gray")} />
|
||||
</span>
|
||||
<div className="ml-2 h-5 text-left relative truncate" style={{ width: "70%" }}>
|
||||
<div className="absolute mt-0.5 text-xs">{hover && event.additional ? event.additional : event.title}</div>
|
||||
</div>
|
||||
{event.isCompleted && (
|
||||
<span className="text-xs mr-1 ml-auto z-10">
|
||||
<IoMdCheckmarkCircleOutline />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Agenda({ service, colorVariants, showDate }) {
|
||||
const { widget } = service;
|
||||
const { events } = useContext(EventContext);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showDate) {
|
||||
return <div className=" text-center" />;
|
||||
}
|
||||
|
||||
const eventsArray = Object.keys(events)
|
||||
.filter(
|
||||
(eventKey) => showDate.startOf("day").toUnixInteger() <= events[eventKey].date?.startOf("day").toUnixInteger(),
|
||||
)
|
||||
.map((eventKey) => events[eventKey])
|
||||
.sort((a, b) => a.date - b.date)
|
||||
.slice(0, widget?.maxEvents ?? 10);
|
||||
|
||||
if (!eventsArray.length) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="p-2 ">
|
||||
<div
|
||||
className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}
|
||||
>
|
||||
<Event
|
||||
key="no-event"
|
||||
event={{
|
||||
title: t("calendar.noEventsToday"),
|
||||
date: DateTime.now(),
|
||||
color: "gray",
|
||||
}}
|
||||
colorVariants={colorVariants}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const days = Array.from(new Set(eventsArray.map((e) => e.date.startOf("day").ts)));
|
||||
const eventsByDay = days.map((d) => eventsArray.filter((e) => e.date.startOf("day").ts === d));
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className={classNames("flex flex-col pt-1 pb-1", !eventsArray.length && !events.length && "animate-pulse")}>
|
||||
{eventsByDay.map((eventsDay, i) => (
|
||||
<div key={days[i]}>
|
||||
{eventsDay.map((event, j) => (
|
||||
<Event
|
||||
key={`event${event.title}-${event.date}`}
|
||||
event={event}
|
||||
colorVariants={colorVariants}
|
||||
showDate={j === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,53 @@
|
||||
import { useContext, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState, useContext } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DateTime } from "luxon";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
import { ShowDateContext } from "../../utils/contexts/calendar";
|
||||
|
||||
import MonthlyView from "./monthly-view";
|
||||
import Monthly from "./monthly";
|
||||
import Agenda from "./agenda";
|
||||
|
||||
import Container from "components/services/widget/container";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
const colorVariants = {
|
||||
// https://tailwindcss.com/docs/content-configuration#dynamic-class-names
|
||||
amber: "bg-amber-500",
|
||||
blue: "bg-blue-500",
|
||||
cyan: "bg-cyan-500",
|
||||
emerald: "bg-emerald-500",
|
||||
fuchsia: "bg-fuchsia-500",
|
||||
gray: "bg-gray-500",
|
||||
green: "bg-green-500",
|
||||
indigo: "bg-indigo-500",
|
||||
lime: "bg-lime-500",
|
||||
neutral: "bg-neutral-500",
|
||||
orange: "bg-orange-500",
|
||||
pink: "bg-pink-500",
|
||||
purple: "bg-purple-500",
|
||||
red: "bg-red-500",
|
||||
rose: "bg-rose-500",
|
||||
sky: "bg-sky-500",
|
||||
slate: "bg-slate-500",
|
||||
stone: "bg-stone-500",
|
||||
teal: "bg-teal-500",
|
||||
violet: "bg-violet-500",
|
||||
white: "bg-white-500",
|
||||
yellow: "bg-yellow-500",
|
||||
zinc: "bg-zinc-500",
|
||||
};
|
||||
|
||||
export default function Component({ service }) {
|
||||
const { widget } = service;
|
||||
const { showDate } = useContext(ShowDateContext);
|
||||
const { i18n } = useTranslation();
|
||||
const [showDate, setShowDate] = useState(null);
|
||||
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
|
||||
const { settings } = useContext(SettingsContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDate) {
|
||||
setShowDate(currentDate);
|
||||
}
|
||||
}, [showDate, currentDate]);
|
||||
|
||||
// params for API fetch
|
||||
const params = useMemo(() => {
|
||||
@@ -27,10 +65,12 @@ export default function Component({ service }) {
|
||||
// Load active integrations
|
||||
const integrations = useMemo(
|
||||
() =>
|
||||
widget.integrations?.map((integration) => ({
|
||||
service: dynamic(() => import(`./integrations/${integration?.type}`)),
|
||||
widget: integration,
|
||||
})) ?? [],
|
||||
widget.integrations
|
||||
?.filter((integration) => integration?.type)
|
||||
.map((integration) => ({
|
||||
service: dynamic(() => import(`./integrations/${integration.type}`)),
|
||||
widget: integration,
|
||||
})) ?? [],
|
||||
[widget.integrations],
|
||||
);
|
||||
|
||||
@@ -47,12 +87,30 @@ export default function Component({ service }) {
|
||||
key={key}
|
||||
config={integration.widget}
|
||||
params={params}
|
||||
hideErrors={settings.hideErrors}
|
||||
className="fixed bottom-0 left-0 bg-red-500 w-screen h-12"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MonthlyView service={service} className="flex" />
|
||||
{(!widget?.view || widget?.view === "monthly") && (
|
||||
<Monthly
|
||||
service={service}
|
||||
colorVariants={colorVariants}
|
||||
showDate={showDate}
|
||||
setShowDate={setShowDate}
|
||||
className="flex"
|
||||
/>
|
||||
)}
|
||||
{widget?.view === "agenda" && (
|
||||
<Agenda
|
||||
service={service}
|
||||
colorVariants={colorVariants}
|
||||
showDate={showDate}
|
||||
setShowDate={setShowDate}
|
||||
className="flex"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||
import { EventContext } from "../../../utils/contexts/calendar";
|
||||
import Error from "../../../components/services/widget/error";
|
||||
|
||||
export default function Integration({ config, params }) {
|
||||
export default function Integration({ config, params, hideErrors = false }) {
|
||||
const { setEvents } = useContext(EventContext);
|
||||
const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, "calendar", {
|
||||
...params,
|
||||
@@ -27,6 +27,8 @@ export default function Integration({ config, params }) {
|
||||
title,
|
||||
date: DateTime.fromISO(event.releaseDate),
|
||||
color: config?.color ?? "green",
|
||||
isCompleted: event.grabbed,
|
||||
additional: "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -34,5 +36,5 @@ export default function Integration({ config, params }) {
|
||||
}, [lidarrData, lidarrError, config, setEvents]);
|
||||
|
||||
const error = lidarrError ?? lidarrData?.error;
|
||||
return error && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||
import { EventContext } from "../../../utils/contexts/calendar";
|
||||
import Error from "../../../components/services/widget/error";
|
||||
|
||||
export default function Integration({ config, params }) {
|
||||
export default function Integration({ config, params, hideErrors = false }) {
|
||||
const { t } = useTranslation();
|
||||
const { setEvents } = useContext(EventContext);
|
||||
const { data: radarrData, error: radarrError } = useWidgetAPI(config, "calendar", {
|
||||
@@ -29,16 +29,22 @@ export default function Integration({ config, params }) {
|
||||
title: cinemaTitle,
|
||||
date: DateTime.fromISO(event.inCinemas),
|
||||
color: config?.color ?? "amber",
|
||||
isCompleted: event.isAvailable,
|
||||
additional: "",
|
||||
};
|
||||
eventsToAdd[physicalTitle] = {
|
||||
title: physicalTitle,
|
||||
date: DateTime.fromISO(event.physicalRelease),
|
||||
color: config?.color ?? "cyan",
|
||||
isCompleted: event.isAvailable,
|
||||
additional: "",
|
||||
};
|
||||
eventsToAdd[digitalTitle] = {
|
||||
title: digitalTitle,
|
||||
date: DateTime.fromISO(event.digitalRelease),
|
||||
color: config?.color ?? "emerald",
|
||||
isCompleted: event.isAvailable,
|
||||
additional: "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -46,5 +52,5 @@ export default function Integration({ config, params }) {
|
||||
}, [radarrData, radarrError, config, setEvents, t]);
|
||||
|
||||
const error = radarrError ?? radarrData?.error;
|
||||
return error && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||
import { EventContext } from "../../../utils/contexts/calendar";
|
||||
import Error from "../../../components/services/widget/error";
|
||||
|
||||
export default function Integration({ config, params }) {
|
||||
export default function Integration({ config, params, hideErrors = false }) {
|
||||
const { setEvents } = useContext(EventContext);
|
||||
const { data: readarrData, error: readarrError } = useWidgetAPI(config, "calendar", {
|
||||
...params,
|
||||
@@ -28,6 +28,8 @@ export default function Integration({ config, params }) {
|
||||
title,
|
||||
date: DateTime.fromISO(event.releaseDate),
|
||||
color: config?.color ?? "rose",
|
||||
isCompleted: event.grabbed,
|
||||
additional: "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -35,5 +37,5 @@ export default function Integration({ config, params }) {
|
||||
}, [readarrData, readarrError, config, setEvents]);
|
||||
|
||||
const error = readarrError ?? readarrData?.error;
|
||||
return error && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||
import { EventContext } from "../../../utils/contexts/calendar";
|
||||
import Error from "../../../components/services/widget/error";
|
||||
|
||||
export default function Integration({ config, params }) {
|
||||
export default function Integration({ config, params, hideErrors = false }) {
|
||||
const { setEvents } = useContext(EventContext);
|
||||
const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, "calendar", {
|
||||
...params,
|
||||
@@ -26,9 +26,11 @@ export default function Integration({ config, params }) {
|
||||
const title = `${event.series.title ?? event.title} - S${event.seasonNumber}E${event.episodeNumber}`;
|
||||
|
||||
eventsToAdd[title] = {
|
||||
title,
|
||||
title: `${event.series.title ?? event.title}`,
|
||||
date: DateTime.fromISO(event.airDateUtc),
|
||||
color: config?.color ?? "teal",
|
||||
isCompleted: event.hasFile,
|
||||
additional: `S${event.seasonNumber} E${event.episodeNumber}`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,5 +38,5 @@ export default function Integration({ config, params }) {
|
||||
}, [sonarrData, sonarrError, config, setEvents]);
|
||||
|
||||
const error = sonarrError ?? sonarrData?.error;
|
||||
return error && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { DateTime, Info } from "luxon";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
|
||||
|
||||
import { EventContext, ShowDateContext } from "../../utils/contexts/calendar";
|
||||
|
||||
const colorVariants = {
|
||||
// https://tailwindcss.com/docs/content-configuration#dynamic-class-names
|
||||
amber: "bg-amber-500",
|
||||
blue: "bg-blue-500",
|
||||
cyan: "bg-cyan-500",
|
||||
emerald: "bg-emerald-500",
|
||||
fuchsia: "bg-fuchsia-500",
|
||||
gray: "bg-gray-500",
|
||||
green: "bg-green-500",
|
||||
indigo: "bg-indigo-500",
|
||||
lime: "bg-lime-500",
|
||||
neutral: "bg-neutral-500",
|
||||
orange: "bg-orange-500",
|
||||
pink: "bg-pink-500",
|
||||
purple: "bg-purple-500",
|
||||
red: "bg-red-500",
|
||||
rose: "bg-rose-500",
|
||||
sky: "bg-sky-500",
|
||||
slate: "bg-slate-500",
|
||||
stone: "bg-stone-500",
|
||||
teal: "bg-teal-500",
|
||||
violet: "bg-violet-500",
|
||||
white: "bg-white-500",
|
||||
yellow: "bg-yellow-500",
|
||||
zinc: "bg-zinc-500",
|
||||
};
|
||||
import { EventContext } from "../../utils/contexts/calendar";
|
||||
|
||||
const cellStyle = "relative w-10 flex items-center justify-center flex-col";
|
||||
const monthButton = "pl-6 pr-6 ml-2 mr-2 hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer";
|
||||
|
||||
export function Day({ weekNumber, weekday, events }) {
|
||||
export function Day({ weekNumber, weekday, events, colorVariants, showDate, setShowDate }) {
|
||||
const currentDate = DateTime.now();
|
||||
const { showDate, setShowDate } = useContext(ShowDateContext);
|
||||
|
||||
const cellDate = showDate.set({ weekday, weekNumber }).startOf("day");
|
||||
const filteredEvents = events?.filter(
|
||||
@@ -105,7 +78,13 @@ export function Event({ event }) {
|
||||
>
|
||||
<span className="absolute left-2 text-left text-xs mt-[2px] truncate text-ellipsis" style={{ width: "96%" }}>
|
||||
{event.title}
|
||||
{event.additional ? ` - ${event.additional}` : ""}
|
||||
</span>
|
||||
{event.isCompleted && (
|
||||
<span className="text-right text-xs flex justify-end mr-1 mt-1 z-10 ">
|
||||
<IoMdCheckmarkCircleOutline />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,19 +99,12 @@ const dayInWeekId = {
|
||||
sunday: 7,
|
||||
};
|
||||
|
||||
export default function MonthlyView({ service }) {
|
||||
export default function Monthly({ service, colorVariants, showDate, setShowDate }) {
|
||||
const { widget } = service;
|
||||
const { i18n } = useTranslation();
|
||||
const { showDate, setShowDate } = useContext(ShowDateContext);
|
||||
const { events } = useContext(EventContext);
|
||||
const currentDate = DateTime.now().setLocale(i18n.language).startOf("day");
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDate) {
|
||||
setShowDate(currentDate);
|
||||
}
|
||||
});
|
||||
|
||||
const dayNames = Info.weekdays("short", { locale: i18n.language });
|
||||
|
||||
const firstDayInWeekCalendar = widget?.firstDayInWeek ? widget?.firstDayInWeek?.toLowerCase() : "monday";
|
||||
@@ -211,6 +183,9 @@ export default function MonthlyView({ service }) {
|
||||
weekNumber={weekNumber}
|
||||
weekday={dayInWeek}
|
||||
events={eventsArray}
|
||||
colorVariants={colorVariants}
|
||||
showDate={showDate}
|
||||
setShowDate={setShowDate}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
@@ -219,8 +194,9 @@ export default function MonthlyView({ service }) {
|
||||
<div className="flex flex-col pt-1 pb-1">
|
||||
{eventsArray
|
||||
?.filter((event) => showDate.startOf("day").toUnixInteger() === event.date?.startOf("day").toUnixInteger())
|
||||
.slice(0, widget?.maxEvents ?? 10)
|
||||
.map((event) => (
|
||||
<Event key={`event${event.title}`} event={event} />
|
||||
<Event key={`event${event.title}-${event.additional}`} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,6 +15,7 @@ const components = {
|
||||
channelsdvrserver: dynamic(() => import("./channelsdvrserver/component")),
|
||||
cloudflared: dynamic(() => import("./cloudflared/component")),
|
||||
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
|
||||
iframe: dynamic(() => import("./iframe/component")),
|
||||
customapi: dynamic(() => import("./customapi/component")),
|
||||
deluge: dynamic(() => import("./deluge/component")),
|
||||
diskstation: dynamic(() => import("./diskstation/component")),
|
||||
|
||||
@@ -69,6 +69,9 @@ function formatValue(t, mapping, rawValue) {
|
||||
case "bitrate":
|
||||
value = t("common.bitrate", { value });
|
||||
break;
|
||||
case "date":
|
||||
value = t("common.date", { value, dateStyle: mapping?.dateStyle ?? "long" });
|
||||
break;
|
||||
case "text":
|
||||
default:
|
||||
// nothing
|
||||
|
||||
@@ -13,6 +13,6 @@ export function calculateCPUPercent(stats) {
|
||||
export function calculateUsedMemory(stats) {
|
||||
// see https://github.com/docker/cli/blob/dcc161076861177b5eef6cb321722520db3184e7/cli/command/container/stats_helpers.go#L239
|
||||
return (
|
||||
stats.memory_stats.usage - (stats.memory_stats.total_inactive_file ?? stats.memory_stats.stats.inactive_file ?? 0)
|
||||
stats.memory_stats.usage - (stats.memory_stats.total_inactive_file ?? stats.memory_stats.stats?.inactive_file ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ export default function Component({ service }) {
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="downloadstation.leech" value={t("common.number", { value: leech })} />
|
||||
<Block label="downloadstation.download" value={t("common.bitrate", { value: rateDl })} />
|
||||
<Block label="downloadstation.download" value={t("common.byterate", { value: rateDl })} />
|
||||
<Block label="downloadstation.seed" value={t("common.number", { value: completed })} />
|
||||
<Block label="downloadstation.upload" value={t("common.bitrate", { value: rateUl })} />
|
||||
<Block label="downloadstation.upload" value={t("common.byterate", { value: rateUl })} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/widgets/iframe/component.jsx
Normal file
50
src/widgets/iframe/component.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import Container from "components/services/widget/container";
|
||||
|
||||
export default function Component({ service }) {
|
||||
const [refreshTimer, setRefreshTimer] = useState(0);
|
||||
|
||||
const { widget } = service;
|
||||
|
||||
useEffect(() => {
|
||||
if (widget?.refreshInterval) {
|
||||
setInterval(
|
||||
() => setRefreshTimer(refreshTimer + 1),
|
||||
widget?.refreshInterval < 1000 ? 1000 : widget?.refreshInterval,
|
||||
);
|
||||
}
|
||||
}, [refreshTimer, widget?.refreshInterval]);
|
||||
|
||||
const scrollingDisableStyle = widget?.allowScrolling === "no" ? { pointerEvents: "none", overflow: "hidden" } : {};
|
||||
|
||||
const classes = widget?.classes || "h-60 sm:h-60 md:h-60 lg:h-60 xl:h-60 2xl:h-72";
|
||||
|
||||
return (
|
||||
<Container service={service}>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center text-center",
|
||||
"service-block",
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
src={widget?.src}
|
||||
key={`${widget?.name}-${refreshTimer}`}
|
||||
name={widget?.name}
|
||||
title={widget?.name}
|
||||
allow={widget?.allowPolicy}
|
||||
allowFullScreen={widget?.allowfullscreen}
|
||||
referrerPolicy={widget?.referrerPolicy}
|
||||
loading={widget?.loadingStrategy}
|
||||
scrolling={widget?.allowScrolling}
|
||||
style={{
|
||||
scrollingDisableStyle,
|
||||
}}
|
||||
className={`rounded w-full ${classes}`}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
3
src/widgets/iframe/widget.js
Normal file
3
src/widgets/iframe/widget.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const widget = {};
|
||||
|
||||
export default widget;
|
||||
@@ -8,10 +8,13 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
|
||||
const { data: immichData, error: immichError } = useWidgetAPI(widget);
|
||||
const { data: versionData, error: versionError } = useWidgetAPI(widget, "version");
|
||||
// see https://github.com/gethomepage/homepage/issues/2282
|
||||
const endpoint = versionData?.major >= 1 && versionData?.minor > 84 ? "statistics" : "stats";
|
||||
const { data: immichData, error: immichError } = useWidgetAPI(widget, endpoint);
|
||||
|
||||
if (immichError || immichData?.statusCode === 401) {
|
||||
return <Container service={service} error={immichError ?? immichData} />;
|
||||
if (immichError || versionError || immichData?.statusCode === 401) {
|
||||
return <Container service={service} error={immichData ?? immichError ?? versionError} />;
|
||||
}
|
||||
|
||||
if (!immichData) {
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||
|
||||
const widget = {
|
||||
api: "{url}/api/server-info/stats",
|
||||
api: "{url}/api/server-info/{endpoint}",
|
||||
proxyHandler: credentialedProxyHandler,
|
||||
|
||||
mappings: {
|
||||
version: {
|
||||
endpoint: "version",
|
||||
},
|
||||
statistics: {
|
||||
endpoint: "statistics",
|
||||
},
|
||||
stats: {
|
||||
endpoint: "stats",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default widget;
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Component({ service }) {
|
||||
const printingStateFalgs = ["Printing", "Paused", "Pausing", "Resuming"];
|
||||
|
||||
if (printingStateFalgs.includes(state)) {
|
||||
const { completion } = jobStats?.progress ?? undefined;
|
||||
const completion = jobStats?.progress?.completion;
|
||||
|
||||
if (!jobStats || !completion) {
|
||||
return (
|
||||
|
||||
@@ -81,8 +81,12 @@ export default async function unifiProxyHandler(req, res) {
|
||||
[status, contentType, data, responseHeaders] = await httpProxy(widget.url);
|
||||
prefix = "";
|
||||
if (responseHeaders?.["x-csrf-token"]) {
|
||||
// Unifi OS < 3.2.5 passes & requires csrf-token
|
||||
prefix = udmpPrefix;
|
||||
csrfToken = responseHeaders["x-csrf-token"];
|
||||
} else if (responseHeaders?.["access-control-expose-headers"]) {
|
||||
// Unifi OS ≥ 3.2.5 doesnt pass csrf token but still uses different endpoint
|
||||
prefix = udmpPrefix;
|
||||
}
|
||||
cache.put(`${prefixCacheKey}.${service}`, prefix);
|
||||
}
|
||||
|
||||
@@ -76,5 +76,9 @@ module.exports = {
|
||||
"dark:bg-white",
|
||||
"bg-orange-400",
|
||||
"dark:bg-orange-400",
|
||||
{
|
||||
pattern: /h-([0-96])/,
|
||||
variants: ["sm", "md", "lg", "xl", "2xl"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user