Compare commits

...

21 Commits

Author SHA1 Message Date
shamoon
b019295a06 Update services.md 2023-11-08 22:39:23 -08:00
shamoon
5fa790e9fe Use old mike 2023-11-08 22:26:17 -08:00
shamoon
7719ea17be Fix: handle immich v1.85.0 API stats breaking change (#2284) 2023-11-07 23:59:43 -08:00
shamoon
885a4051f3 Fix: support Unifi widget with Unifi OS v3.2.5+ (#2281) 2023-11-07 14:38:26 -08:00
Ingmar Delsink
67f5ee8df5 Fix: Access container memory_stats.stats safely by optional chaining (#2271) (#2272) 2023-11-05 07:39:26 -08:00
Reiss Cashmore
ebd384c62d Feature: iFrame widget (#2261)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2023-10-31 07:19:57 -07:00
shamoon
5512d05f00 Fix: Respect hideErrors for Calendar widget (#2259) 2023-10-29 20:14:06 -07:00
shamoon
654f16dbb5 Fix: Correct Synology DownloadStation units (#2249) 2023-10-27 14:39:57 -07:00
shamoon
bec1e5fff2 Fix: octoprint error when progress empty (#2247) 2023-10-27 09:29:36 -07:00
shamoon
1da9255578 Fix: authentik users endpoint URL (#2244) 2023-10-26 23:03:52 -07:00
shamoon
cc887214cf Fix: override some colors for white theme (#2242) 2023-10-26 21:59:18 -07:00
shamoon
98c3ca6dac Docs: Update broken links in readme
Closes #2233
2023-10-24 14:09:56 -07:00
Kirill Kuznetsov
3c4818a2b4 Feature: add date formatting option in custom api (#2228) 2023-10-23 16:54:04 -07:00
Avishek Sen
f773e026d5 docs: fix typos (#2222) 2023-10-21 11:42:37 -07:00
shamoon
3f1229555e Fix translation of no events today in calendar
See #2221
2023-10-21 07:48:17 -07:00
Denis Papec
6898faa3de Feature: Added agenda view for calendar, calendar improvements (#2216)
* Feature: Added agenda view for calendar, calendar improvements

* Fix duplicate event keys

* Additional hover on title, not date

* Show date once in list

* Rename monthly view for consistency

* Remove unneeded key props

* CSS cleanup, dont slice title to arbitrary 42 chars which can break column layouts

* Simplify agenda components

* Fix show date once in list

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2023-10-20 16:31:19 -07:00
shamoon
792f768a7f Feature: true ping, rename old ping to siteMonitor (#2215) 2023-10-20 00:09:33 -07:00
shamoon
0c8c759f8a Enable autoSelectFamily for http(s) requests (#2214) 2023-10-19 14:42:15 -07:00
shamoon
241c981444 Fix tab spacing on mobile (#2209) 2023-10-18 13:25:12 -07:00
shamoon
56349e57e5 Update development.md 2023-10-18 12:53:27 -07:00
shamoon
6763da57a6 Update CONTRIBUTING.md 2023-10-18 12:49:27 -07:00
52 changed files with 646 additions and 176 deletions

View File

@@ -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: |

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

View File

@@ -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.**

View File

@@ -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:

View File

@@ -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)

View File

@@ -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).

View File

@@ -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:

View File

@@ -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 48PM" 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:

View 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
```

View File

@@ -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_

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View File

@@ -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'}

View File

@@ -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!"
}
}

View File

@@ -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"

View File

@@ -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") {

View 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>
);
}

View File

@@ -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",

View File

@@ -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>

View File

@@ -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.",
});

View 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.",
});
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>;
}

View File

@@ -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 {

View File

@@ -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",

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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}` }} />;
}

View File

@@ -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}` }} />;
}

View File

@@ -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}` }} />;
}

View File

@@ -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}` }} />;
}

View File

@@ -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>

View File

@@ -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")),

View File

@@ -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

View File

@@ -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)
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
const widget = {};
export default widget;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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);
}

View File

@@ -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"],
},
],
};