mirror of
				https://github.com/karl0ss/homepage.git
				synced 2025-11-04 00:10:57 +00:00 
			
		
		
		
	Merge branch 'dev'
This commit is contained in:
		
						commit
						1656f02418
					
				
							
								
								
									
										35
									
								
								.github/workflows/docker-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										35
									
								
								.github/workflows/docker-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -26,8 +26,6 @@ on:
 | 
			
		||||
  merge_group:
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  # Use docker.io for Docker Hub if empty
 | 
			
		||||
  REGISTRY: ghcr.io
 | 
			
		||||
  # github.repository as <account>/<repo>
 | 
			
		||||
  IMAGE_NAME: ${{ github.repository }}
 | 
			
		||||
 | 
			
		||||
@ -66,14 +64,6 @@ jobs:
 | 
			
		||||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      # Install the cosign tool except on PR
 | 
			
		||||
      # https://github.com/sigstore/cosign-installer
 | 
			
		||||
      - name: Install cosign
 | 
			
		||||
        if: github.event_name != 'pull_request'
 | 
			
		||||
        uses: sigstore/cosign-installer@main
 | 
			
		||||
        with:
 | 
			
		||||
          cosign-release: 'v1.13.1' # optional
 | 
			
		||||
 | 
			
		||||
      # Setup QEMU
 | 
			
		||||
      # https://github.com/marketplace/actions/docker-setup-buildx#with-qemu
 | 
			
		||||
      - name: Setup QEMU
 | 
			
		||||
@ -99,9 +89,15 @@ jobs:
 | 
			
		||||
        if: github.event_name != 'pull_request'
 | 
			
		||||
        uses: docker/login-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          registry: ${{ env.REGISTRY }}
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.actor }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Login to Docker Hub
 | 
			
		||||
        if: github.event_name != 'pull_request'
 | 
			
		||||
        uses: docker/login-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKERHUB_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKERHUB_TOKEN }}
 | 
			
		||||
 | 
			
		||||
      # Extract metadata (tags, labels) for Docker
 | 
			
		||||
      # https://github.com/docker/metadata-action
 | 
			
		||||
@ -109,7 +105,9 @@ jobs:
 | 
			
		||||
        id: meta
 | 
			
		||||
        uses: docker/metadata-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
 | 
			
		||||
          images: |
 | 
			
		||||
            ${{ env.IMAGE_NAME }}
 | 
			
		||||
            ghcr.io/${{ env.IMAGE_NAME }}
 | 
			
		||||
          flavor: |
 | 
			
		||||
            latest=auto
 | 
			
		||||
 | 
			
		||||
@ -133,19 +131,6 @@ jobs:
 | 
			
		||||
          cache-from: type=local,src=/tmp/.buildx-cache
 | 
			
		||||
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
 | 
			
		||||
 | 
			
		||||
      # Sign the resulting Docker image digest except on PRs.
 | 
			
		||||
      # This will only write to the public Rekor transparency log when the Docker
 | 
			
		||||
      # repository is public to avoid leaking data.  If you would like to publish
 | 
			
		||||
      # transparency data even for private images, pass --force to cosign below.
 | 
			
		||||
      # https://github.com/sigstore/cosign
 | 
			
		||||
#       - name: Sign the published Docker image
 | 
			
		||||
#         if: ${{ github.event_name != 'pull_request' }}
 | 
			
		||||
#         env:
 | 
			
		||||
#           COSIGN_EXPERIMENTAL: "true"
 | 
			
		||||
#         # This step uses the identity token to provision an ephemeral certificate
 | 
			
		||||
#         # against the sigstore community Fulcio instance.
 | 
			
		||||
#         run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign {}@${{ steps.build-and-push.outputs.digest }}
 | 
			
		||||
 | 
			
		||||
      # Temp fix
 | 
			
		||||
      # https://github.com/docker/build-push-action/issues/252
 | 
			
		||||
      # https://github.com/moby/buildkit/issues/1896
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										20
									
								
								.github/workflows/reaction-comments.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/reaction-comments.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
name: 'Reaction Comments'
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  issue_comment:
 | 
			
		||||
    types: [created, edited]
 | 
			
		||||
  pull_request_review_comment:
 | 
			
		||||
    types: [created, edited]
 | 
			
		||||
  schedule:
 | 
			
		||||
    - cron: '0 0 * * *'
 | 
			
		||||
 | 
			
		||||
permissions:
 | 
			
		||||
  actions: write
 | 
			
		||||
  issues: write
 | 
			
		||||
  pull-requests: write
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  action:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: dessant/reaction-comments@v4
 | 
			
		||||
@ -100,6 +100,8 @@ If you are using multiple instances of homepage, an `instance` annotation can be
 | 
			
		||||
 | 
			
		||||
If you have a single service that needs to be shown on multiple specific instances of homepage (but not on all of them), the service can be annotated by multiple `instance.name` annotations, where `name` can be the names of your specific multiple homepage instances. For example, a service that is annotated with `gethomepage.dev/instance.public: ""` and `gethomepage.dev/instance.internal: ""` will be shown on `public` and `internal` homepage instances.
 | 
			
		||||
 | 
			
		||||
Use the `gethomepage.dev/pod-selector` selector to specify the pod used for the health check. For example, a service that is annotated with `gethomepage.dev/pod-selector: app.kubernetes.io/name=deployment` would link to a pod with the label `app.kubernetes.io/name: deployment`.
 | 
			
		||||
 | 
			
		||||
### Traefik IngressRoute support
 | 
			
		||||
 | 
			
		||||
Homepage can also read ingresses defined using the Traefik IngressRoute custom resource definition. Due to the complex nature of Traefik routing rules, it is required for the `gethomepage.dev/href` annotation to be set:
 | 
			
		||||
 | 
			
		||||
@ -71,7 +71,7 @@ Homepage provides a set of common translations that you can use in your widgets.
 | 
			
		||||
| `common.ms`           | `1,000 ms`      | Format a number in milliseconds. |
 | 
			
		||||
| `common.date`         | `2024-01-01`    | Format a date.                   |
 | 
			
		||||
| `common.relativeDate` | `1 day ago`     | Format a relative date.          |
 | 
			
		||||
| `common.uptime`       | `1 day, 1 hour` | Format an uptime.                |
 | 
			
		||||
| `common.duration`     | `1 day, 1 hour` | Format an duration.              |
 | 
			
		||||
 | 
			
		||||
### Text
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,11 @@ description: Unifi Controller Information Widget Configuration
 | 
			
		||||
 | 
			
		||||
_(Find the Unifi Controller service widget [here](../services/unifi-controller.md))_
 | 
			
		||||
 | 
			
		||||
You can display general connectivity status from your Unifi (Network) Controller. When authenticating you will want to use a local account that has at least read privileges.
 | 
			
		||||
You can display general connectivity status from your Unifi (Network) Controller.
 | 
			
		||||
 | 
			
		||||
!!!
 | 
			
		||||
 | 
			
		||||
    When authenticating you will want to use a local account that has at least read privileges.
 | 
			
		||||
 | 
			
		||||
An optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										33
									
								
								docs/widgets/services/argocd.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								docs/widgets/services/argocd.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
---
 | 
			
		||||
title: ArgoCD
 | 
			
		||||
description: ArgoCD Widget Configuration
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Learn more about [ArgoCD](https://argo-cd.readthedocs.io/en/stable/).
 | 
			
		||||
 | 
			
		||||
Allowed fields (limited to a max of 4): `["apps", "synced", "outOfSync", "healthy", "progressing", "degraded", "suspended", "missing"]`
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
widget:
 | 
			
		||||
  type: argocd
 | 
			
		||||
  url: http://argocd.host.or.ip:port
 | 
			
		||||
  key: argocdapikey
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
You can generate an API key either by creating a bearer token for an existing account, see [Authorization](https://argo-cd.readthedocs.io/en/latest/developer-guide/api-docs/#authorization) (not recommended) or create a new local user account with limited privileges and generate an authentication token for this account. To do this the steps are:
 | 
			
		||||
 | 
			
		||||
- [Create a new local user](https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/#create-new-user) and give it the `apiKey` capability
 | 
			
		||||
- Setup [RBAC configuration](https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/#rbac-configuration) for your the user and give it readonly access to your ArgoCD resources, e.g. by giving it the `role:readonly` role.
 | 
			
		||||
- In your ArgoCD project under _Settings / Accounts_ open the newly created account and in the _Tokens_ section click on _Generate New_ to generate an access token, optionally specifying an expiry date.
 | 
			
		||||
 | 
			
		||||
If you installed ArgoCD via the official Helm chart, the account creation and rbac config can be achived by overriding these helm values:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
configs:
 | 
			
		||||
  cm:
 | 
			
		||||
    accounts.readonly: apiKey
 | 
			
		||||
  rbac:
 | 
			
		||||
    policy.csv: "g, readonly, role:readonly"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This creates a new account called `readonly` and attaches the `role:readonly` role to it.
 | 
			
		||||
							
								
								
									
										22
									
								
								docs/widgets/services/beszel.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								docs/widgets/services/beszel.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
---
 | 
			
		||||
title: Beszel
 | 
			
		||||
description: Beszel Widget Configuration
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Learn more about [Beszel](https://github.com/henrygd/beszel)
 | 
			
		||||
 | 
			
		||||
The widget has two modes, a single system with detailed info if `systemId` is provided, or an overview of all systems if `systemId` is not provided.
 | 
			
		||||
 | 
			
		||||
The `systemID` in the `id` field on the collections page of Beszel.
 | 
			
		||||
 | 
			
		||||
Allowed fields for 'overview' mode: `["systems", "up"]`
 | 
			
		||||
Allowed fields for a single system: `["name", "status", "updated", "cpu", "memory", "disk", "network"]`
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
widget:
 | 
			
		||||
  type: beszel
 | 
			
		||||
  url: http://beszel.host.or.ip
 | 
			
		||||
  username: username # email
 | 
			
		||||
  password: password
 | 
			
		||||
  systemId: systemId # optional
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										20
									
								
								docs/widgets/services/gitlab.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/widgets/services/gitlab.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
---
 | 
			
		||||
title: Gitlab
 | 
			
		||||
description: Gitlab Widget Configuration
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Learn more about [Gitlab](https://gitlab.com).
 | 
			
		||||
 | 
			
		||||
API requires a personal access token with either `read_api` or `api` permission. See the [gitlab documentation](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token) for details on generating one.
 | 
			
		||||
 | 
			
		||||
Your Gitlab user ID can be found on [your profile page](https://support.circleci.com/hc/en-us/articles/20761157174043-How-to-find-your-GitLab-User-ID).
 | 
			
		||||
 | 
			
		||||
Allowed fields: `["events", "issues", "merges", "projects"]`.
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
widget:
 | 
			
		||||
  type: gitlab
 | 
			
		||||
  url: http://gitlab.host.or.ip:port
 | 
			
		||||
  key: personal-access-token
 | 
			
		||||
  user_id: 123456
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										19
									
								
								docs/widgets/services/headscale.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								docs/widgets/services/headscale.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
---
 | 
			
		||||
title: Headscale
 | 
			
		||||
description: Headscale Widget Configuration
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Learn more about [Headscale](https://headscale.net/).
 | 
			
		||||
 | 
			
		||||
You will need to generate an API access token from the [command line](https://headscale.net/ref/remote-cli/#create-an-api-key) using `headscale apikeys create` command.
 | 
			
		||||
 | 
			
		||||
To find your node ID, you can use `headscale nodes list` command.
 | 
			
		||||
 | 
			
		||||
Allowed fields: `["name", "address", "last_seen", "status"]`.
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
widget:
 | 
			
		||||
  type: headscale
 | 
			
		||||
  nodeId: nodeid
 | 
			
		||||
  key: headscaleapiaccesstoken
 | 
			
		||||
```
 | 
			
		||||
@ -8,12 +8,14 @@ search:
 | 
			
		||||
You can also find a list of all available service widgets in the sidebar navigation.
 | 
			
		||||
 | 
			
		||||
- [Adguard Home](adguard-home.md)
 | 
			
		||||
- [ArgoCD](argocd.md)
 | 
			
		||||
- [Atsumeru](atsumeru.md)
 | 
			
		||||
- [Audiobookshelf](audiobookshelf.md)
 | 
			
		||||
- [Authentik](authentik.md)
 | 
			
		||||
- [Autobrr](autobrr.md)
 | 
			
		||||
- [Azure DevOps](azuredevops.md)
 | 
			
		||||
- [Bazarr](bazarr.md)
 | 
			
		||||
- [Beszel](beszel.md)
 | 
			
		||||
- [Caddy](caddy.md)
 | 
			
		||||
- [Calendar](calendar.md)
 | 
			
		||||
- [Calibre-Web](calibre-web.md)
 | 
			
		||||
@ -39,11 +41,13 @@ You can also find a list of all available service widgets in the sidebar navigat
 | 
			
		||||
- [Gatus](gatus.md)
 | 
			
		||||
- [Ghostfolio](ghostfolio.md)
 | 
			
		||||
- [Gitea](gitea.md)
 | 
			
		||||
- [Gitlab](gitlab.md)
 | 
			
		||||
- [Glances](glances.md)
 | 
			
		||||
- [Gluetun](gluetun.md)
 | 
			
		||||
- [Gotify](gotify.md)
 | 
			
		||||
- [Grafana](grafana.md)
 | 
			
		||||
- [HDHomeRun](hdhomerun.md)
 | 
			
		||||
- [Headscale](headscale.md)
 | 
			
		||||
- [Healthchecks](healthchecks.md)
 | 
			
		||||
- [Home Assistant](homeassistant.md)
 | 
			
		||||
- [HomeBox](homebox.md)
 | 
			
		||||
@ -96,6 +100,7 @@ You can also find a list of all available service widgets in the sidebar navigat
 | 
			
		||||
- [Plex](plex.md)
 | 
			
		||||
- [Portainer](portainer.md)
 | 
			
		||||
- [Prometheus](prometheus.md)
 | 
			
		||||
- [Prometheus Metric](prometheusmetric.md)
 | 
			
		||||
- [Prowlarr](prowlarr.md)
 | 
			
		||||
- [Proxmox](proxmox.md)
 | 
			
		||||
- [Proxmox Backup Server](proxmoxbackupserver.md)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										67
									
								
								docs/widgets/services/prometheusmetric.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								docs/widgets/services/prometheusmetric.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,67 @@
 | 
			
		||||
---
 | 
			
		||||
title: Prometheus Metric
 | 
			
		||||
description: Prometheus Metric Widget Configuration
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Learn more about [Querying Prometheus](https://prometheus.io/docs/prometheus/latest/querying/basics/).
 | 
			
		||||
 | 
			
		||||
This widget can show metrics for your service defined by PromQL queries which are requested from a running Prometheus instance.
 | 
			
		||||
 | 
			
		||||
Quries can be defined in the `metrics` array of the widget along with a label to be used to present the metric value. You can optionally specify a global `refreshInterval` in milliseconds and/or define the `refreshInterval` per metric. Inside the optional `format` object of a metric various formatting styles and transformations can be applied (see below).
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
widget:
 | 
			
		||||
  type: prometheusmetric
 | 
			
		||||
  url: https://prometheus.host.or.ip
 | 
			
		||||
  refreshInterval: 10000 # optional - in milliseconds, defaults to 10s
 | 
			
		||||
  metrics:
 | 
			
		||||
    - label: Metric 1
 | 
			
		||||
      query: alertmanager_alerts{state="active"}
 | 
			
		||||
    - label: Metric 2
 | 
			
		||||
      query: apiserver_storage_size_bytes{node="mynode"}
 | 
			
		||||
      format:
 | 
			
		||||
        type: bytes
 | 
			
		||||
    - label: Metric 3
 | 
			
		||||
      query: avg(prometheus_notifications_latency_seconds)
 | 
			
		||||
      format:
 | 
			
		||||
        type: number
 | 
			
		||||
        suffix: s
 | 
			
		||||
        options:
 | 
			
		||||
          maximumFractionDigits: 4
 | 
			
		||||
    - label: Metric 4
 | 
			
		||||
      query: time()
 | 
			
		||||
      refreshInterval: 1000 # will override global refreshInterval
 | 
			
		||||
      format:
 | 
			
		||||
        type: date
 | 
			
		||||
        scale: 1000
 | 
			
		||||
        options:
 | 
			
		||||
          timeStyle: medium
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Formatting
 | 
			
		||||
 | 
			
		||||
Supported values for `format.type` are `text`, `number`, `percent`, `bytes`, `bits`, `bbytes`, `bbits`, `byterate`, `bibyterate`, `bitrate`, `bibitrate`, `date`, `duration`, `relativeDate`, and `text` which is the default.
 | 
			
		||||
 | 
			
		||||
The `dateStyle` and `timeStyle` options of the `date` format are passed directly to [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) and the `style` and `numeric` options of `relativeDate` are passed to [Intl.RelativeTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat). For the `number` format, options of [Intl.NumberFormat](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) can be used, e.g. `maximumFractionDigits` or `minimumFractionDigits`.
 | 
			
		||||
 | 
			
		||||
### Data Transformation
 | 
			
		||||
 | 
			
		||||
You can manipulate your metric value with the following tools: `scale`, `prefix` and `suffix`, for example:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
- query: my_custom_metric{}
 | 
			
		||||
  label: Metric 1
 | 
			
		||||
  format:
 | 
			
		||||
    type: number
 | 
			
		||||
    scale: 1000 # multiplies value by a number or fraction string e.g. 1/16
 | 
			
		||||
- query: my_custom_metric{}
 | 
			
		||||
  label: Metric 2
 | 
			
		||||
  format:
 | 
			
		||||
    type: number
 | 
			
		||||
    prefix: "$" # prefixes value with given string
 | 
			
		||||
- query: my_custom_metric{}
 | 
			
		||||
  label: Metric 3
 | 
			
		||||
  format:
 | 
			
		||||
    type: number
 | 
			
		||||
    suffix: "€" # suffixes value with given string
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										15
									
								
								docs/widgets/services/spoolman.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								docs/widgets/services/spoolman.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
---
 | 
			
		||||
title: Spoolman
 | 
			
		||||
description: Spoolman Widget Configuration
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Learn more about [Spoolman](https://github.com/Donkie/Spoolman).
 | 
			
		||||
 | 
			
		||||
4 spools are displayed by default. If more than 4 spools are configured in spoolman you can use the spoolIds configuration option to control which are displayed.
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
widget:
 | 
			
		||||
  type: spoolman
 | 
			
		||||
  url: http://spoolman.host.or.ip
 | 
			
		||||
  spoolIds: [1, 2, 3, 4] # optional
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										20
									
								
								docs/widgets/services/suwayomi.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/widgets/services/suwayomi.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
---
 | 
			
		||||
title: Suwayomi
 | 
			
		||||
description: Suwayomi Widget Configuration
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Learn more about [Suwayomi](https://github.com/Suwayomi/Suwayomi-Server).
 | 
			
		||||
 | 
			
		||||
Allowed fields: ["download", "nondownload", "read", "unread", "downloadedread", "downloadedunread", "nondownloadedread", "nondownloadedunread"]
 | 
			
		||||
 | 
			
		||||
The widget defaults to the first four above. If more than four fields are provided, only the first 4 are displayed.
 | 
			
		||||
Category IDs can be obtained from the url when navigating to it, `?tab={categoryID}`.
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
widget:
 | 
			
		||||
  type: suwayomi
 | 
			
		||||
  url: http://suwayomi.host.or.ip
 | 
			
		||||
  username: username #optional
 | 
			
		||||
  password: password #optional
 | 
			
		||||
  category: 0 #optional, defaults to all categories
 | 
			
		||||
```
 | 
			
		||||
@ -7,7 +7,11 @@ Learn more about [Unifi Controller](https://ui.com/).
 | 
			
		||||
 | 
			
		||||
_(Find the Unifi Controller information widget [here](../info/unifi_controller.md))_
 | 
			
		||||
 | 
			
		||||
You can display general connectivity status from your Unifi (Network) Controller. When authenticating you will want to use a local account that has at least read privileges.
 | 
			
		||||
You can display general connectivity status from your Unifi (Network) Controller.
 | 
			
		||||
 | 
			
		||||
!!!
 | 
			
		||||
 | 
			
		||||
    When authenticating you will want to use a local account that has at least read privileges.
 | 
			
		||||
 | 
			
		||||
An optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -31,12 +31,14 @@ nav:
 | 
			
		||||
      - "Service Widgets":
 | 
			
		||||
          - widgets/services/index.md
 | 
			
		||||
          - widgets/services/adguard-home.md
 | 
			
		||||
          - widgets/services/argocd.md
 | 
			
		||||
          - widgets/services/atsumeru.md
 | 
			
		||||
          - widgets/services/audiobookshelf.md
 | 
			
		||||
          - widgets/services/authentik.md
 | 
			
		||||
          - widgets/services/autobrr.md
 | 
			
		||||
          - widgets/services/azuredevops.md
 | 
			
		||||
          - widgets/services/bazarr.md
 | 
			
		||||
          - widgets/services/beszel.md
 | 
			
		||||
          - widgets/services/caddy.md
 | 
			
		||||
          - widgets/services/calendar.md
 | 
			
		||||
          - widgets/services/calibre-web.md
 | 
			
		||||
@ -62,11 +64,13 @@ nav:
 | 
			
		||||
          - widgets/services/gatus.md
 | 
			
		||||
          - widgets/services/ghostfolio.md
 | 
			
		||||
          - widgets/services/gitea.md
 | 
			
		||||
          - widgets/services/gitlab.md
 | 
			
		||||
          - widgets/services/glances.md
 | 
			
		||||
          - widgets/services/gluetun.md
 | 
			
		||||
          - widgets/services/gotify.md
 | 
			
		||||
          - widgets/services/grafana.md
 | 
			
		||||
          - widgets/services/hdhomerun.md
 | 
			
		||||
          - widgets/services/headscale.md
 | 
			
		||||
          - widgets/services/healthchecks.md
 | 
			
		||||
          - widgets/services/homeassistant.md
 | 
			
		||||
          - widgets/services/homebox.md
 | 
			
		||||
@ -119,6 +123,7 @@ nav:
 | 
			
		||||
          - widgets/services/plex.md
 | 
			
		||||
          - widgets/services/portainer.md
 | 
			
		||||
          - widgets/services/prometheus.md
 | 
			
		||||
          - widgets/services/prometheusmetric.md
 | 
			
		||||
          - widgets/services/prowlarr.md
 | 
			
		||||
          - widgets/services/proxmox.md
 | 
			
		||||
          - widgets/services/proxmoxbackupserver.md
 | 
			
		||||
@ -134,6 +139,7 @@ nav:
 | 
			
		||||
          - widgets/services/scrutiny.md
 | 
			
		||||
          - widgets/services/sonarr.md
 | 
			
		||||
          - widgets/services/speedtest-tracker.md
 | 
			
		||||
          - widgets/services/spoolman.md
 | 
			
		||||
          - widgets/services/stash.md
 | 
			
		||||
          - widgets/services/stocks.md
 | 
			
		||||
          - widgets/services/swagdashboard.md
 | 
			
		||||
 | 
			
		||||
@ -84,12 +84,12 @@ function prettyBytes(number, options) {
 | 
			
		||||
  return `${prefix + numberString} ${unit}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function uptime(uptimeInSeconds, i18next) {
 | 
			
		||||
  const mo = Math.floor(uptimeInSeconds / (3600 * 24 * 31));
 | 
			
		||||
  const d = Math.floor((uptimeInSeconds % (3600 * 24 * 31)) / (3600 * 24));
 | 
			
		||||
  const h = Math.floor((uptimeInSeconds % (3600 * 24)) / 3600);
 | 
			
		||||
  const m = Math.floor((uptimeInSeconds % 3600) / 60);
 | 
			
		||||
  const s = Math.floor(uptimeInSeconds % 60);
 | 
			
		||||
function duration(durationInSeconds, i18next) {
 | 
			
		||||
  const mo = Math.floor(durationInSeconds / (3600 * 24 * 31));
 | 
			
		||||
  const d = Math.floor((durationInSeconds % (3600 * 24 * 31)) / (3600 * 24));
 | 
			
		||||
  const h = Math.floor((durationInSeconds % (3600 * 24)) / 3600);
 | 
			
		||||
  const m = Math.floor((durationInSeconds % 3600) / 60);
 | 
			
		||||
  const s = Math.floor(durationInSeconds % 60);
 | 
			
		||||
 | 
			
		||||
  const moDisplay = mo > 0 ? mo + i18next.t("common.months") : "";
 | 
			
		||||
  const dDisplay = d > 0 ? d + i18next.t("common.days") : "";
 | 
			
		||||
@ -156,7 +156,7 @@ module.exports = {
 | 
			
		||||
        i18next.services.formatter.add("relativeDate", (value, lng, options) =>
 | 
			
		||||
          relativeDate(new Date(value), new Intl.RelativeTimeFormat(lng, { ...options })),
 | 
			
		||||
        );
 | 
			
		||||
        i18next.services.formatter.add("uptime", (value, lng) => uptime(value, i18next));
 | 
			
		||||
        i18next.services.formatter.add("duration", (value, lng) => duration(value, i18next));
 | 
			
		||||
      },
 | 
			
		||||
      type: "3rdParty",
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										46
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										46
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -36,7 +36,7 @@
 | 
			
		||||
        "swr": "^1.3.0",
 | 
			
		||||
        "systeminformation": "^5.23.2",
 | 
			
		||||
        "tough-cookie": "^4.1.3",
 | 
			
		||||
        "urbackup-server-api": "^0.52.0",
 | 
			
		||||
        "urbackup-server-api": "^0.52.1",
 | 
			
		||||
        "winston": "^3.11.0",
 | 
			
		||||
        "xml-js": "^1.6.11"
 | 
			
		||||
      },
 | 
			
		||||
@ -47,7 +47,7 @@
 | 
			
		||||
        "eslint-config-airbnb": "^19.0.4",
 | 
			
		||||
        "eslint-config-next": "^14.2.3",
 | 
			
		||||
        "eslint-config-prettier": "^9.1.0",
 | 
			
		||||
        "eslint-plugin-import": "^2.29.1",
 | 
			
		||||
        "eslint-plugin-import": "^2.31.0",
 | 
			
		||||
        "eslint-plugin-jsx-a11y": "^6.8.0",
 | 
			
		||||
        "eslint-plugin-prettier": "^5.2.1",
 | 
			
		||||
        "eslint-plugin-react": "^7.37.1",
 | 
			
		||||
@ -55,8 +55,8 @@
 | 
			
		||||
        "postcss": "^8.4.47",
 | 
			
		||||
        "prettier": "^3.2.5",
 | 
			
		||||
        "tailwind-scrollbar": "^3.0.5",
 | 
			
		||||
        "tailwindcss": "^3.4.13",
 | 
			
		||||
        "typescript": "^5.6.2"
 | 
			
		||||
        "tailwindcss": "^3.4.14",
 | 
			
		||||
        "typescript": "^5.6.3"
 | 
			
		||||
      },
 | 
			
		||||
      "optionalDependencies": {
 | 
			
		||||
        "osx-temperature-sensor": "^1.0.8"
 | 
			
		||||
@ -3135,11 +3135,10 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/eslint-module-utils": {
 | 
			
		||||
      "version": "2.9.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.9.0.tgz",
 | 
			
		||||
      "integrity": "sha512-McVbYmwA3NEKwRQY5g4aWMdcZE5xZxV8i8l7CqJSrameuGSQJtSWaL/LxTEzSKKaCcOhlpDR8XEfYXWPrdo/ZQ==",
 | 
			
		||||
      "version": "2.12.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
 | 
			
		||||
      "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "debug": "^3.2.7"
 | 
			
		||||
      },
 | 
			
		||||
@ -3163,11 +3162,10 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/eslint-plugin-import": {
 | 
			
		||||
      "version": "2.30.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz",
 | 
			
		||||
      "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==",
 | 
			
		||||
      "version": "2.31.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
 | 
			
		||||
      "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@rtsao/scc": "^1.1.0",
 | 
			
		||||
        "array-includes": "^3.1.8",
 | 
			
		||||
@ -3177,7 +3175,7 @@
 | 
			
		||||
        "debug": "^3.2.7",
 | 
			
		||||
        "doctrine": "^2.1.0",
 | 
			
		||||
        "eslint-import-resolver-node": "^0.3.9",
 | 
			
		||||
        "eslint-module-utils": "^2.9.0",
 | 
			
		||||
        "eslint-module-utils": "^2.12.0",
 | 
			
		||||
        "hasown": "^2.0.2",
 | 
			
		||||
        "is-core-module": "^2.15.1",
 | 
			
		||||
        "is-glob": "^4.0.3",
 | 
			
		||||
@ -3186,13 +3184,14 @@
 | 
			
		||||
        "object.groupby": "^1.0.3",
 | 
			
		||||
        "object.values": "^1.2.0",
 | 
			
		||||
        "semver": "^6.3.1",
 | 
			
		||||
        "string.prototype.trimend": "^1.0.8",
 | 
			
		||||
        "tsconfig-paths": "^3.15.0"
 | 
			
		||||
      },
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=4"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8"
 | 
			
		||||
        "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/eslint-plugin-import/node_modules/debug": {
 | 
			
		||||
@ -7713,9 +7712,9 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/tailwindcss": {
 | 
			
		||||
      "version": "3.4.13",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz",
 | 
			
		||||
      "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==",
 | 
			
		||||
      "version": "3.4.14",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz",
 | 
			
		||||
      "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@alloc/quick-lru": "^5.2.0",
 | 
			
		||||
@ -8102,9 +8101,9 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/typescript": {
 | 
			
		||||
      "version": "5.6.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
 | 
			
		||||
      "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
 | 
			
		||||
      "version": "5.6.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
 | 
			
		||||
      "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "tsc": "bin/tsc",
 | 
			
		||||
@ -8186,10 +8185,9 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/urbackup-server-api": {
 | 
			
		||||
      "version": "0.52.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/urbackup-server-api/-/urbackup-server-api-0.52.0.tgz",
 | 
			
		||||
      "integrity": "sha512-KfroCFZEWCuCkWye1F1JwI2fkO1za/Mf1a8TNGTujzxU0ZGzDqhA1zCOcvV97q7nH1TKFNpw5tMZ06fSCKv2UA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "version": "0.52.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/urbackup-server-api/-/urbackup-server-api-0.52.1.tgz",
 | 
			
		||||
      "integrity": "sha512-gAxF9MdXxnceqUr/1Uj2LuGZQb/bvZ3Ply9zH/UTSWGkwKL5C0qMPrBvKRyTHbPMG/NBuHF6BzavkF7GNvOLew==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "async-mutex": "^0.5.0",
 | 
			
		||||
        "node-fetch": "^2.7.0"
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,7 @@
 | 
			
		||||
    "swr": "^1.3.0",
 | 
			
		||||
    "systeminformation": "^5.23.2",
 | 
			
		||||
    "tough-cookie": "^4.1.3",
 | 
			
		||||
    "urbackup-server-api": "^0.52.0",
 | 
			
		||||
    "urbackup-server-api": "^0.52.1",
 | 
			
		||||
    "winston": "^3.11.0",
 | 
			
		||||
    "xml-js": "^1.6.11"
 | 
			
		||||
  },
 | 
			
		||||
@ -49,7 +49,7 @@
 | 
			
		||||
    "eslint-config-airbnb": "^19.0.4",
 | 
			
		||||
    "eslint-config-next": "^14.2.3",
 | 
			
		||||
    "eslint-config-prettier": "^9.1.0",
 | 
			
		||||
    "eslint-plugin-import": "^2.29.1",
 | 
			
		||||
    "eslint-plugin-import": "^2.31.0",
 | 
			
		||||
    "eslint-plugin-jsx-a11y": "^6.8.0",
 | 
			
		||||
    "eslint-plugin-prettier": "^5.2.1",
 | 
			
		||||
    "eslint-plugin-react": "^7.37.1",
 | 
			
		||||
@ -57,8 +57,8 @@
 | 
			
		||||
    "postcss": "^8.4.47",
 | 
			
		||||
    "prettier": "^3.2.5",
 | 
			
		||||
    "tailwind-scrollbar": "^3.0.5",
 | 
			
		||||
    "tailwindcss": "^3.4.13",
 | 
			
		||||
    "typescript": "^5.6.2"
 | 
			
		||||
    "tailwindcss": "^3.4.14",
 | 
			
		||||
    "typescript": "^5.6.3"
 | 
			
		||||
  },
 | 
			
		||||
  "optionalDependencies": {
 | 
			
		||||
    "osx-temperature-sensor": "^1.0.8"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										172
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										172
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@ -93,8 +93,8 @@ importers:
 | 
			
		||||
        specifier: ^4.1.3
 | 
			
		||||
        version: 4.1.4
 | 
			
		||||
      urbackup-server-api:
 | 
			
		||||
        specifier: ^0.52.0
 | 
			
		||||
        version: 0.52.0
 | 
			
		||||
        specifier: ^0.52.1
 | 
			
		||||
        version: 0.52.1
 | 
			
		||||
      winston:
 | 
			
		||||
        specifier: ^3.11.0
 | 
			
		||||
        version: 3.14.2
 | 
			
		||||
@ -108,7 +108,7 @@ importers:
 | 
			
		||||
    devDependencies:
 | 
			
		||||
      '@tailwindcss/forms':
 | 
			
		||||
        specifier: ^0.5.8
 | 
			
		||||
        version: 0.5.9(tailwindcss@3.4.13)
 | 
			
		||||
        version: 0.5.9(tailwindcss@3.4.14)
 | 
			
		||||
      autoprefixer:
 | 
			
		||||
        specifier: ^10.4.20
 | 
			
		||||
        version: 10.4.20(postcss@8.4.47)
 | 
			
		||||
@ -117,16 +117,16 @@ importers:
 | 
			
		||||
        version: 8.57.1
 | 
			
		||||
      eslint-config-airbnb:
 | 
			
		||||
        specifier: ^19.0.4
 | 
			
		||||
        version: 19.0.4(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.0(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.1(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
        version: 19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.0(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.1(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
      eslint-config-next:
 | 
			
		||||
        specifier: ^14.2.3
 | 
			
		||||
        version: 14.2.8(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
        version: 14.2.8(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      eslint-config-prettier:
 | 
			
		||||
        specifier: ^9.1.0
 | 
			
		||||
        version: 9.1.0(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-import:
 | 
			
		||||
        specifier: ^2.29.1
 | 
			
		||||
        version: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
        specifier: ^2.31.0
 | 
			
		||||
        version: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-jsx-a11y:
 | 
			
		||||
        specifier: ^6.8.0
 | 
			
		||||
        version: 6.10.0(eslint@8.57.1)
 | 
			
		||||
@ -147,13 +147,13 @@ importers:
 | 
			
		||||
        version: 3.3.3
 | 
			
		||||
      tailwind-scrollbar:
 | 
			
		||||
        specifier: ^3.0.5
 | 
			
		||||
        version: 3.1.0(tailwindcss@3.4.13)
 | 
			
		||||
        version: 3.1.0(tailwindcss@3.4.14)
 | 
			
		||||
      tailwindcss:
 | 
			
		||||
        specifier: ^3.4.13
 | 
			
		||||
        version: 3.4.13
 | 
			
		||||
        specifier: ^3.4.14
 | 
			
		||||
        version: 3.4.14
 | 
			
		||||
      typescript:
 | 
			
		||||
        specifier: ^5.6.2
 | 
			
		||||
        version: 5.6.2
 | 
			
		||||
        specifier: ^5.6.3
 | 
			
		||||
        version: 5.6.3
 | 
			
		||||
 | 
			
		||||
packages:
 | 
			
		||||
 | 
			
		||||
@ -1077,6 +1077,27 @@ packages:
 | 
			
		||||
      eslint-plugin-import-x:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  eslint-module-utils@2.12.0:
 | 
			
		||||
    resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==}
 | 
			
		||||
    engines: {node: '>=4'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@typescript-eslint/parser': '*'
 | 
			
		||||
      eslint: '*'
 | 
			
		||||
      eslint-import-resolver-node: '*'
 | 
			
		||||
      eslint-import-resolver-typescript: '*'
 | 
			
		||||
      eslint-import-resolver-webpack: '*'
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@typescript-eslint/parser':
 | 
			
		||||
        optional: true
 | 
			
		||||
      eslint:
 | 
			
		||||
        optional: true
 | 
			
		||||
      eslint-import-resolver-node:
 | 
			
		||||
        optional: true
 | 
			
		||||
      eslint-import-resolver-typescript:
 | 
			
		||||
        optional: true
 | 
			
		||||
      eslint-import-resolver-webpack:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  eslint-module-utils@2.9.0:
 | 
			
		||||
    resolution: {integrity: sha512-McVbYmwA3NEKwRQY5g4aWMdcZE5xZxV8i8l7CqJSrameuGSQJtSWaL/LxTEzSKKaCcOhlpDR8XEfYXWPrdo/ZQ==}
 | 
			
		||||
    engines: {node: '>=4'}
 | 
			
		||||
@ -1098,12 +1119,12 @@ packages:
 | 
			
		||||
      eslint-import-resolver-webpack:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  eslint-plugin-import@2.30.0:
 | 
			
		||||
    resolution: {integrity: sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==}
 | 
			
		||||
  eslint-plugin-import@2.31.0:
 | 
			
		||||
    resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==}
 | 
			
		||||
    engines: {node: '>=4'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@typescript-eslint/parser': '*'
 | 
			
		||||
      eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
 | 
			
		||||
      eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@typescript-eslint/parser':
 | 
			
		||||
        optional: true
 | 
			
		||||
@ -1151,6 +1172,7 @@ packages:
 | 
			
		||||
  eslint@8.57.1:
 | 
			
		||||
    resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==}
 | 
			
		||||
    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
 | 
			
		||||
    deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  espree@9.6.1:
 | 
			
		||||
@ -2510,8 +2532,8 @@ packages:
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      tailwindcss: 3.x
 | 
			
		||||
 | 
			
		||||
  tailwindcss@3.4.13:
 | 
			
		||||
    resolution: {integrity: sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==}
 | 
			
		||||
  tailwindcss@3.4.14:
 | 
			
		||||
    resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==}
 | 
			
		||||
    engines: {node: '>=14.0.0'}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
@ -2624,8 +2646,8 @@ packages:
 | 
			
		||||
    resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==}
 | 
			
		||||
    engines: {node: '>= 0.4'}
 | 
			
		||||
 | 
			
		||||
  typescript@5.6.2:
 | 
			
		||||
    resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==}
 | 
			
		||||
  typescript@5.6.3:
 | 
			
		||||
    resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
 | 
			
		||||
    engines: {node: '>=14.17'}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
@ -2649,8 +2671,8 @@ packages:
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      browserslist: '>= 4.21.0'
 | 
			
		||||
 | 
			
		||||
  urbackup-server-api@0.52.0:
 | 
			
		||||
    resolution: {integrity: sha512-KfroCFZEWCuCkWye1F1JwI2fkO1za/Mf1a8TNGTujzxU0ZGzDqhA1zCOcvV97q7nH1TKFNpw5tMZ06fSCKv2UA==}
 | 
			
		||||
  urbackup-server-api@0.52.1:
 | 
			
		||||
    resolution: {integrity: sha512-gAxF9MdXxnceqUr/1Uj2LuGZQb/bvZ3Ply9zH/UTSWGkwKL5C0qMPrBvKRyTHbPMG/NBuHF6BzavkF7GNvOLew==}
 | 
			
		||||
 | 
			
		||||
  uri-js@4.4.1:
 | 
			
		||||
    resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
 | 
			
		||||
@ -2956,10 +2978,10 @@ snapshots:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      defer-to-connect: 2.0.1
 | 
			
		||||
 | 
			
		||||
  '@tailwindcss/forms@0.5.9(tailwindcss@3.4.13)':
 | 
			
		||||
  '@tailwindcss/forms@0.5.9(tailwindcss@3.4.14)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      mini-svg-data-uri: 1.4.4
 | 
			
		||||
      tailwindcss: 3.4.13
 | 
			
		||||
      tailwindcss: 3.4.14
 | 
			
		||||
 | 
			
		||||
  '@tanstack/react-virtual@3.10.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
@ -3015,13 +3037,13 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  '@types/triple-beam@1.3.5': {}
 | 
			
		||||
 | 
			
		||||
  '@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2)':
 | 
			
		||||
  '@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@eslint-community/regexpp': 4.11.0
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      '@typescript-eslint/scope-manager': 7.2.0
 | 
			
		||||
      '@typescript-eslint/type-utils': 7.2.0(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/utils': 7.2.0(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/type-utils': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      '@typescript-eslint/utils': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      '@typescript-eslint/visitor-keys': 7.2.0
 | 
			
		||||
      debug: 4.3.6
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
@ -3029,22 +3051,22 @@ snapshots:
 | 
			
		||||
      ignore: 5.3.2
 | 
			
		||||
      natural-compare: 1.4.0
 | 
			
		||||
      semver: 7.6.3
 | 
			
		||||
      ts-api-utils: 1.3.0(typescript@5.6.2)
 | 
			
		||||
      ts-api-utils: 1.3.0(typescript@5.6.3)
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      typescript: 5.6.2
 | 
			
		||||
      typescript: 5.6.3
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  '@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2)':
 | 
			
		||||
  '@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@typescript-eslint/scope-manager': 7.2.0
 | 
			
		||||
      '@typescript-eslint/types': 7.2.0
 | 
			
		||||
      '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.6.3)
 | 
			
		||||
      '@typescript-eslint/visitor-keys': 7.2.0
 | 
			
		||||
      debug: 4.3.6
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      typescript: 5.6.2
 | 
			
		||||
      typescript: 5.6.3
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
@ -3053,21 +3075,21 @@ snapshots:
 | 
			
		||||
      '@typescript-eslint/types': 7.2.0
 | 
			
		||||
      '@typescript-eslint/visitor-keys': 7.2.0
 | 
			
		||||
 | 
			
		||||
  '@typescript-eslint/type-utils@7.2.0(eslint@8.57.1)(typescript@5.6.2)':
 | 
			
		||||
  '@typescript-eslint/type-utils@7.2.0(eslint@8.57.1)(typescript@5.6.3)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/utils': 7.2.0(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.6.3)
 | 
			
		||||
      '@typescript-eslint/utils': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      debug: 4.3.6
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      ts-api-utils: 1.3.0(typescript@5.6.2)
 | 
			
		||||
      ts-api-utils: 1.3.0(typescript@5.6.3)
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      typescript: 5.6.2
 | 
			
		||||
      typescript: 5.6.3
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  '@typescript-eslint/types@7.2.0': {}
 | 
			
		||||
 | 
			
		||||
  '@typescript-eslint/typescript-estree@7.2.0(typescript@5.6.2)':
 | 
			
		||||
  '@typescript-eslint/typescript-estree@7.2.0(typescript@5.6.3)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@typescript-eslint/types': 7.2.0
 | 
			
		||||
      '@typescript-eslint/visitor-keys': 7.2.0
 | 
			
		||||
@ -3076,20 +3098,20 @@ snapshots:
 | 
			
		||||
      is-glob: 4.0.3
 | 
			
		||||
      minimatch: 9.0.3
 | 
			
		||||
      semver: 7.6.3
 | 
			
		||||
      ts-api-utils: 1.3.0(typescript@5.6.2)
 | 
			
		||||
      ts-api-utils: 1.3.0(typescript@5.6.3)
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      typescript: 5.6.2
 | 
			
		||||
      typescript: 5.6.3
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  '@typescript-eslint/utils@7.2.0(eslint@8.57.1)(typescript@5.6.2)':
 | 
			
		||||
  '@typescript-eslint/utils@7.2.0(eslint@8.57.1)(typescript@5.6.3)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1)
 | 
			
		||||
      '@types/json-schema': 7.0.15
 | 
			
		||||
      '@types/semver': 7.5.8
 | 
			
		||||
      '@typescript-eslint/scope-manager': 7.2.0
 | 
			
		||||
      '@typescript-eslint/types': 7.2.0
 | 
			
		||||
      '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.6.3)
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      semver: 7.6.3
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
@ -3759,41 +3781,41 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  escape-string-regexp@4.0.0: {}
 | 
			
		||||
 | 
			
		||||
  eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint@8.57.1):
 | 
			
		||||
  eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      confusing-browser-globals: 1.0.11
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      object.assign: 4.1.5
 | 
			
		||||
      object.entries: 1.1.8
 | 
			
		||||
      semver: 6.3.1
 | 
			
		||||
 | 
			
		||||
  eslint-config-airbnb@19.0.4(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.0(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.1(eslint@8.57.1))(eslint@8.57.1):
 | 
			
		||||
  eslint-config-airbnb@19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.0(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.1(eslint@8.57.1))(eslint@8.57.1):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-react: 7.37.1(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1)
 | 
			
		||||
      object.assign: 4.1.5
 | 
			
		||||
      object.entries: 1.1.8
 | 
			
		||||
 | 
			
		||||
  eslint-config-next@14.2.8(eslint@8.57.1)(typescript@5.6.2):
 | 
			
		||||
  eslint-config-next@14.2.8(eslint@8.57.1)(typescript@5.6.3):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@next/eslint-plugin-next': 14.2.8
 | 
			
		||||
      '@rushstack/eslint-patch': 1.10.4
 | 
			
		||||
      '@typescript-eslint/eslint-plugin': 7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/eslint-plugin': 7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      eslint-import-resolver-node: 0.3.9
 | 
			
		||||
      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-react: 7.37.1(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1)
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      typescript: 5.6.2
 | 
			
		||||
      typescript: 5.6.3
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - eslint-import-resolver-webpack
 | 
			
		||||
      - eslint-plugin-import-x
 | 
			
		||||
@ -3811,37 +3833,48 @@ snapshots:
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1):
 | 
			
		||||
  eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@nolyfill/is-core-module': 1.0.39
 | 
			
		||||
      debug: 4.3.6
 | 
			
		||||
      enhanced-resolve: 5.17.1
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
      eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
      fast-glob: 3.3.2
 | 
			
		||||
      get-tsconfig: 4.8.0
 | 
			
		||||
      is-bun-module: 1.1.0
 | 
			
		||||
      is-glob: 4.0.3
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
      eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - '@typescript-eslint/parser'
 | 
			
		||||
      - eslint-import-resolver-node
 | 
			
		||||
      - eslint-import-resolver-webpack
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  eslint-module-utils@2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1):
 | 
			
		||||
  eslint-module-utils@2.12.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      debug: 3.2.7
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      eslint-import-resolver-node: 0.3.9
 | 
			
		||||
      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1)
 | 
			
		||||
      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1):
 | 
			
		||||
  eslint-module-utils@2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      debug: 3.2.7
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      eslint-import-resolver-node: 0.3.9
 | 
			
		||||
      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@rtsao/scc': 1.1.0
 | 
			
		||||
      array-includes: 3.1.8
 | 
			
		||||
@ -3852,7 +3885,7 @@ snapshots:
 | 
			
		||||
      doctrine: 2.1.0
 | 
			
		||||
      eslint: 8.57.1
 | 
			
		||||
      eslint-import-resolver-node: 0.3.9
 | 
			
		||||
      eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
      eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
 | 
			
		||||
      hasown: 2.0.2
 | 
			
		||||
      is-core-module: 2.15.1
 | 
			
		||||
      is-glob: 4.0.3
 | 
			
		||||
@ -3861,9 +3894,10 @@ snapshots:
 | 
			
		||||
      object.groupby: 1.0.3
 | 
			
		||||
      object.values: 1.2.0
 | 
			
		||||
      semver: 6.3.1
 | 
			
		||||
      string.prototype.trimend: 1.0.8
 | 
			
		||||
      tsconfig-paths: 3.15.0
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.2)
 | 
			
		||||
      '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.6.3)
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - eslint-import-resolver-typescript
 | 
			
		||||
      - eslint-import-resolver-webpack
 | 
			
		||||
@ -5355,11 +5389,11 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  systeminformation@5.23.5: {}
 | 
			
		||||
 | 
			
		||||
  tailwind-scrollbar@3.1.0(tailwindcss@3.4.13):
 | 
			
		||||
  tailwind-scrollbar@3.1.0(tailwindcss@3.4.14):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      tailwindcss: 3.4.13
 | 
			
		||||
      tailwindcss: 3.4.14
 | 
			
		||||
 | 
			
		||||
  tailwindcss@3.4.13:
 | 
			
		||||
  tailwindcss@3.4.14:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@alloc/quick-lru': 5.2.0
 | 
			
		||||
      arg: 5.0.2
 | 
			
		||||
@ -5454,9 +5488,9 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  triple-beam@1.4.1: {}
 | 
			
		||||
 | 
			
		||||
  ts-api-utils@1.3.0(typescript@5.6.2):
 | 
			
		||||
  ts-api-utils@1.3.0(typescript@5.6.3):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      typescript: 5.6.2
 | 
			
		||||
      typescript: 5.6.3
 | 
			
		||||
 | 
			
		||||
  ts-interface-checker@0.1.13: {}
 | 
			
		||||
 | 
			
		||||
@ -5515,7 +5549,7 @@ snapshots:
 | 
			
		||||
      is-typed-array: 1.1.13
 | 
			
		||||
      possible-typed-array-names: 1.0.0
 | 
			
		||||
 | 
			
		||||
  typescript@5.6.2: {}
 | 
			
		||||
  typescript@5.6.3: {}
 | 
			
		||||
 | 
			
		||||
  unbox-primitive@1.0.2:
 | 
			
		||||
    dependencies:
 | 
			
		||||
@ -5536,7 +5570,7 @@ snapshots:
 | 
			
		||||
      escalade: 3.2.0
 | 
			
		||||
      picocolors: 1.1.0
 | 
			
		||||
 | 
			
		||||
  urbackup-server-api@0.52.0:
 | 
			
		||||
  urbackup-server-api@0.52.1:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      async-mutex: 0.5.0
 | 
			
		||||
      node-fetch: 2.7.0
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@
 | 
			
		||||
        "ms": "{{value, number}}",
 | 
			
		||||
        "date": "{{value, date}}",
 | 
			
		||||
        "relativeDate": "{{value, relativeDate}}",
 | 
			
		||||
        "uptime": "{{value, uptime}}",
 | 
			
		||||
        "duration": "{{value, duration}}",
 | 
			
		||||
        "months": "mo",
 | 
			
		||||
        "days": "d",
 | 
			
		||||
        "hours": "h",
 | 
			
		||||
@ -309,6 +309,16 @@
 | 
			
		||||
        "stopped": "Stopped",
 | 
			
		||||
        "total": "Total"
 | 
			
		||||
    },
 | 
			
		||||
    "suwayomi": {
 | 
			
		||||
      "download": "Downloaded",
 | 
			
		||||
      "nondownload": "Non-Downloaded",
 | 
			
		||||
      "read": "Read",
 | 
			
		||||
      "unread": "Unread",
 | 
			
		||||
      "downloadedread": "Downloaded & Read",
 | 
			
		||||
      "downloadedunread": "Downloaded & Unread",
 | 
			
		||||
      "nondownloadedread": "Non-Downloaded & Read",
 | 
			
		||||
      "nondownloadedunread": "Non-Downloaded & Unread"
 | 
			
		||||
    },
 | 
			
		||||
    "tailscale": {
 | 
			
		||||
        "address": "Address",
 | 
			
		||||
        "expires": "Expires",
 | 
			
		||||
@ -959,5 +969,43 @@
 | 
			
		||||
      "tasks7d": "Tasks Due This Week",
 | 
			
		||||
      "tasksOverdue": "Overdue Tasks",
 | 
			
		||||
      "tasksInProgress": "Tasks In Progress"
 | 
			
		||||
    },
 | 
			
		||||
    "headscale": {
 | 
			
		||||
      "name": "Name",
 | 
			
		||||
      "address": "Address",
 | 
			
		||||
      "last_seen": "Last Seen",
 | 
			
		||||
      "status": "Status",
 | 
			
		||||
      "online": "Online",
 | 
			
		||||
      "offline": "Offline"
 | 
			
		||||
    },
 | 
			
		||||
    "beszel": {
 | 
			
		||||
      "name": "Name",
 | 
			
		||||
      "systems": "Systems",
 | 
			
		||||
      "up": "Up",
 | 
			
		||||
      "status": "Status",
 | 
			
		||||
      "updated": "Updated",
 | 
			
		||||
      "cpu": "CPU",
 | 
			
		||||
      "memory": "MEM",
 | 
			
		||||
      "disk": "Disk",
 | 
			
		||||
      "network": "NET"
 | 
			
		||||
    },
 | 
			
		||||
    "argocd": {
 | 
			
		||||
        "apps": "Apps",
 | 
			
		||||
        "synced": "Synced",
 | 
			
		||||
        "outOfSync": "Out Of Sync",
 | 
			
		||||
        "healthy": "Healthy",
 | 
			
		||||
        "degraded": "Degraded",
 | 
			
		||||
        "progressing": "Progressing",
 | 
			
		||||
        "missing": "Missing",
 | 
			
		||||
        "suspended": "Suspended"
 | 
			
		||||
    },
 | 
			
		||||
    "spoolman": {
 | 
			
		||||
        "loading": "Loading"
 | 
			
		||||
    },
 | 
			
		||||
    "gitlab": {
 | 
			
		||||
        "groups": "Groups",
 | 
			
		||||
        "issues": "Issues",
 | 
			
		||||
        "merges": "Merge Requests",
 | 
			
		||||
        "projects": "Projects"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@ export default function BookmarksGroup({ bookmarks, layout, disableCollapse, gro
 | 
			
		||||
      className={classNames(
 | 
			
		||||
        "bookmark-group",
 | 
			
		||||
        layout?.style === "row" ? "basis-full" : "basis-full md:basis-1/4 lg:basis-1/5 xl:basis-1/6",
 | 
			
		||||
        layout?.header === false ? "flex-1 px-1 -my-1" : "flex-1 p-1",
 | 
			
		||||
        layout?.header === false ? "flex-1 px-1 -my-1 overflow-hidden" : "flex-1 p-1 overflow-hidden",
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      <Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
 | 
			
		||||
 | 
			
		||||
@ -29,9 +29,9 @@ export default function Item({ bookmark }) {
 | 
			
		||||
            )}
 | 
			
		||||
            {!bookmark.icon && bookmark.abbr}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="flex-1 flex items-center justify-between rounded-r-md bookmark-text">
 | 
			
		||||
            <div className="flex-1 grow pl-3 py-2 text-xs bookmark-name">{bookmark.name}</div>
 | 
			
		||||
            <div className="px-2 py-2 truncate text-theme-500 dark:text-theme-300 text-xs bookmark-description">
 | 
			
		||||
          <div className="flex-1 overflow-hidden flex items-center justify-between rounded-r-md bookmark-text">
 | 
			
		||||
            <div className="pl-3 py-2 text-xs bookmark-name">{bookmark.name}</div>
 | 
			
		||||
            <div className="shrink truncate px-2 py-2 text-theme-500 dark:text-theme-300 text-xs bookmark-description">
 | 
			
		||||
              {description}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
@ -98,6 +98,12 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
 | 
			
		||||
    } else if (event.key === "ArrowUp" && currentItemIndex > 0) {
 | 
			
		||||
      setCurrentItemIndex(currentItemIndex - 1);
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
    } else if (
 | 
			
		||||
      event.key === "ArrowRight" &&
 | 
			
		||||
      results[currentItemIndex] &&
 | 
			
		||||
      results[currentItemIndex].type === "searchSuggestion"
 | 
			
		||||
    ) {
 | 
			
		||||
      setSearchString(results[currentItemIndex].name);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -25,7 +25,7 @@ export default function Uptime({ refresh = 1500 }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Resource
 | 
			
		||||
      icon={FaRegClock}
 | 
			
		||||
      value={t("common.uptime", { value: data.uptime })}
 | 
			
		||||
      value={t("common.duration", { value: data.uptime })}
 | 
			
		||||
      label={t("resources.uptime")}
 | 
			
		||||
      percentage={percent}
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,7 @@ export default function Document() {
 | 
			
		||||
          name="description"
 | 
			
		||||
          content="A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations."
 | 
			
		||||
        />
 | 
			
		||||
        <meta name="apple-mobile-web-app-capable" content="yes" />
 | 
			
		||||
        <meta name="mobile-web-app-capable" content="yes" />
 | 
			
		||||
        <link rel="manifest" href="/site.webmanifest?v=4" crossOrigin="use-credentials" />
 | 
			
		||||
      </Head>
 | 
			
		||||
      <body>
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import cachedFetch from "utils/proxy/cached-fetch";
 | 
			
		||||
 | 
			
		||||
export default async function handler(req, res) {
 | 
			
		||||
  const { latitude, longitude, units, cache, timezone } = req.query;
 | 
			
		||||
  const degrees = units === "imperial" ? "fahrenheit" : "celsius";
 | 
			
		||||
  const degrees = units === "metric" ? "celsius" : "fahrenheit";
 | 
			
		||||
  const timezeone = timezone ?? "auto";
 | 
			
		||||
  const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=sunrise,sunset¤t_weather=true&temperature_unit=${degrees}&timezone=${timezeone}`;
 | 
			
		||||
  return res.send(await cachedFetch(apiUrl, cache));
 | 
			
		||||
 | 
			
		||||
@ -368,6 +368,9 @@ export function cleanServiceGroups(groups) {
 | 
			
		||||
          repositoryId,
 | 
			
		||||
          userEmail,
 | 
			
		||||
 | 
			
		||||
          // beszel
 | 
			
		||||
          systemId,
 | 
			
		||||
 | 
			
		||||
          // calendar
 | 
			
		||||
          firstDayInWeek,
 | 
			
		||||
          integrations,
 | 
			
		||||
@ -415,7 +418,7 @@ export function cleanServiceGroups(groups) {
 | 
			
		||||
          pointsLimit,
 | 
			
		||||
          diskUnits,
 | 
			
		||||
 | 
			
		||||
          // glances, customapi, iframe
 | 
			
		||||
          // glances, customapi, iframe, prometheusmetric
 | 
			
		||||
          refreshInterval,
 | 
			
		||||
 | 
			
		||||
          // hdhomerun
 | 
			
		||||
@ -458,6 +461,9 @@ export function cleanServiceGroups(groups) {
 | 
			
		||||
          // opnsense, pfsense
 | 
			
		||||
          wan,
 | 
			
		||||
 | 
			
		||||
          // prometheusmetric
 | 
			
		||||
          metrics,
 | 
			
		||||
 | 
			
		||||
          // proxmox
 | 
			
		||||
          node,
 | 
			
		||||
 | 
			
		||||
@ -486,6 +492,9 @@ export function cleanServiceGroups(groups) {
 | 
			
		||||
 | 
			
		||||
          // technitium
 | 
			
		||||
          range,
 | 
			
		||||
 | 
			
		||||
          // spoolman
 | 
			
		||||
          spoolIds,
 | 
			
		||||
        } = cleanedService.widget;
 | 
			
		||||
 | 
			
		||||
        let fieldsList = fields;
 | 
			
		||||
@ -511,6 +520,10 @@ export function cleanServiceGroups(groups) {
 | 
			
		||||
          if (repositoryId) cleanedService.widget.repositoryId = repositoryId;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (type === "beszel") {
 | 
			
		||||
          if (systemId) cleanedService.widget.systemId = systemId;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (type === "coinmarketcap") {
 | 
			
		||||
          if (currency) cleanedService.widget.currency = currency;
 | 
			
		||||
          if (symbols) cleanedService.widget.symbols = symbols;
 | 
			
		||||
@ -639,6 +652,13 @@ export function cleanServiceGroups(groups) {
 | 
			
		||||
        if (type === "vikunja") {
 | 
			
		||||
          if (enableTaskList !== undefined) cleanedService.widget.enableTaskList = !!enableTaskList;
 | 
			
		||||
        }
 | 
			
		||||
        if (type === "prometheusmetric") {
 | 
			
		||||
          if (metrics) cleanedService.widget.metrics = metrics;
 | 
			
		||||
          if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;
 | 
			
		||||
        }
 | 
			
		||||
        if (type === "spoolman") {
 | 
			
		||||
          if (spoolIds !== undefined) cleanedService.widget.spoolIds = spoolIds;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return cleanedService;
 | 
			
		||||
 | 
			
		||||
@ -36,9 +36,11 @@ export default async function credentialedProxyHandler(req, res, map) {
 | 
			
		||||
        headers["X-gotify-Key"] = `${widget.key}`;
 | 
			
		||||
      } else if (
 | 
			
		||||
        [
 | 
			
		||||
          "argocd",
 | 
			
		||||
          "authentik",
 | 
			
		||||
          "cloudflared",
 | 
			
		||||
          "ghostfolio",
 | 
			
		||||
          "headscale",
 | 
			
		||||
          "linkwarden",
 | 
			
		||||
          "mealie",
 | 
			
		||||
          "netalertx",
 | 
			
		||||
@ -92,6 +94,8 @@ export default async function credentialedProxyHandler(req, res, map) {
 | 
			
		||||
        }
 | 
			
		||||
      } else if (widget.type === "wgeasy") {
 | 
			
		||||
        headers.Authorization = widget.password;
 | 
			
		||||
      } else if (widget.type === "gitlab") {
 | 
			
		||||
        headers["PRIVATE-TOKEN"] = widget.key;
 | 
			
		||||
      } else {
 | 
			
		||||
        headers["X-API-Key"] = `${widget.key}`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,7 @@ export default async function genericProxyHandler(req, res, map) {
 | 
			
		||||
        formatApiCall(widgets[widget.type].api, { endpoint, ...widget }).replace(/(?<=\?.*)\?/g, "&"),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const headers = req.extraHeaders ?? widget.headers ?? {};
 | 
			
		||||
      const headers = req.extraHeaders ?? widget.headers ?? widgets[widget.type].headers ?? {};
 | 
			
		||||
 | 
			
		||||
      if (widget.username && widget.password) {
 | 
			
		||||
        headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
 | 
			
		||||
@ -75,7 +75,13 @@ export default async function genericProxyHandler(req, res, map) {
 | 
			
		||||
          url.port ? `:${url.port}` : "",
 | 
			
		||||
          url.pathname,
 | 
			
		||||
        );
 | 
			
		||||
        return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url), resultData } });
 | 
			
		||||
        return res.status(status).json({
 | 
			
		||||
          error: {
 | 
			
		||||
            message: "HTTP Error",
 | 
			
		||||
            url: sanitizeErrorURL(url),
 | 
			
		||||
            resultData: Buffer.isBuffer(resultData) ? Buffer.from(resultData).toString() : resultData,
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return res.status(status).send(resultData);
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										52
									
								
								src/widgets/argocd/component.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/widgets/argocd/component.jsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
			
		||||
import Container from "components/services/widget/container";
 | 
			
		||||
import Block from "components/services/widget/block";
 | 
			
		||||
import useWidgetAPI from "utils/proxy/use-widget-api";
 | 
			
		||||
 | 
			
		||||
export default function Component({ service }) {
 | 
			
		||||
  const { widget } = service;
 | 
			
		||||
 | 
			
		||||
  if (!widget.fields) {
 | 
			
		||||
    widget.fields = ["apps", "synced", "outOfSync", "healthy"];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const MAX_ALLOWED_FIELDS = 4;
 | 
			
		||||
  if (widget.fields.length > MAX_ALLOWED_FIELDS) {
 | 
			
		||||
    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { data: appsData, error: appsError } = useWidgetAPI(widget, "applications");
 | 
			
		||||
 | 
			
		||||
  const appCounts = widget.fields.map((status) => {
 | 
			
		||||
    if (status === "apps") {
 | 
			
		||||
      return { status, count: appsData?.items?.length };
 | 
			
		||||
    }
 | 
			
		||||
    const count = appsData?.items?.filter(
 | 
			
		||||
      (item) =>
 | 
			
		||||
        item.status?.sync?.status.toLowerCase() === status.toLowerCase() ||
 | 
			
		||||
        item.status?.health?.status.toLowerCase() === status.toLowerCase(),
 | 
			
		||||
    ).length;
 | 
			
		||||
    return { status, count };
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (appsError) {
 | 
			
		||||
    return <Container service={service} error={appsError} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!appsData) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        {appCounts.map((a) => (
 | 
			
		||||
          <Block label={`argocd.${a.status}`} key={a.status} />
 | 
			
		||||
        ))}
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      {appCounts.map((a) => (
 | 
			
		||||
        <Block label={`argocd.${a.status}`} key={a.status} value={a.count} />
 | 
			
		||||
      ))}
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/widgets/argocd/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/widgets/argocd/widget.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
  api: "{url}/api/v1/{endpoint}",
 | 
			
		||||
  proxyHandler: credentialedProxyHandler,
 | 
			
		||||
 | 
			
		||||
  mappings: {
 | 
			
		||||
    applications: {
 | 
			
		||||
      endpoint: "applications",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default widget;
 | 
			
		||||
@ -39,21 +39,15 @@ export default function Component({ service }) {
 | 
			
		||||
      <Block label="audiobookshelf.podcasts" value={t("common.number", { value: totalPodcasts })} />
 | 
			
		||||
      <Block
 | 
			
		||||
        label="audiobookshelf.podcastsDuration"
 | 
			
		||||
        value={t("common.number", {
 | 
			
		||||
          value: totalPodcastsDuration / 60,
 | 
			
		||||
          maximumFractionDigits: 0,
 | 
			
		||||
          style: "unit",
 | 
			
		||||
          unit: "minute",
 | 
			
		||||
        value={t("common.duration", {
 | 
			
		||||
          value: totalPodcastsDuration,
 | 
			
		||||
        })}
 | 
			
		||||
      />
 | 
			
		||||
      <Block label="audiobookshelf.books" value={t("common.number", { value: totalBooks })} />
 | 
			
		||||
      <Block
 | 
			
		||||
        label="audiobookshelf.booksDuration"
 | 
			
		||||
        value={t("common.number", {
 | 
			
		||||
          value: totalBooksDuration / 60,
 | 
			
		||||
          maximumFractionDigits: 0,
 | 
			
		||||
          style: "unit",
 | 
			
		||||
          unit: "minute",
 | 
			
		||||
        value={t("common.duration", {
 | 
			
		||||
          value: totalBooksDuration,
 | 
			
		||||
        })}
 | 
			
		||||
      />
 | 
			
		||||
    </Container>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										60
									
								
								src/widgets/beszel/component.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/widgets/beszel/component.jsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
import { useTranslation } from "next-i18next";
 | 
			
		||||
 | 
			
		||||
import Container from "components/services/widget/container";
 | 
			
		||||
import Block from "components/services/widget/block";
 | 
			
		||||
import useWidgetAPI from "utils/proxy/use-widget-api";
 | 
			
		||||
 | 
			
		||||
export default function Component({ service }) {
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const { widget } = service;
 | 
			
		||||
  const { systemId } = widget;
 | 
			
		||||
 | 
			
		||||
  const { data: systems, error: systemsError } = useWidgetAPI(widget, "systems");
 | 
			
		||||
 | 
			
		||||
  const MAX_ALLOWED_FIELDS = 4;
 | 
			
		||||
  if (!widget.fields?.length > 0) {
 | 
			
		||||
    widget.fields = systemId ? ["name", "status", "cpu", "memory"] : ["systems", "up"];
 | 
			
		||||
  }
 | 
			
		||||
  if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
 | 
			
		||||
    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (systemsError) {
 | 
			
		||||
    return <Container service={service} error={systemsError} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!systems) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        <Block label="beszel.systems" />
 | 
			
		||||
        <Block label="beszel.up" />
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (systemId) {
 | 
			
		||||
    const system = systems.items.find((item) => item.id === systemId);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        <Block label="beszel.name" value={system.name} />
 | 
			
		||||
        <Block label="beszel.status" value={t(`beszel.${system.status}`)} />
 | 
			
		||||
        <Block label="beszel.updated" value={t("common.relativeDate", { value: system.updated })} />
 | 
			
		||||
        <Block label="beszel.cpu" value={t("common.percent", { value: system.info.cpu, maximumFractionDigits: 2 })} />
 | 
			
		||||
        <Block label="beszel.memory" value={t("common.percent", { value: system.info.mp, maximumFractionDigits: 2 })} />
 | 
			
		||||
        <Block label="beszel.disk" value={t("common.percent", { value: system.info.dp, maximumFractionDigits: 2 })} />
 | 
			
		||||
        <Block label="beszel.network" value={t("common.percent", { value: system.info.b, maximumFractionDigits: 2 })} />
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const upTotal = systems.items.filter((item) => item.status === "up").length;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      <Block label="beszel.systems" value={systems.totalItems} />
 | 
			
		||||
      <Block label="beszel.up" value={`${upTotal} / ${systems.totalItems}`} />
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										99
									
								
								src/widgets/beszel/proxy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/widgets/beszel/proxy.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,99 @@
 | 
			
		||||
import cache from "memory-cache";
 | 
			
		||||
 | 
			
		||||
import getServiceWidget from "utils/config/service-helpers";
 | 
			
		||||
import { formatApiCall } from "utils/proxy/api-helpers";
 | 
			
		||||
import { httpProxy } from "utils/proxy/http";
 | 
			
		||||
import widgets from "widgets/widgets";
 | 
			
		||||
import createLogger from "utils/logger";
 | 
			
		||||
 | 
			
		||||
const proxyName = "beszelProxyHandler";
 | 
			
		||||
const tokenCacheKey = `${proxyName}__token`;
 | 
			
		||||
const logger = createLogger(proxyName);
 | 
			
		||||
 | 
			
		||||
async function login(loginUrl, username, password, service) {
 | 
			
		||||
  const authResponse = await httpProxy(loginUrl, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    body: JSON.stringify({ identity: username, password }),
 | 
			
		||||
    headers: {
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const status = authResponse[0];
 | 
			
		||||
  let data = authResponse[2];
 | 
			
		||||
  try {
 | 
			
		||||
    data = JSON.parse(Buffer.from(authResponse[2]).toString());
 | 
			
		||||
 | 
			
		||||
    if (status === 200) {
 | 
			
		||||
      cache.put(`${tokenCacheKey}.${service}`, data.token);
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    logger.error(`Error ${status} logging into beszel`, JSON.stringify(authResponse[2]));
 | 
			
		||||
  }
 | 
			
		||||
  return [status, data.token ?? data];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default async function beszelProxyHandler(req, res) {
 | 
			
		||||
  const { group, service, endpoint } = req.query;
 | 
			
		||||
 | 
			
		||||
  if (group && service) {
 | 
			
		||||
    const widget = await getServiceWidget(group, service);
 | 
			
		||||
 | 
			
		||||
    if (!widgets?.[widget.type]?.api) {
 | 
			
		||||
      return res.status(403).json({ error: "Service does not support API calls" });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (widget) {
 | 
			
		||||
      const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
 | 
			
		||||
      const loginUrl = formatApiCall(widgets[widget.type].api, { endpoint: "admins/auth-with-password", ...widget });
 | 
			
		||||
 | 
			
		||||
      let status;
 | 
			
		||||
      let data;
 | 
			
		||||
 | 
			
		||||
      let token = cache.get(`${tokenCacheKey}.${service}`);
 | 
			
		||||
      if (!token) {
 | 
			
		||||
        [status, token] = await login(loginUrl, widget.username, widget.password, service);
 | 
			
		||||
        if (status !== 200) {
 | 
			
		||||
          logger.debug(`HTTP ${status} logging into npm api: ${token}`);
 | 
			
		||||
          return res.status(status).send(token);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      [status, , data] = await httpProxy(url, {
 | 
			
		||||
        method: "GET",
 | 
			
		||||
        headers: {
 | 
			
		||||
          "Content-Type": "application/json",
 | 
			
		||||
          Authorization: `Bearer ${token}`,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (status === 403) {
 | 
			
		||||
        logger.debug(`HTTP ${status} retrieving data from npm api, logging in and trying again.`);
 | 
			
		||||
        cache.del(`${tokenCacheKey}.${service}`);
 | 
			
		||||
        [status, token] = await login(loginUrl, widget.username, widget.password, service);
 | 
			
		||||
 | 
			
		||||
        if (status !== 200) {
 | 
			
		||||
          logger.debug(`HTTP ${status} logging into npm api: ${data}`);
 | 
			
		||||
          return res.status(status).send(data);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // eslint-disable-next-line no-unused-vars
 | 
			
		||||
        [status, , data] = await httpProxy(url, {
 | 
			
		||||
          method: "GET",
 | 
			
		||||
          headers: {
 | 
			
		||||
            "Content-Type": "application/json",
 | 
			
		||||
            Authorization: `Bearer ${token}`,
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (status !== 200) {
 | 
			
		||||
        return res.status(status).send(data);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return res.send(data);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return res.status(400).json({ error: "Invalid proxy service type" });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/widgets/beszel/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/widgets/beszel/widget.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
import beszelProxyHandler from "./proxy";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
  api: "{url}/api/{endpoint}",
 | 
			
		||||
  proxyHandler: beszelProxyHandler,
 | 
			
		||||
 | 
			
		||||
  mappings: {
 | 
			
		||||
    systems: {
 | 
			
		||||
      endpoint: "collections/systems/records?page=1&perPage=500&sort=%2Bcreated",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default widget;
 | 
			
		||||
@ -21,7 +21,7 @@ export default async function calendarProxyHandler(req, res) {
 | 
			
		||||
      if (contentType) res.setHeader("Content-Type", contentType);
 | 
			
		||||
 | 
			
		||||
      if (status !== 200) {
 | 
			
		||||
        logger.debug(`HTTTP ${status} retrieving data from integration URL ${integration.url} : ${data}`);
 | 
			
		||||
        logger.debug(`HTTP ${status} retrieving data from integration URL ${integration.url} : ${data}`);
 | 
			
		||||
        return res.status(status).send(data);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,12 +2,14 @@ import dynamic from "next/dynamic";
 | 
			
		||||
 | 
			
		||||
const components = {
 | 
			
		||||
  adguard: dynamic(() => import("./adguard/component")),
 | 
			
		||||
  argocd: dynamic(() => import("./argocd/component")),
 | 
			
		||||
  atsumeru: dynamic(() => import("./atsumeru/component")),
 | 
			
		||||
  audiobookshelf: dynamic(() => import("./audiobookshelf/component")),
 | 
			
		||||
  authentik: dynamic(() => import("./authentik/component")),
 | 
			
		||||
  autobrr: dynamic(() => import("./autobrr/component")),
 | 
			
		||||
  azuredevops: dynamic(() => import("./azuredevops/component")),
 | 
			
		||||
  bazarr: dynamic(() => import("./bazarr/component")),
 | 
			
		||||
  beszel: dynamic(() => import("./beszel/component")),
 | 
			
		||||
  caddy: dynamic(() => import("./caddy/component")),
 | 
			
		||||
  calendar: dynamic(() => import("./calendar/component")),
 | 
			
		||||
  calibreweb: dynamic(() => import("./calibreweb/component")),
 | 
			
		||||
@ -36,11 +38,13 @@ const components = {
 | 
			
		||||
  gatus: dynamic(() => import("./gatus/component")),
 | 
			
		||||
  ghostfolio: dynamic(() => import("./ghostfolio/component")),
 | 
			
		||||
  gitea: dynamic(() => import("./gitea/component")),
 | 
			
		||||
  gitlab: dynamic(() => import("./gitlab/component")),
 | 
			
		||||
  glances: dynamic(() => import("./glances/component")),
 | 
			
		||||
  gluetun: dynamic(() => import("./gluetun/component")),
 | 
			
		||||
  gotify: dynamic(() => import("./gotify/component")),
 | 
			
		||||
  grafana: dynamic(() => import("./grafana/component")),
 | 
			
		||||
  hdhomerun: dynamic(() => import("./hdhomerun/component")),
 | 
			
		||||
  headscale: dynamic(() => import("./headscale/component")),
 | 
			
		||||
  peanut: dynamic(() => import("./peanut/component")),
 | 
			
		||||
  homeassistant: dynamic(() => import("./homeassistant/component")),
 | 
			
		||||
  homebox: dynamic(() => import("./homebox/component")),
 | 
			
		||||
@ -93,6 +97,7 @@ const components = {
 | 
			
		||||
  plex: dynamic(() => import("./plex/component")),
 | 
			
		||||
  portainer: dynamic(() => import("./portainer/component")),
 | 
			
		||||
  prometheus: dynamic(() => import("./prometheus/component")),
 | 
			
		||||
  prometheusmetric: dynamic(() => import("./prometheusmetric/component")),
 | 
			
		||||
  prowlarr: dynamic(() => import("./prowlarr/component")),
 | 
			
		||||
  proxmox: dynamic(() => import("./proxmox/component")),
 | 
			
		||||
  pterodactyl: dynamic(() => import("./pterodactyl/component")),
 | 
			
		||||
@ -107,10 +112,12 @@ const components = {
 | 
			
		||||
  scrutiny: dynamic(() => import("./scrutiny/component")),
 | 
			
		||||
  sonarr: dynamic(() => import("./sonarr/component")),
 | 
			
		||||
  speedtest: dynamic(() => import("./speedtest/component")),
 | 
			
		||||
  spoolman: dynamic(() => import("./spoolman/component")),
 | 
			
		||||
  stash: dynamic(() => import("./stash/component")),
 | 
			
		||||
  stocks: dynamic(() => import("./stocks/component")),
 | 
			
		||||
  strelaysrv: dynamic(() => import("./strelaysrv/component")),
 | 
			
		||||
  swagdashboard: dynamic(() => import("./swagdashboard/component")),
 | 
			
		||||
  suwayomi: dynamic(() => import("./suwayomi/component")),
 | 
			
		||||
  tailscale: dynamic(() => import("./tailscale/component")),
 | 
			
		||||
  tandoor: dynamic(() => import("./tandoor/component")),
 | 
			
		||||
  tautulli: dynamic(() => import("./tautulli/component")),
 | 
			
		||||
 | 
			
		||||
@ -40,7 +40,7 @@ export default function Component({ service }) {
 | 
			
		||||
        />
 | 
			
		||||
        <Block
 | 
			
		||||
          label="frigate.uptime"
 | 
			
		||||
          value={t("common.uptime", {
 | 
			
		||||
          value={t("common.duration", {
 | 
			
		||||
            value: data.uptime,
 | 
			
		||||
          })}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
@ -44,7 +44,7 @@ export default function Component({ service }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      <Block label="fritzbox.connectionStatus" value={t(`fritzbox.connectionStatus${fritzboxData.connectionStatus}`)} />
 | 
			
		||||
      <Block label="fritzbox.uptime" value={t("common.uptime", { value: fritzboxData.uptime })} />
 | 
			
		||||
      <Block label="fritzbox.uptime" value={t("common.duration", { value: fritzboxData.uptime })} />
 | 
			
		||||
      <Block label="fritzbox.maxDown" value={t("common.byterate", { value: fritzboxData.maxDown / 8, decimals: 1 })} />
 | 
			
		||||
      <Block label="fritzbox.maxUp" value={t("common.byterate", { value: fritzboxData.maxUp / 8, decimals: 1 })} />
 | 
			
		||||
      <Block label="fritzbox.down" value={t("common.byterate", { value: fritzboxData.down, decimals: 1 })} />
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										36
									
								
								src/widgets/gitlab/component.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/widgets/gitlab/component.jsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,36 @@
 | 
			
		||||
import { useTranslation } from "next-i18next";
 | 
			
		||||
 | 
			
		||||
import Container from "components/services/widget/container";
 | 
			
		||||
import Block from "components/services/widget/block";
 | 
			
		||||
import useWidgetAPI from "utils/proxy/use-widget-api";
 | 
			
		||||
 | 
			
		||||
export default function Component({ service }) {
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const { widget } = service;
 | 
			
		||||
 | 
			
		||||
  const { data: gitlabCounts, error: gitlabCountsError } = useWidgetAPI(widget, "counts");
 | 
			
		||||
 | 
			
		||||
  if (gitlabCountsError) {
 | 
			
		||||
    return <Container service={service} error={gitlabCountsError} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!gitlabCounts) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        <Block label="gitlab.groups" />
 | 
			
		||||
        <Block label="gitlab.issues" />
 | 
			
		||||
        <Block label="gitlab.merges" />
 | 
			
		||||
        <Block label="gitlab.projects" />
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      <Block label="gitlab.groups" value={t("common.number", { value: gitlabCounts.groups_count })} />
 | 
			
		||||
      <Block label="gitlab.issues" value={t("common.number", { value: gitlabCounts.issues_count })} />
 | 
			
		||||
      <Block label="gitlab.merges" value={t("common.number", { value: gitlabCounts.merge_requests_count })} />
 | 
			
		||||
      <Block label="gitlab.projects" value={t("common.number", { value: gitlabCounts.projects_count })} />
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								src/widgets/gitlab/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/widgets/gitlab/widget.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
			
		||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
  api: "{url}/api/v4/{endpoint}",
 | 
			
		||||
  proxyHandler: credentialedProxyHandler,
 | 
			
		||||
  mappings: {
 | 
			
		||||
    counts: {
 | 
			
		||||
      endpoint: "users/{user_id}/associations_count",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default widget;
 | 
			
		||||
							
								
								
									
										43
									
								
								src/widgets/headscale/component.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/widgets/headscale/component.jsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
import { useTranslation } from "next-i18next";
 | 
			
		||||
 | 
			
		||||
import Container from "components/services/widget/container";
 | 
			
		||||
import Block from "components/services/widget/block";
 | 
			
		||||
import useWidgetAPI from "utils/proxy/use-widget-api";
 | 
			
		||||
 | 
			
		||||
export default function Component({ service }) {
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const { widget } = service;
 | 
			
		||||
 | 
			
		||||
  const { data: nodeData, error: nodeError } = useWidgetAPI(widget, "node");
 | 
			
		||||
 | 
			
		||||
  if (nodeError) {
 | 
			
		||||
    return <Container service={service} error={nodeError} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!nodeData) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        <Block label="headscale.name" />
 | 
			
		||||
        <Block label="headscale.address" />
 | 
			
		||||
        <Block label="headscale.last_seen" />
 | 
			
		||||
        <Block label="headscale.status" />
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    givenName,
 | 
			
		||||
    ipAddresses: [address],
 | 
			
		||||
    lastSeen,
 | 
			
		||||
    online,
 | 
			
		||||
  } = nodeData.node;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      <Block label="headscale.name" value={givenName} />
 | 
			
		||||
      <Block label="headscale.address" value={address} />
 | 
			
		||||
      <Block label="headscale.last_seen" value={t("common.relativeDate", { value: lastSeen })} />
 | 
			
		||||
      <Block label="headscale.status" value={t(online ? "headscale.online" : "headscale.offline")} />
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/widgets/headscale/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/widgets/headscale/widget.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
  api: "{url}/api/v1/{endpoint}/{nodeId}",
 | 
			
		||||
  proxyHandler: credentialedProxyHandler,
 | 
			
		||||
 | 
			
		||||
  mappings: {
 | 
			
		||||
    node: {
 | 
			
		||||
      endpoint: "node",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default widget;
 | 
			
		||||
@ -21,8 +21,8 @@ export default function Component({ service }) {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const enabled = infoData.filter((c) => c.enabled === 1).length;
 | 
			
		||||
  const disabled = infoData.filter((c) => c.enabled === 0).length;
 | 
			
		||||
  const enabled = infoData.filter((c) => !!c.enabled).length;
 | 
			
		||||
  const disabled = infoData.filter((c) => !c.enabled).length;
 | 
			
		||||
  const total = infoData.length;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 | 
			
		||||
@ -30,7 +30,7 @@ async function login(loginUrl, username, password, service) {
 | 
			
		||||
      cache.put(`${tokenCacheKey}.${service}`, data.token, expiration - 5 * 60 * 1000); // expiration -5 minutes
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    logger.error(`Error ${status} logging into npm`, authResponse[2]);
 | 
			
		||||
    logger.error(`Error ${status} logging into npm`, JSON.stringify(authResponse[2]));
 | 
			
		||||
  }
 | 
			
		||||
  return [status, data.token ?? data];
 | 
			
		||||
}
 | 
			
		||||
@ -50,19 +50,18 @@ export default async function npmProxyHandler(req, res) {
 | 
			
		||||
      const loginUrl = `${widget.url}/api/tokens`;
 | 
			
		||||
 | 
			
		||||
      let status;
 | 
			
		||||
      let contentType;
 | 
			
		||||
      let data;
 | 
			
		||||
 | 
			
		||||
      let token = cache.get(`${tokenCacheKey}.${service}`);
 | 
			
		||||
      if (!token) {
 | 
			
		||||
        [status, token] = await login(loginUrl, widget.username, widget.password, service);
 | 
			
		||||
        if (status !== 200) {
 | 
			
		||||
          logger.debug(`HTTTP ${status} logging into npm api: ${token}`);
 | 
			
		||||
          logger.debug(`HTTP ${status} logging into npm api: ${token}`);
 | 
			
		||||
          return res.status(status).send(token);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      [status, contentType, data] = await httpProxy(url, {
 | 
			
		||||
      [status, , data] = await httpProxy(url, {
 | 
			
		||||
        method: "GET",
 | 
			
		||||
        headers: {
 | 
			
		||||
          "Content-Type": "application/json",
 | 
			
		||||
@ -71,17 +70,17 @@ export default async function npmProxyHandler(req, res) {
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (status === 403) {
 | 
			
		||||
        logger.debug(`HTTTP ${status} retrieving data from npm api, logging in and trying again.`);
 | 
			
		||||
        logger.debug(`HTTP ${status} retrieving data from npm api, logging in and trying again.`);
 | 
			
		||||
        cache.del(`${tokenCacheKey}.${service}`);
 | 
			
		||||
        [status, token] = await login(loginUrl, widget.username, widget.password, service);
 | 
			
		||||
 | 
			
		||||
        if (status !== 200) {
 | 
			
		||||
          logger.debug(`HTTTP ${status} logging into npm api: ${data}`);
 | 
			
		||||
          logger.debug(`HTTP ${status} logging into npm api: ${data}`);
 | 
			
		||||
          return res.status(status).send(data);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // eslint-disable-next-line no-unused-vars
 | 
			
		||||
        [status, contentType, data] = await httpProxy(url, {
 | 
			
		||||
        [status, , data] = await httpProxy(url, {
 | 
			
		||||
          method: "GET",
 | 
			
		||||
          headers: {
 | 
			
		||||
            "Content-Type": "application/json",
 | 
			
		||||
 | 
			
		||||
@ -138,7 +138,7 @@ export default async function omadaProxyHandler(req, res) {
 | 
			
		||||
      const sitesResponseData = JSON.parse(data);
 | 
			
		||||
 | 
			
		||||
      if (status !== 200 || sitesResponseData.errorCode > 0) {
 | 
			
		||||
        logger.debug(`HTTTP ${status} getting sites list: ${sitesResponseData.msg}`);
 | 
			
		||||
        logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
 | 
			
		||||
        return res
 | 
			
		||||
          .status(status)
 | 
			
		||||
          .json({ error: { message: "Error getting sites list", url, data: sitesResponseData } });
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@ export default function Component({ service }) {
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      <Block label="openwrt.uptime" value={t("common.uptime", { value: uptime })} />
 | 
			
		||||
      <Block label="openwrt.uptime" value={t("common.duration", { value: uptime })} />
 | 
			
		||||
      <Block label="openwrt.cpuLoad" value={cpuLoad} />
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										116
									
								
								src/widgets/prometheusmetric/component.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/widgets/prometheusmetric/component.jsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,116 @@
 | 
			
		||||
import { useTranslation } from "next-i18next";
 | 
			
		||||
 | 
			
		||||
import Container from "components/services/widget/container";
 | 
			
		||||
import Block from "components/services/widget/block";
 | 
			
		||||
import useWidgetAPI from "utils/proxy/use-widget-api";
 | 
			
		||||
 | 
			
		||||
function formatValue(t, metric, rawValue) {
 | 
			
		||||
  if (!metric?.format) return rawValue;
 | 
			
		||||
  if (!rawValue) return "-";
 | 
			
		||||
 | 
			
		||||
  let value = rawValue;
 | 
			
		||||
 | 
			
		||||
  // Scale the value. Accepts either a number to multiply by or a string
 | 
			
		||||
  // like "12/345".
 | 
			
		||||
  const scale = metric?.format?.scale;
 | 
			
		||||
  if (typeof scale === "number") {
 | 
			
		||||
    value *= scale;
 | 
			
		||||
  } else if (typeof scale === "string" && scale.includes("/")) {
 | 
			
		||||
    const parts = scale.split("/");
 | 
			
		||||
    const numerator = parts[0] ? parseFloat(parts[0]) : 1;
 | 
			
		||||
    const denominator = parts[1] ? parseFloat(parts[1]) : 1;
 | 
			
		||||
    value = (value * numerator) / denominator;
 | 
			
		||||
  } else {
 | 
			
		||||
    value = parseFloat(value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Format the value using a known type and optional options.
 | 
			
		||||
  switch (metric?.format?.type) {
 | 
			
		||||
    case "text":
 | 
			
		||||
      break;
 | 
			
		||||
    default:
 | 
			
		||||
      value = t(`common.${metric.format.type}`, { value, ...metric.format?.options });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Apply fixed prefix.
 | 
			
		||||
  const prefix = metric?.format?.prefix;
 | 
			
		||||
  if (prefix) {
 | 
			
		||||
    value = `${prefix}${value}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Apply fixed suffix.
 | 
			
		||||
  const suffix = metric?.format?.suffix;
 | 
			
		||||
  if (suffix) {
 | 
			
		||||
    value = `${value}${suffix}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Component({ service }) {
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const { widget } = service;
 | 
			
		||||
 | 
			
		||||
  const { metrics = [], refreshInterval = 10000 } = widget;
 | 
			
		||||
 | 
			
		||||
  let prometheusmetricError;
 | 
			
		||||
 | 
			
		||||
  const prometheusmetricData = new Map(
 | 
			
		||||
    metrics.slice(0, 4).map((metric) => {
 | 
			
		||||
      // disable the rule that hooks should not be called from a callback,
 | 
			
		||||
      // because we don't need a strong guarantee of hook execution order here.
 | 
			
		||||
      // eslint-disable-next-line react-hooks/rules-of-hooks
 | 
			
		||||
      const { data: resultData, error: resultError } = useWidgetAPI(widget, "query", {
 | 
			
		||||
        query: metric.query,
 | 
			
		||||
        refreshInterval: Math.max(1000, metric.refreshInterval ?? refreshInterval),
 | 
			
		||||
      });
 | 
			
		||||
      if (resultError) {
 | 
			
		||||
        prometheusmetricError = resultError;
 | 
			
		||||
      }
 | 
			
		||||
      return [metric.key ?? metric.label, resultData];
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (prometheusmetricError) {
 | 
			
		||||
    return <Container service={service} error={prometheusmetricError} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!prometheusmetricData) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        {metrics.slice(0, 4).map((item) => (
 | 
			
		||||
          <Block label={item.label} key={item.label} />
 | 
			
		||||
        ))}
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function getResultValue(data) {
 | 
			
		||||
    // Fetches the first metric result from the Prometheus query result data.
 | 
			
		||||
    // The first element in the result value is the timestamp which is ignored here.
 | 
			
		||||
    const resultType = data?.data?.resultType;
 | 
			
		||||
    const result = data?.data?.result;
 | 
			
		||||
 | 
			
		||||
    switch (resultType) {
 | 
			
		||||
      case "vector":
 | 
			
		||||
        return result?.[0]?.value?.[1];
 | 
			
		||||
      case "scalar":
 | 
			
		||||
        return result?.[1];
 | 
			
		||||
      default:
 | 
			
		||||
        return "";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      {metrics.map((metric) => (
 | 
			
		||||
        <Block
 | 
			
		||||
          label={metric.label}
 | 
			
		||||
          key={metric.key ?? metric.label}
 | 
			
		||||
          value={formatValue(t, metric, getResultValue(prometheusmetricData.get(metric.key ?? metric.label)))}
 | 
			
		||||
        />
 | 
			
		||||
      ))}
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										16
									
								
								src/widgets/prometheusmetric/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/widgets/prometheusmetric/widget.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
import genericProxyHandler from "utils/proxy/handlers/generic";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
  api: "{url}/api/v1/{endpoint}",
 | 
			
		||||
  proxyHandler: genericProxyHandler,
 | 
			
		||||
 | 
			
		||||
  mappings: {
 | 
			
		||||
    query: {
 | 
			
		||||
      method: "GET",
 | 
			
		||||
      endpoint: "query",
 | 
			
		||||
      params: ["query"],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default widget;
 | 
			
		||||
@ -15,7 +15,7 @@ async function fetchFromPyloadAPI(url, sessionId, params, service) {
 | 
			
		||||
  const options = {
 | 
			
		||||
    body: params
 | 
			
		||||
      ? Object.keys(params)
 | 
			
		||||
          .map((prop) => `${prop}=${params[prop]}`)
 | 
			
		||||
          .map((prop) => `${prop}=${encodeURIComponent(params[prop])}`)
 | 
			
		||||
          .join("&")
 | 
			
		||||
      : `session=${sessionId}`,
 | 
			
		||||
    method: "POST",
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,10 @@ const widget = {
 | 
			
		||||
        wanted: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile && item.isAvailable).length,
 | 
			
		||||
        have: jsonArrayFilter(data, (item) => item.hasFile).length,
 | 
			
		||||
        missing: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile).length,
 | 
			
		||||
        all: asJson(data),
 | 
			
		||||
        all: asJson(data).map((entry) => ({
 | 
			
		||||
          title: entry.title,
 | 
			
		||||
          id: entry.id,
 | 
			
		||||
        })),
 | 
			
		||||
      }),
 | 
			
		||||
    },
 | 
			
		||||
    "queue/status": {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										63
									
								
								src/widgets/spoolman/component.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/widgets/spoolman/component.jsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,63 @@
 | 
			
		||||
import { useTranslation } from "next-i18next";
 | 
			
		||||
 | 
			
		||||
import Container from "components/services/widget/container";
 | 
			
		||||
import Block from "components/services/widget/block";
 | 
			
		||||
import useWidgetAPI from "utils/proxy/use-widget-api";
 | 
			
		||||
 | 
			
		||||
export default function Component({ service }) {
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const { widget } = service;
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line prefer-const
 | 
			
		||||
  let { data: spoolData, error: spoolError } = useWidgetAPI(widget, "spools");
 | 
			
		||||
 | 
			
		||||
  if (spoolError) {
 | 
			
		||||
    return <Container service={service} error={spoolError} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!spoolData) {
 | 
			
		||||
    const nBlocksGuess = widget.spoolIds?.length ?? 4;
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        {[...Array(nBlocksGuess)].map((_, i) => (
 | 
			
		||||
          // eslint-disable-next-line react/no-array-index-key
 | 
			
		||||
          <Block key={i} label="spoolman.loading" />
 | 
			
		||||
        ))}
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (spoolData.error || spoolData.message) {
 | 
			
		||||
    return <Container service={service} error={spoolData?.error ?? spoolData} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (spoolData.length === 0) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        <Block label="spoolman.noSpools" />
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (widget.spoolIds?.length) {
 | 
			
		||||
    spoolData = spoolData.filter((spool) => widget.spoolIds.includes(spool.id));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (spoolData.length > 4) {
 | 
			
		||||
    spoolData = spoolData.slice(0, 4);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      {spoolData.map((spool) => (
 | 
			
		||||
        <Block
 | 
			
		||||
          key={spool.id}
 | 
			
		||||
          label={spool.filament.name}
 | 
			
		||||
          value={t("common.percent", {
 | 
			
		||||
            value: (spool.remaining_weight / spool.initial_weight) * 100,
 | 
			
		||||
          })}
 | 
			
		||||
        />
 | 
			
		||||
      ))}
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/widgets/spoolman/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/widgets/spoolman/widget.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
  api: "{url}/api/v1/{endpoint}",
 | 
			
		||||
  proxyHandler: credentialedProxyHandler,
 | 
			
		||||
 | 
			
		||||
  mappings: {
 | 
			
		||||
    spools: {
 | 
			
		||||
      endpoint: "spool",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default widget;
 | 
			
		||||
@ -46,12 +46,12 @@ export default function Component({ service }) {
 | 
			
		||||
      <Block label="stash.scenes" value={t("common.number", { value: stats.scene_count })} />
 | 
			
		||||
      <Block label="stash.scenesPlayed" value={t("common.number", { value: stats.scenes_played })} />
 | 
			
		||||
      <Block label="stash.playCount" value={t("common.number", { value: stats.total_play_count })} />
 | 
			
		||||
      <Block label="stash.playDuration" value={t("common.uptime", { value: stats.total_play_duration })} />
 | 
			
		||||
      <Block label="stash.playDuration" value={t("common.duration", { value: stats.total_play_duration })} />
 | 
			
		||||
      <Block
 | 
			
		||||
        label="stash.sceneSize"
 | 
			
		||||
        value={t("common.bbytes", { value: stats.scenes_size, maximumFractionDigits: 1 })}
 | 
			
		||||
      />
 | 
			
		||||
      <Block label="stash.sceneDuration" value={t("common.uptime", { value: stats.scenes_duration })} />
 | 
			
		||||
      <Block label="stash.sceneDuration" value={t("common.duration", { value: stats.scenes_duration })} />
 | 
			
		||||
 | 
			
		||||
      <Block label="stash.images" value={t("common.number", { value: stats.image_count })} />
 | 
			
		||||
      <Block
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										40
									
								
								src/widgets/suwayomi/component.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/widgets/suwayomi/component.jsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
import { useTranslation } from "next-i18next";
 | 
			
		||||
 | 
			
		||||
import Container from "components/services/widget/container";
 | 
			
		||||
import Block from "components/services/widget/block";
 | 
			
		||||
import useWidgetAPI from "utils/proxy/use-widget-api";
 | 
			
		||||
 | 
			
		||||
export default function Component({ service }) {
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const { widget } = service;
 | 
			
		||||
 | 
			
		||||
  const { data: suwayomiData, error: suwayomiError } = useWidgetAPI(widget);
 | 
			
		||||
 | 
			
		||||
  if (suwayomiError) {
 | 
			
		||||
    return <Container service={service} error={suwayomiError} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!suwayomiData) {
 | 
			
		||||
    if (!widget.fields || widget.fields.length === 0) {
 | 
			
		||||
      widget.fields = ["download", "nondownload", "read", "unread"];
 | 
			
		||||
    } else if (widget.fields.length > 4) {
 | 
			
		||||
      widget.fields = widget.fields.slice(0, 4);
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        {widget.fields.map((field) => (
 | 
			
		||||
          <Block key={field} label={`suwayomi.${field}`} />
 | 
			
		||||
        ))}
 | 
			
		||||
      </Container>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Container service={service}>
 | 
			
		||||
      {suwayomiData.map((data) => (
 | 
			
		||||
        <Block key={data.label} label={data.label} value={t("common.number", { value: data.count })} />
 | 
			
		||||
      ))}
 | 
			
		||||
    </Container>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										175
									
								
								src/widgets/suwayomi/proxy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								src/widgets/suwayomi/proxy.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,175 @@
 | 
			
		||||
import { httpProxy } from "utils/proxy/http";
 | 
			
		||||
import { formatApiCall } from "utils/proxy/api-helpers";
 | 
			
		||||
import getServiceWidget from "utils/config/service-helpers";
 | 
			
		||||
import createLogger from "utils/logger";
 | 
			
		||||
import widgets from "widgets/widgets";
 | 
			
		||||
 | 
			
		||||
const proxyName = "suwayomiProxyHandler";
 | 
			
		||||
const logger = createLogger(proxyName);
 | 
			
		||||
 | 
			
		||||
const countsToExtract = {
 | 
			
		||||
  download: {
 | 
			
		||||
    condition: (c) => c.isDownloaded,
 | 
			
		||||
    gqlCondition: "isDownloaded: true",
 | 
			
		||||
  },
 | 
			
		||||
  nondownload: {
 | 
			
		||||
    condition: (c) => !c.isDownloaded,
 | 
			
		||||
    gqlCondition: "isDownloaded: false",
 | 
			
		||||
  },
 | 
			
		||||
  read: {
 | 
			
		||||
    condition: (c) => c.isRead,
 | 
			
		||||
    gqlCondition: "isRead: true",
 | 
			
		||||
  },
 | 
			
		||||
  unread: {
 | 
			
		||||
    condition: (c) => !c.isRead,
 | 
			
		||||
    gqlCondition: "isRead: false",
 | 
			
		||||
  },
 | 
			
		||||
  downloadedread: {
 | 
			
		||||
    condition: (c) => c.isDownloaded && c.isRead,
 | 
			
		||||
    gqlCondition: "isDownloaded: true, isRead: true",
 | 
			
		||||
  },
 | 
			
		||||
  downloadedunread: {
 | 
			
		||||
    condition: (c) => c.isDownloaded && !c.isRead,
 | 
			
		||||
    gqlCondition: "isDownloaded: true, isRead: false",
 | 
			
		||||
  },
 | 
			
		||||
  nondownloadedread: {
 | 
			
		||||
    condition: (c) => !c.isDownloaded && c.isRead,
 | 
			
		||||
    gqlCondition: "isDownloaded: false, isRead: true",
 | 
			
		||||
  },
 | 
			
		||||
  nondownloadedunread: {
 | 
			
		||||
    condition: (c) => !c.isDownloaded && !c.isRead,
 | 
			
		||||
    gqlCondition: "isDownloaded: false, isRead: false",
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function makeBody(fields, category = "all") {
 | 
			
		||||
  if (Number.isNaN(Number(category))) {
 | 
			
		||||
    let query = "";
 | 
			
		||||
    fields.forEach((field) => {
 | 
			
		||||
      query += `
 | 
			
		||||
      ${field}: chapters(
 | 
			
		||||
        condition: {${countsToExtract[field].gqlCondition}}
 | 
			
		||||
        filter: {inLibrary: {equalTo: true}}
 | 
			
		||||
      ) {
 | 
			
		||||
        totalCount
 | 
			
		||||
      }`;
 | 
			
		||||
    });
 | 
			
		||||
    return JSON.stringify({
 | 
			
		||||
      operationName: "Counts",
 | 
			
		||||
      query: `
 | 
			
		||||
      query Counts {
 | 
			
		||||
        ${query}
 | 
			
		||||
      }`,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return JSON.stringify({
 | 
			
		||||
    operationName: "category",
 | 
			
		||||
    query: `
 | 
			
		||||
    query category($id: Int!) {
 | 
			
		||||
      category(id: $id) {
 | 
			
		||||
        # name
 | 
			
		||||
        mangas {
 | 
			
		||||
          nodes {
 | 
			
		||||
            chapters {
 | 
			
		||||
              nodes {
 | 
			
		||||
                isRead
 | 
			
		||||
                isDownloaded
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }`,
 | 
			
		||||
    variables: {
 | 
			
		||||
      id: Number(category),
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function extractCounts(responseJSON, fields) {
 | 
			
		||||
  if (!("category" in responseJSON.data)) {
 | 
			
		||||
    return fields.map((field) => ({
 | 
			
		||||
      count: responseJSON.data[field].totalCount,
 | 
			
		||||
      label: `suwayomi.${field}`,
 | 
			
		||||
    }));
 | 
			
		||||
  }
 | 
			
		||||
  const tmp = responseJSON.data.category.mangas.nodes.reduce(
 | 
			
		||||
    (accumulator, manga) => {
 | 
			
		||||
      manga.chapters.nodes.forEach((chapter) => {
 | 
			
		||||
        fields.forEach((field, i) => {
 | 
			
		||||
          if (countsToExtract[field].condition(chapter)) {
 | 
			
		||||
            accumulator[i] += 1;
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
      return accumulator;
 | 
			
		||||
    },
 | 
			
		||||
    [0, 0, 0, 0],
 | 
			
		||||
  );
 | 
			
		||||
  return fields.map((field, i) => ({
 | 
			
		||||
    count: tmp[i],
 | 
			
		||||
    label: `suwayomi.${field}`,
 | 
			
		||||
  }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default async function suwayomiProxyHandler(req, res) {
 | 
			
		||||
  const { group, service, endpoint } = req.query;
 | 
			
		||||
 | 
			
		||||
  if (!group || !service) {
 | 
			
		||||
    logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
 | 
			
		||||
    return res.status(400).json({ error: "Invalid proxy service type" });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const widget = await getServiceWidget(group, service);
 | 
			
		||||
 | 
			
		||||
  if (!widget) {
 | 
			
		||||
    logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
 | 
			
		||||
    return res.status(400).json({ error: "Invalid proxy service type" });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!widget.fields || widget.fields.length === 0) {
 | 
			
		||||
    widget.fields = ["download", "nondownload", "read", "unread"];
 | 
			
		||||
  } else if (widget.fields.length > 4) {
 | 
			
		||||
    widget.fields = widget.fields.slice(0, 4);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
 | 
			
		||||
 | 
			
		||||
  const body = makeBody(widget.fields, widget.category);
 | 
			
		||||
 | 
			
		||||
  const headers = {
 | 
			
		||||
    "Content-Type": "application/json",
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (widget.username && widget.password) {
 | 
			
		||||
    headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const [status, contentType, data] = await httpProxy(url, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    body,
 | 
			
		||||
    headers,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (status === 401) {
 | 
			
		||||
    logger.error("Invalid or missing username or password for service '%s' in group '%s'", service, group);
 | 
			
		||||
    return res.status(status).send({ error: { message: "401: unauthorized, username or password is incorrect." } });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (status !== 200) {
 | 
			
		||||
    logger.error(
 | 
			
		||||
      "Error getting data from Suwayomi for service '%s' in group '%s': %d.  Data: %s",
 | 
			
		||||
      service,
 | 
			
		||||
      group,
 | 
			
		||||
      status,
 | 
			
		||||
      data,
 | 
			
		||||
    );
 | 
			
		||||
    return res.status(status).send({ error: { message: "Error getting data. body: %s, data: %s", body, data } });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const returnData = extractCounts(JSON.parse(data), widget.fields);
 | 
			
		||||
 | 
			
		||||
  if (contentType) res.setHeader("Content-Type", contentType);
 | 
			
		||||
  return res.status(status).send(returnData);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								src/widgets/suwayomi/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/widgets/suwayomi/widget.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
import suwayomiProxyHandler from "./proxy";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
  api: "{url}/api/graphql",
 | 
			
		||||
  proxyHandler: suwayomiProxyHandler,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default widget;
 | 
			
		||||
@ -205,7 +205,7 @@ export default function Component({ service }) {
 | 
			
		||||
    <div className="flex flex-col pb-1 mx-1">
 | 
			
		||||
      {playing.map((session) => (
 | 
			
		||||
        <SessionEntry
 | 
			
		||||
          key={session.Id}
 | 
			
		||||
          key={session.session_key}
 | 
			
		||||
          session={session}
 | 
			
		||||
          enableUser={enableUser}
 | 
			
		||||
          showEpisodeNumber={showEpisodeNumber}
 | 
			
		||||
 | 
			
		||||
@ -35,7 +35,7 @@ export default function Component({ service }) {
 | 
			
		||||
    <>
 | 
			
		||||
      <Container service={service}>
 | 
			
		||||
        <Block label="truenas.load" value={t("common.number", { value: statusData.loadavg[0] })} />
 | 
			
		||||
        <Block label="truenas.uptime" value={t("common.uptime", { value: statusData.uptime_seconds })} />
 | 
			
		||||
        <Block label="truenas.uptime" value={t("common.duration", { value: statusData.uptime_seconds })} />
 | 
			
		||||
        <Block label="truenas.alerts" value={t("common.number", { value: alertData.pending })} />
 | 
			
		||||
      </Container>
 | 
			
		||||
      {enablePools &&
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,3 @@
 | 
			
		||||
// import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
 | 
			
		||||
import genericProxyHandler from "utils/proxy/handlers/generic";
 | 
			
		||||
 | 
			
		||||
const widget = {
 | 
			
		||||
 | 
			
		||||
@ -58,7 +58,7 @@ export default function Component({ service }) {
 | 
			
		||||
      break;
 | 
			
		||||
    case 2:
 | 
			
		||||
      status = t("uptimerobot.up");
 | 
			
		||||
      uptime = t("common.uptime", { value: monitor.logs[0].duration });
 | 
			
		||||
      uptime = t("common.duration", { value: monitor.logs[0].duration });
 | 
			
		||||
      logIndex = 1;
 | 
			
		||||
      break;
 | 
			
		||||
    case 8:
 | 
			
		||||
@ -73,7 +73,7 @@ export default function Component({ service }) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const lastDown = new Date(monitor.logs[logIndex].datetime * 1000).toLocaleString();
 | 
			
		||||
  const downDuration = t("common.uptime", { value: monitor.logs[logIndex].duration });
 | 
			
		||||
  const downDuration = t("common.duration", { value: monitor.logs[logIndex].duration });
 | 
			
		||||
  const hideDown = logIndex === 1 && monitor.logs[logIndex].type !== 1;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,12 @@
 | 
			
		||||
import adguard from "./adguard/widget";
 | 
			
		||||
import argocd from "./argocd/widget";
 | 
			
		||||
import atsumeru from "./atsumeru/widget";
 | 
			
		||||
import audiobookshelf from "./audiobookshelf/widget";
 | 
			
		||||
import authentik from "./authentik/widget";
 | 
			
		||||
import autobrr from "./autobrr/widget";
 | 
			
		||||
import azuredevops from "./azuredevops/widget";
 | 
			
		||||
import bazarr from "./bazarr/widget";
 | 
			
		||||
import beszel from "./beszel/widget";
 | 
			
		||||
import caddy from "./caddy/widget";
 | 
			
		||||
import calendar from "./calendar/widget";
 | 
			
		||||
import calibreweb from "./calibreweb/widget";
 | 
			
		||||
@ -30,11 +32,13 @@ import gamedig from "./gamedig/widget";
 | 
			
		||||
import gatus from "./gatus/widget";
 | 
			
		||||
import ghostfolio from "./ghostfolio/widget";
 | 
			
		||||
import gitea from "./gitea/widget";
 | 
			
		||||
import gitlab from "./gitlab/widget";
 | 
			
		||||
import glances from "./glances/widget";
 | 
			
		||||
import gluetun from "./gluetun/widget";
 | 
			
		||||
import gotify from "./gotify/widget";
 | 
			
		||||
import grafana from "./grafana/widget";
 | 
			
		||||
import hdhomerun from "./hdhomerun/widget";
 | 
			
		||||
import headscale from "./headscale/widget";
 | 
			
		||||
import homeassistant from "./homeassistant/widget";
 | 
			
		||||
import homebox from "./homebox/widget";
 | 
			
		||||
import homebridge from "./homebridge/widget";
 | 
			
		||||
@ -85,6 +89,7 @@ import plantit from "./plantit/widget";
 | 
			
		||||
import plex from "./plex/widget";
 | 
			
		||||
import portainer from "./portainer/widget";
 | 
			
		||||
import prometheus from "./prometheus/widget";
 | 
			
		||||
import prometheusmetric from "./prometheusmetric/widget";
 | 
			
		||||
import prowlarr from "./prowlarr/widget";
 | 
			
		||||
import proxmox from "./proxmox/widget";
 | 
			
		||||
import pterodactyl from "./pterodactyl/widget";
 | 
			
		||||
@ -98,10 +103,12 @@ import sabnzbd from "./sabnzbd/widget";
 | 
			
		||||
import scrutiny from "./scrutiny/widget";
 | 
			
		||||
import sonarr from "./sonarr/widget";
 | 
			
		||||
import speedtest from "./speedtest/widget";
 | 
			
		||||
import spoolman from "./spoolman/widget";
 | 
			
		||||
import stash from "./stash/widget";
 | 
			
		||||
import stocks from "./stocks/widget";
 | 
			
		||||
import strelaysrv from "./strelaysrv/widget";
 | 
			
		||||
import swagdashboard from "./swagdashboard/widget";
 | 
			
		||||
import suwayomi from "./suwayomi/widget";
 | 
			
		||||
import tailscale from "./tailscale/widget";
 | 
			
		||||
import tandoor from "./tandoor/widget";
 | 
			
		||||
import tautulli from "./tautulli/widget";
 | 
			
		||||
@ -126,12 +133,14 @@ import zabbix from "./zabbix/widget";
 | 
			
		||||
 | 
			
		||||
const widgets = {
 | 
			
		||||
  adguard,
 | 
			
		||||
  argocd,
 | 
			
		||||
  atsumeru,
 | 
			
		||||
  audiobookshelf,
 | 
			
		||||
  authentik,
 | 
			
		||||
  autobrr,
 | 
			
		||||
  azuredevops,
 | 
			
		||||
  bazarr,
 | 
			
		||||
  beszel,
 | 
			
		||||
  caddy,
 | 
			
		||||
  calibreweb,
 | 
			
		||||
  changedetectionio,
 | 
			
		||||
@ -156,11 +165,13 @@ const widgets = {
 | 
			
		||||
  gatus,
 | 
			
		||||
  ghostfolio,
 | 
			
		||||
  gitea,
 | 
			
		||||
  gitlab,
 | 
			
		||||
  glances,
 | 
			
		||||
  gluetun,
 | 
			
		||||
  gotify,
 | 
			
		||||
  grafana,
 | 
			
		||||
  hdhomerun,
 | 
			
		||||
  headscale,
 | 
			
		||||
  homeassistant,
 | 
			
		||||
  homebox,
 | 
			
		||||
  homebridge,
 | 
			
		||||
@ -214,6 +225,7 @@ const widgets = {
 | 
			
		||||
  plex,
 | 
			
		||||
  portainer,
 | 
			
		||||
  prometheus,
 | 
			
		||||
  prometheusmetric,
 | 
			
		||||
  prowlarr,
 | 
			
		||||
  proxmox,
 | 
			
		||||
  pterodactyl,
 | 
			
		||||
@ -228,10 +240,12 @@ const widgets = {
 | 
			
		||||
  scrutiny,
 | 
			
		||||
  sonarr,
 | 
			
		||||
  speedtest,
 | 
			
		||||
  spoolman,
 | 
			
		||||
  stash,
 | 
			
		||||
  stocks,
 | 
			
		||||
  strelaysrv,
 | 
			
		||||
  swagdashboard,
 | 
			
		||||
  suwayomi,
 | 
			
		||||
  tailscale,
 | 
			
		||||
  tandoor,
 | 
			
		||||
  tautulli,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user