Compare commits

..

16 Commits

Author SHA1 Message Date
shamoon
ea5d031d1f Merge branch 'dev' into feature/auth 2026-02-04 22:18:24 -08:00
shamoon
83b5e96682 Mock next/router in signin tests 2026-02-04 21:49:04 -08:00
shamoon
8792337133 Normalize credentials provider id and signin lookup 2026-02-04 21:44:40 -08:00
shamoon
42b290c76c lint 2026-02-04 21:38:28 -08:00
shamoon
c4afced5fa Basic password 2026-02-04 21:29:01 -08:00
shamoon
6b6457cb5d Remove old tests 2026-02-04 21:01:13 -08:00
shamoon
ab869f042a Add some auth tests 2026-02-04 20:58:22 -08:00
shamoon
e0b66c398f Update pnpm-lock.yaml 2026-02-04 20:39:55 -08:00
shamoon
d55ef5cb9c always /
[ci skip]
2026-02-04 20:34:10 -08:00
shamoon
abb8d50327 Update index.md
[ci skip]
2026-02-04 20:34:09 -08:00
shamoon
cddc9dacf8 Update README.md
[ci skip]
2026-02-04 20:34:09 -08:00
shamoon
28db90521f lint 2026-02-04 20:34:08 -08:00
shamoon
dffd21b600 Jazzy 2026-02-04 20:34:08 -08:00
shamoon
e375a9747a Update README.md 2026-02-04 20:34:07 -08:00
shamoon
f0e65a6ac8 docs, allowed hosts stuff
[ci skip]
2026-02-04 20:34:07 -08:00
shamoon
0660b91d94 save this
[ci skip]
2026-02-04 20:34:06 -08:00
189 changed files with 1853 additions and 2058 deletions

View File

@@ -9,11 +9,11 @@ coverage:
project:
default:
target: 100%
threshold: 15%
threshold: 25%
patch:
default:
target: 100%
threshold: 10%
threshold: 25%
comment:
layout: "reach,diff,flags,files"

View File

@@ -1,10 +1,6 @@
title: "[Feature Request] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
#### ⚠️ Don't forget to search [existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) and [discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions) (including closed ones!).
- type: textarea
id: description
attributes:

View File

@@ -35,8 +35,8 @@ What type of change does your PR introduce to Homepage?
## Checklist:
- [ ] If applicable, I have added corresponding documentation changes.
- [ ] If applicable, I have added or updated tests for new features and bug fixes (see [testing](https://gethomepage.dev/widgets/authoring/getting-started/#testing)).
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/widgets/authoring/getting-started/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/widgets/authoring/getting-started/#code-linting).
- [ ] If applicable, I have added or updated tests for new features and bug fixes.
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/more/development/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/more/development/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/more/development/#code-linting).
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] In the description above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -9,9 +9,7 @@ on:
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
contents: write
jobs:
pre-commit:
@@ -37,34 +35,44 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: sudo apt-get install pngquant
- run: pip install mkdocs-material mkdocs-redirects "mkdocs-material[imaging]"
- name: Test Docs Build
run: uv run --frozen zensical build --clean
run: MKINSIDERS=false mkdocs build
deploy:
name: Build & Deploy Docs
if: github.repository == 'gethomepage/homepage' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs:
- pre-commit
steps:
- uses: actions/configure-pages@v5
- uses: actions/checkout@v6
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
- run: sudo apt-get install pngquant
- name: Build Docs
run: uv run --frozen zensical build --clean
- uses: actions/upload-pages-artifact@v4
python-version: 3.x
- run: echo "cache_id=${{github.sha}}" >> $GITHUB_ENV
- uses: actions/cache@v5
with:
path: site
- uses: actions/deploy-pages@v4
id: deployment
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: sudo apt-get install pngquant
- run: pip install git+https://${GH_TOKEN}@github.com/benphelps/mkdocs-material-insiders.git
- run: pip install mkdocs-redirects "mkdocs-material[imaging]"
- name: Docs Deploy
run: MKINSIDERS=true mkdocs gh-deploy --force
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}

View File

@@ -13,13 +13,13 @@ jobs:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

2
.gitignore vendored
View File

@@ -46,7 +46,7 @@ next-env.d.ts
# IDEs
/.idea/
# Zensical documentation
# MkDocs documentation
site*/
.cache/

View File

@@ -1 +0,0 @@
3.13

View File

@@ -38,11 +38,11 @@ People _love_ thorough bug reports. I'm not even kidding.
## Development Guidelines
Please see the [documentation regarding development](https://gethomepage.dev/widgets/authoring/getting-started/#development) and specifically the [guidelines for new service widgets](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines) if you are considering making one.
Please see the [documentation regarding development](https://gethomepage.dev/more/development/) and specifically the [guidelines for new service widgets](https://gethomepage.dev/more/development/#service-widget-guidelines) if you are considering making one.
## Use a Consistent Coding Style
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks).
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks).
## License

View File

@@ -70,65 +70,14 @@ For configuration options, examples and more, [please check out the homepage doc
## Security Notice 🔒
Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. If Homepage is reachable from any untrusted network, it **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. The built-in host check in Homepage is a best-effort guard and should not be treated as security when exposed publicly.
Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system). To keep your information private, if Homepage is reachable from any untrusted network, it:
## With Docker
1. **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers.
2. An optional built-in OIDC login flow is available (opt-in) offering a simple “authenticated or not” guard.
Using docker compose:
## Installation
```yaml
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
PUID: 1000 # optional, your user id
PGID: 1000 # optional, your group id
ports:
- 3000:3000
volumes:
- /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # optional, for docker integrations
restart: unless-stopped
```
or docker run:
```bash
docker run --name homepage \
-e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev \
-e PUID=1000 \
-e PGID=1000 \
-p 3000:3000 \
-v /path/to/config:/app/config \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--restart unless-stopped \
ghcr.io/gethomepage/homepage:latest
```
## From Source
First, clone the repository:
```bash
git clone https://github.com/gethomepage/homepage.git
```
Then install dependencies and build the production bundle:
```bash
pnpm install
pnpm build
```
If this is your first time starting, copy the `src/skeleton` directory to `config/` to populate initial example config files.
Finally, run the server in production mode:
```bash
pnpm start
```
See the [Installation](https://gethomepage.dev/installation/) section of the docs for instructions on installing Homepage via Docker, Kubernetes, Unraid, or from source.
# Configuration
@@ -156,16 +105,16 @@ This is a [Next.js](https://nextjs.org/) application, see their documentation fo
The homepage documentation is available at [https://gethomepage.dev/](https://gethomepage.dev/).
Homepage uses Zensical for documentation. To run the documentation locally, first install the dependencies:
Homepage uses Material for MkDocs for documentation. To run the documentation locally, first install the dependencies:
```bash
uv sync
pip install -r requirements.txt
```
Then run the development server:
```bash
uv run zensical serve # or build, to build the static site
mkdocs serve # or build, to build the static site
```
# Support & Suggestions

View File

@@ -177,16 +177,6 @@ labels:
- homepage.widget.fields=["field1","field2"] # optional
```
!!! note
If you use mapping syntax (`:`) for labels instead of list syntax (`-`), array values like `fields` must be wrapped in single quotes so they are passed as a string:
```yaml
labels:
...
homepage.widget.fields: '["field1","field2"]'
```
Multiple widgets can be specified by incrementing the index, e.g.
```yaml

View File

@@ -15,8 +15,6 @@ services:
volumes:
- /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
```
### Running as non-root
@@ -38,7 +36,6 @@ services:
- /path/to/config:/app/config # Make sure your local config directory exists
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations, see alternative methods
environment:
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
PUID: $PUID
PGID: $PGID
```
@@ -46,7 +43,7 @@ services:
### With Docker Run
```bash
docker run -p 3000:3000 -e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest
docker run -p 3000:3000 -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest
```
### Using Environment Secrets

View File

@@ -27,14 +27,25 @@ You have a few options for deploying homepage, depending on your needs. We offer
</div>
### `HOMEPAGE_ALLOWED_HOSTS`
### Security & Authentication
As of v1.0 there is one required environment variable to access homepage via a URL other than `localhost`, <code>HOMEPAGE_ALLOWED_HOSTS</code>. The setting helps prevent certain kinds of attacks when retrieving data from the homepage API proxy.
Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided.
The value is a comma-separated (no spaces) list of allowed hosts (sometimes with the port) that can host your homepage install. See the [docker](docker.md), [kubernetes](k8s.md) and [source](source.md) installation pages for more information about where / how to set the variable.
Required environment variables for authentication:
`localhost:3000` and `127.0.0.1:3000` are always included, but you can add a domain or IP address to this list to allow that host such as `HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev,192.168.1.2:1234`, etc.
- `HOMEPAGE_AUTH_ENABLED=true`
- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies)
If you are seeing errors about host validation, check the homepage logs and ensure that the host exactly as output in the logs is in the `HOMEPAGE_ALLOWED_HOSTS` list.
For password-only login:
This can be disabled by setting `HOMEPAGE_ALLOWED_HOSTS` to `*` but this is not recommended. Public deployments must rely on a reverse proxy (and/or VPN) that enforces authentication, TLS, and unexpected Host headers; the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections.
- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided)
For OIDC login (overrides password login):
- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`)
- `HOMEPAGE_OIDC_CLIENT_ID`
- `HOMEPAGE_OIDC_CLIENT_SECRET`
- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks)
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)
All app pages and `/api` routes will require a signed-in session. Static assets remain public. Homepage still does not implement per-user dashboards or roles; authentication is a simple gate only.

View File

@@ -223,9 +223,6 @@ spec:
- name: homepage
image: "ghcr.io/gethomepage/homepage:latest"
imagePullPolicy: Always
env:
- name: HOMEPAGE_ALLOWED_HOSTS
value: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
ports:
- name: http
containerPort: 3000

View File

@@ -27,9 +27,7 @@ If this is your first time starting, copy the `src/skeleton` directory to `confi
Finally, run the server:
```bash
HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start
pnpm start
```
When updating homepage versions you will need to re-build the static files i.e. repeat the process above.
See [HOMEPAGE_ALLOWED_HOSTS](index.md#homepage_allowed_hosts) for more information on this environment variable.

View File

@@ -7,17 +7,13 @@ You can include all or some of the available resources. If you do not want to se
The disk path is the path reported by `df` (Mounted On), or the mount point of the disk.
!!! note
Any disk you wish to access must be mounted to your container as a volume.
The cpu and memory resource information are the container's usage while [glances](glances.md) displays statistics for the host machine on which it is installed.
The resources widget primarily relies on a popular tool called [systeminformation](https://systeminformation.io). Thus, any limitiations of that software apply, for example, BRTFS RAID is not supported for the disk usage. In this case users may want to use the [glances widget](glances.md) instead.
!!! warning
_Note: unfortunately, the package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp._
The package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp.
**Any disk you wish to access must be mounted to your container as a volume.**
```yaml
- resources:
@@ -79,10 +75,3 @@ You can additionally supply an optional `expanded` property set to true in order
```
![194136533-c4238c82-4d67-41a4-b3c8-18bf26d33ac2](https://user-images.githubusercontent.com/3441425/194728642-a9885274-922b-4027-acf5-a746f58fdfce.png)
To monitor a named host network interface in Docker (for example `network: eno1`), mount host `/sys` (read-only):
```yaml
volumes:
- /sys:/sys:ro
```

View File

@@ -67,7 +67,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Jackett](jackett.md)
- [JDownloader](jdownloader.md)
- [Jellyfin](jellyfin.md)
- [Seerr](seerr.md)
- [Jellyseerr](jellyseerr.md)
- [Jellystat](jellystat.md)
- [Kavita](kavita.md)
- [Komga](komga.md)
@@ -101,6 +101,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [OpenMediaVault](openmediavault.md)
- [OpenWRT](openwrt.md)
- [OPNsense](opnsense.md)
- [Overseerr](overseerr.md)
- [PaperlessNGX](paperlessngx.md)
- [Peanut](peanut.md)
- [pfSense](pfsense.md)

View File

@@ -5,7 +5,7 @@ description: Jellyfin Widget Configuration
Learn more about [Jellyfin](https://github.com/jellyfin/jellyfin).
You can create an API key from inside the Jellyfin Administration Dashboard under `Advanced > API Keys`.
You can create an API key from inside Jellyfin at `Settings > Advanced > Api Keys`.
As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "songs"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the "Now Playing" feature (enabled by default) can be disabled with the `enableNowPlaying` option.
@@ -17,7 +17,7 @@ As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "song
```yaml
widget:
type: jellyfin
url: http://jellyfin.host.or.ip:port
url: http://jellyfin.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
version: 2 # optional, default is 1
enableBlocks: true # optional, defaults to false

View File

@@ -0,0 +1,18 @@
---
title: Jellyseerr
description: Jellyseerr Widget Configuration
---
Learn more about [Jellyseerr](https://github.com/Fallenbagel/jellyseerr).
Find your API key under `Settings > General > API Key`.
Allowed fields: `["pending", "approved", "available", "issues"]`.
Default fields: `["pending", "approved", "available"]`.
```yaml
widget:
type: jellyseerr
url: http://jellyseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -0,0 +1,17 @@
---
title: Overseerr
description: Overseerr Widget Configuration
---
Learn more about [Overseerr](https://github.com/sct/overseerr).
Find your API key under `Settings > General`.
Allowed fields: `["pending", "approved", "available", "processing"]`.
```yaml
widget:
type: overseerr
url: http://overseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -12,7 +12,7 @@ Allowed fields: no configurable fields for this widget.
```yaml
widget:
type: tautulli
url: http://tautulli.host.or.ip:port
url: http://tautulli.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false

View File

@@ -1,20 +0,0 @@
---
title: Seerr Widget
description: Seerr Widget Configuration
---
Learn more about [Seerr](https://github.com/seerr-team/seerr).
Find your API key under `Settings > General > API Key`.
_Jellyseerr and Overseerr merged into Seerr. Use `type: seerr` (legacy `type: jellyseerr` and `type: overseerr` are aliased)._
Allowed fields: `["pending", "approved", "available", "completed", "processing", "issues"]`.
Default fields: `["pending", "approved", "completed"]`.
```yaml
widget:
type: seerr
url: http://seerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -1,15 +0,0 @@
---
title: SparkyFitness
description: SparkyFitness Widget Configuration
---
Learn more about [SparkyFitness](https://github.com/CodeWithCJ/SparkyFitness).
Allowed fields: `["eaten", "burned", "remaining", "steps"]`.
```yaml
widget:
type: sparkyfitness
url: http://sparkyfitness.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -1,21 +0,0 @@
---
title: Tracearr
description: Tracearr Widget Configuration
---
Learn more about [Tracearr](https://www.tracearr.com/).
Provides detailed information about currently active streams across multiple servers.
Allowed fields (for summary view): `["streams", "transcodes", "directplay", "bitrate"]`.
```yaml
widget:
type: tracearr
url: http://tracearr.host.or.ip:3000
key: apikeyapikeyapikeyapikeyapikey
view: both # optional, "summary", "details", or "both", defaults to "details"
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false
expandOneStreamToTwoRows: false # optional, defaults to true
```

View File

@@ -91,6 +91,7 @@ nav:
- widgets/services/jackett.md
- widgets/services/jdownloader.md
- widgets/services/jellyfin.md
- widgets/services/jellyseerr.md
- widgets/services/jellystat.md
- widgets/services/kavita.md
- widgets/services/komga.md
@@ -124,6 +125,7 @@ nav:
- widgets/services/openmediavault.md
- widgets/services/opnsense.md
- widgets/services/openwrt.md
- widgets/services/overseerr.md
- widgets/services/pangolin.md
- widgets/services/paperlessngx.md
- widgets/services/peanut.md
@@ -149,10 +151,8 @@ nav:
- widgets/services/rutorrent.md
- widgets/services/sabnzbd.md
- widgets/services/scrutiny.md
- widgets/services/seerr.md
- widgets/services/slskd.md
- widgets/services/sonarr.md
- widgets/services/sparkyfitness.md
- widgets/services/speedtest-tracker.md
- widgets/services/spoolman.md
- widgets/services/stash.md
@@ -165,7 +165,6 @@ nav:
- widgets/services/technitium.md
- widgets/services/tdarr.md
- widgets/services/traefik.md
- widgets/services/tracearr.md
- widgets/services/transmission.md
- widgets/services/trilium.md
- widgets/services/truenas.md

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.10.1",
"version": "1.10.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -29,6 +29,7 @@
"memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2",
"next": "^15.5.11",
"next-auth": "^4.24.10",
"next-i18next": "^12.1.0",
"ping": "^0.4.4",
"pretty-bytes": "^7.1.0",

114
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
next:
specifier: ^15.5.11
version: 15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-auth:
specifier: ^4.24.10
version: 4.24.13(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-i18next:
specifier: ^12.1.0
version: 12.1.0(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -803,6 +806,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1683,6 +1689,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
core-js@3.40.0:
resolution: {integrity: sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==}
@@ -2551,6 +2561,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
@@ -2723,6 +2736,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'}
@@ -2831,6 +2848,20 @@ packages:
net@1.0.2:
resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==}
next-auth@4.24.13:
resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==}
peerDependencies:
'@auth/core': 0.34.3
next: ^12.2.5 || ^13 || ^14 || ^15 || ^16
nodemailer: ^7.0.7
react: ^17.0.2 || ^18 || ^19
react-dom: ^17.0.2 || ^18 || ^19
peerDependenciesMeta:
'@auth/core':
optional: true
nodemailer:
optional: true
next-i18next@12.1.0:
resolution: {integrity: sha512-rhos/PVULmZPdC0jpec2MDBQMXdGZ3+Mbh/tZfrDtjgnVN3ucdq7k8BlwsJNww6FnqC8AC31n6dSYuqVzYsGsw==}
engines: {node: '>=12'}
@@ -2878,10 +2909,17 @@ packages:
oauth4webapi@3.3.0:
resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==}
oauth@0.9.15:
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -2910,12 +2948,19 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
oidc-token-hash@5.2.0:
resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==}
engines: {node: ^10.13.0 || >=12.0.0}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
openid-client@6.3.0:
resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==}
@@ -3007,6 +3052,14 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@5.2.6:
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
preact: '>=10'
preact@10.28.2:
resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3038,6 +3091,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
pretty-format@3.8.0:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
prism-react-renderer@2.4.1:
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
peerDependencies:
@@ -3670,6 +3726,10 @@ packages:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
@@ -3868,6 +3928,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
@@ -4389,6 +4452,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@panva/hkdf@1.2.1': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -5255,6 +5320,8 @@ snapshots:
concat-map@0.0.1: {}
cookie@0.7.2: {}
core-js@3.40.0: {}
core-util-is@1.0.3: {}
@@ -6347,6 +6414,8 @@ snapshots:
jiti@2.6.1: {}
jose@4.15.9: {}
jose@5.10.0: {}
js-tokens@10.0.0: {}
@@ -6508,6 +6577,10 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
luxon@3.6.1: {}
lz-string@1.5.0: {}
@@ -6587,6 +6660,21 @@ snapshots:
net@1.0.2: {}
next-auth@4.24.13(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.28.6
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
preact-render-to-string: 5.2.6(preact@10.28.2)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
uuid: 8.3.2
next-i18next@12.1.0(next@15.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.9
@@ -6635,8 +6723,12 @@ snapshots:
oauth4webapi@3.3.0: {}
oauth@0.9.15: {}
object-assign@4.1.1: {}
object-hash@2.2.0: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -6676,6 +6768,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
oidc-token-hash@5.2.0: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -6684,6 +6778,13 @@ snapshots:
dependencies:
fn.name: 1.1.0
openid-client@5.7.1:
dependencies:
jose: 4.15.9
lru-cache: 6.0.0
object-hash: 2.2.0
oidc-token-hash: 5.2.0
openid-client@6.3.0:
dependencies:
jose: 5.10.0
@@ -6766,6 +6867,13 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact-render-to-string@5.2.6(preact@10.28.2):
dependencies:
preact: 10.28.2
pretty-format: 3.8.0
preact@10.28.2: {}
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
@@ -6787,6 +6895,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
pretty-format@3.8.0: {}
prism-react-renderer@2.4.1(react@18.3.1):
dependencies:
'@types/prismjs': 1.26.5
@@ -7544,6 +7654,8 @@ snapshots:
uuid@10.0.0: {}
uuid@8.3.2: {}
varint@6.0.0: {}
victory-vendor@37.3.6:
@@ -7780,6 +7892,8 @@ snapshots:
y18n@5.0.8: {}
yallist@4.0.0: {}
yallist@5.0.0: {}
yargs-parser@21.1.1: {}

View File

@@ -184,13 +184,6 @@
"no_active": "No Active Streams",
"plex_connection_error": "Check Plex Connection"
},
"tracearr": {
"no_active": "No Active Streams",
"streams": "Streams",
"transcodes": "Transcodes",
"directplay": "Direct Play",
"bitrate": "Bitrate"
},
"omada": {
"connectedAp": "Connected APs",
"activeUser": "Active devices",
@@ -289,14 +282,18 @@
"approved": "Approved",
"available": "Available"
},
"seerr": {
"jellyseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available",
"completed": "Completed",
"processing": "Processing",
"issues": "Open Issues"
},
"overseerr": {
"pending": "Pending",
"processing": "Processing",
"approved": "Approved",
"available": "Available"
},
"netalertx": {
"total": "Total",
"connected": "Connected",
@@ -1174,11 +1171,5 @@
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
},
"sparkyfitness": {
"eaten": "Eaten",
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
}
}

View File

@@ -1,8 +0,0 @@
[project]
name = "homepage-docs"
version = "1.0.0"
description = "Documentation for the Homepage project"
requires-python = ">=3.13"
dependencies = [
"zensical>=0.0.21",
]

47
requirements.txt Normal file
View File

@@ -0,0 +1,47 @@
Babel==2.12.1
backrefs==5.9
cairocffi==1.7.1
CairoSVG==2.7.1
certifi==2023.7.22
cffi==1.17.1
cfgv==3.4.0
charset-normalizer==3.2.0
click==8.1.7
colorama==0.4.6
cssselect2==0.7.0
defusedxml==0.7.1
distlib==0.3.9
filelock==3.17.0
ghp-import==2.1.0
identify==2.6.7
idna==3.4
Jinja2==3.1.2
Markdown==3.4.4
MarkupSafe==2.1.3
mergedeep==1.3.4
mkdocs==1.6.0
mkdocs-get-deps==0.2.0
mkdocs-material==9.6.18
mkdocs-material-extensions==1.3.1
mkdocs-redirects==1.2.1
nodeenv==1.9.1
packaging==23.1
paginate==0.5.6
pathspec==0.11.2
pillow==10.4.0
platformdirs==3.10.0
pre-commit==3.5.0
pycparser==2.22
Pygments==2.16.1
pymdown-extensions==10.3
python-dateutil==2.8.2
PyYAML==6.0.1
pyyaml_env_tag==0.1
regex==2023.8.8
requests==2.31.0
six==1.16.0
tinycss2==1.4.0
urllib3==2.0.5
virtualenv==20.29.2
watchdog==3.0.0
webencodings==0.5.1

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { nextAuthMock } = vi.hoisted(() => ({
nextAuthMock: vi.fn((options) => ({ options })),
}));
vi.mock("next-auth", () => ({
default: nextAuthMock,
}));
describe("pages/api/auth/[...nextauth]", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
nextAuthMock.mockClear();
process.env = { ...originalEnv };
delete process.env.NEXTAUTH_SECRET;
delete process.env.NEXTAUTH_URL;
});
it("configures no providers when auth is disabled", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
expect(nextAuthMock).toHaveBeenCalledTimes(1);
expect(mod.default.options.providers).toEqual([]);
expect(mod.default.options.pages?.signIn).toBe("/auth/signin");
});
it("maps HOMEPAGE_AUTH_SECRET and HOMEPAGE_EXTERNAL_URL to NextAuth envs", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
expect(mod.default.options.secret).toBe("secret");
});
it("throws when auth is enabled but no provider settings are present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/Password auth is enabled but required settings are missing/i,
);
});
it("builds a password provider when auth is enabled without OIDC config", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider.id).toBe("credentials");
expect(provider.name).toBe("Credentials");
expect(provider.type).toBe("credentials");
expect(typeof provider.authorize).toBe("function");
});
it("builds an OIDC provider when enabled and maps profile fields", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/";
process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id";
process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
process.env.HOMEPAGE_OIDC_NAME = "My OIDC";
process.env.HOMEPAGE_OIDC_SCOPE = "openid email";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider).toMatchObject({
id: "homepage-oidc",
name: "My OIDC",
type: "oauth",
idToken: true,
issuer: "https://issuer.example",
wellKnown: "https://issuer.example/.well-known/openid-configuration",
clientId: "client-id",
clientSecret: "client-secret",
});
expect(provider.authorization.params.scope).toBe("openid email");
expect(
provider.profile({
sub: "sub",
preferred_username: "user",
email: "user@example.com",
picture: "https://example.com/p.png",
}),
).toEqual({
id: "sub",
name: "user",
email: "user@example.com",
image: "https://example.com/p.png",
});
expect(
provider.profile({
id: "id",
name: "name",
}),
).toEqual({
id: "id",
name: "name",
email: null,
image: null,
});
});
it("throws when only partial OIDC settings are provided", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/OIDC auth is enabled but required settings are missing/i,
);
});
});

View File

@@ -344,17 +344,4 @@ describe("pages/api/services/proxy", () => {
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" });
});
it("returns 500 when an async proxy handler throws", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
handlerFn.handler.mockRejectedValueOnce(new Error("proxy boom"));
const req = { method: "GET", query: { group: "g", service: "s", index: "0" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" });
});
});

View File

@@ -90,74 +90,17 @@ describe("pages/api/widgets/resources", () => {
});
it("returns 404 when requested network interface does not exist", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]).mockResolvedValueOnce([
{
iface: "missing",
operstate: "unknown",
rx_bytes: 0,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 0,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
},
]);
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]);
const req = { query: { type: "network", interfaceName: "missing" } };
const res = createMockRes();
await handler(req, res);
expect(si.networkStats).toHaveBeenNthCalledWith(1, "*");
expect(si.networkStats).toHaveBeenNthCalledWith(2, "missing");
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "Interface not found" });
});
it("falls back to direct named interface query when wildcard enumeration misses it", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "eth0", rx_bytes: 1 }]).mockResolvedValueOnce([
{
iface: "eno1",
operstate: "up",
rx_bytes: 1000,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 500,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
},
]);
const req = { query: { type: "network", interfaceName: "eno1" } };
const res = createMockRes();
await handler(req, res);
expect(si.networkStats).toHaveBeenNthCalledWith(1, "*");
expect(si.networkStats).toHaveBeenNthCalledWith(2, "eno1");
expect(res.statusCode).toBe(200);
expect(res.body.interface).toBe("eno1");
expect(res.body.network).toEqual({
iface: "eno1",
operstate: "up",
rx_bytes: 1000,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 500,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
});
});
it("returns default interface network stats", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
si.networkInterfaceDefault.mockResolvedValueOnce("en0");

View File

@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { getSettingsMock } = vi.hoisted(() => ({
getSettingsMock: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings: getSettingsMock,
}));
vi.mock("next/router", () => ({
useRouter: () => ({
query: {},
}),
}));
import { getProviders } from "next-auth/react";
import SignInPage, { getServerSideProps } from "pages/auth/signin";
describe("pages/auth/signin", () => {
it("renders an error state when no providers are configured", async () => {
render(
<SignInPage
providers={{}}
settings={{
theme: "dark",
color: "slate",
title: "Homepage",
}}
/>,
);
expect(screen.getByText("Authentication not configured")).toBeInTheDocument();
await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(document.documentElement.classList.contains("scheme-dark")).toBe(true);
expect(document.documentElement.classList.contains("theme-slate")).toBe(true);
});
});
it("renders provider buttons when providers are available", () => {
render(
<SignInPage
providers={{
oidc: { id: "oidc", name: "OIDC" },
}}
settings={{
theme: "light",
color: "emerald",
title: "My Dashboard",
}}
/>,
);
expect(screen.getByText("Sign in")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument();
});
it("getServerSideProps returns providers and settings", async () => {
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
const res = await getServerSideProps({});
expect(getProviders).toHaveBeenCalled();
expect(getSettingsMock).toHaveBeenCalled();
expect(res).toEqual({
props: {
providers: { foo: { id: "foo", name: "Foo" } },
settings: { theme: "dark" },
},
});
});
});

View File

@@ -9,8 +9,6 @@ import { buildHighlightConfig } from "utils/highlights";
const ALIASED_WIDGETS = {
pialert: "netalertx",
hoarder: "karakeep",
jellyseerr: "seerr",
overseerr: "seerr",
};
export default function Container({ error = false, children, service }) {

View File

@@ -58,26 +58,6 @@ describe("components/services/widget/container", () => {
expect(screen.getByTestId("karakeep.count")).toBeInTheDocument();
});
it("supports seerr aliases when filtering (jellyseerr/overseerr -> seerr)", () => {
renderWithProviders(
<Container service={{ widget: { type: "jellyseerr", fields: ["pending"] } }}>
<Dummy label="seerr.pending" />
</Container>,
{ settings: {} },
);
expect(screen.getByTestId("seerr.pending")).toBeInTheDocument();
renderWithProviders(
<Container service={{ widget: { type: "overseerr", fields: ["processing"] } }}>
<Dummy label="seerr.processing" />
</Container>,
{ settings: {} },
);
expect(screen.getByTestId("seerr.processing")).toBeInTheDocument();
});
it("returns null when errors are hidden via settings.hideErrors", () => {
const { container } = renderWithProviders(
<Container error="nope" service={{ widget: { type: "omada", hide_errors: false } }}>

View File

@@ -11,7 +11,7 @@ import Resource from "../widget/resource";
import Resources from "../widget/resources";
import WidgetLabel from "../widget/widget_label";
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl"];
function convertToFahrenheit(t) {
return (t * 9) / 5 + 32;

View File

@@ -1,23 +1,34 @@
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
export function middleware(req) {
// Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
const host = req.headers.get("host");
const port = process.env.PORT || 3000;
let allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
const allowAll = process.env.HOMEPAGE_ALLOWED_HOSTS === "*";
if (process.env.HOMEPAGE_ALLOWED_HOSTS) {
allowedHosts = allowedHosts.concat(process.env.HOMEPAGE_ALLOWED_HOSTS.split(","));
}
if (!allowAll && (!host || !allowedHosts.includes(host))) {
console.error(
`Host validation failed for: ${host}. Hint: Set the HOMEPAGE_ALLOWED_HOSTS environment variable to allow requests from this host / port.`,
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET;
let warnedAllowedHosts = false;
export async function middleware(req) {
if (!warnedAllowedHosts && process.env.HOMEPAGE_ALLOWED_HOSTS) {
warnedAllowedHosts = true;
console.warn(
"HOMEPAGE_ALLOWED_HOSTS is deprecated. To secure a publicly accessible homepage, configure authentication instead.",
);
return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 });
}
if (authEnabled) {
const token = await getToken({ req, secret: authSecret });
if (!token) {
const signInUrl = new URL("/auth/signin", req.url);
signInUrl.searchParams.set("callbackUrl", "/");
return NextResponse.redirect(signInUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: "/api/:path*",
// Protect all app and API routes; allow Next.js internals, public assets, auth pages, and NextAuth endpoints.
matcher: [
"/",
"/((?!_next/static|_next/image|favicon.ico|robots.txt|manifest.json|sitemap.xml|icons/|api/auth|auth/).*)",
],
};

View File

@@ -1,70 +1,89 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { NextResponse } = vi.hoisted(() => ({
const { NextResponse, getToken } = vi.hoisted(() => ({
NextResponse: {
json: vi.fn((body, init) => ({ type: "json", body, init })),
next: vi.fn(() => ({ type: "next" })),
redirect: vi.fn((url) => ({ type: "redirect", url })),
},
getToken: vi.fn(),
}));
vi.mock("next/server", () => ({ NextResponse }));
vi.mock("next-auth/jwt", () => ({ getToken }));
import { middleware } from "./middleware";
async function loadMiddleware() {
vi.resetModules();
const mod = await import("./middleware");
return mod.middleware;
}
function createReq(host) {
function createReq(url = "http://localhost:3000/") {
return {
url,
headers: {
get: (key) => (key === "host" ? host : null),
get: () => null,
},
};
}
describe("middleware", () => {
const originalEnv = process.env;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
});
it("allows requests for default localhost hosts", () => {
process.env.PORT = "3000";
const res = middleware(createReq("localhost:3000"));
it("allows requests when auth is disabled", async () => {
const middleware = await loadMiddleware();
const res = await middleware(createReq());
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });
});
it("blocks requests when host is not allowed", () => {
process.env.PORT = "3000";
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
it("warns once when HOMEPAGE_ALLOWED_HOSTS is set, but does not block", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com";
const res = middleware(createReq("evil.com"));
expect(errSpy).toHaveBeenCalled();
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: "Host validation failed. See logs for more details." },
{ status: 400 },
);
expect(res.type).toBe("json");
expect(res.init.status).toBe(400);
});
it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => {
process.env.HOMEPAGE_ALLOWED_HOSTS = "*";
const res = middleware(createReq("anything.example"));
const middleware = await loadMiddleware();
const res1 = await middleware(createReq());
const res2 = await middleware(createReq());
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });
expect(res1).toEqual({ type: "next" });
expect(res2).toEqual({ type: "next" });
});
it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => {
process.env.PORT = "3000";
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000";
it("redirects to signin when auth is enabled and no token is present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
const res = middleware(createReq("example.com:3000"));
getToken.mockResolvedValueOnce(null);
const middleware = await loadMiddleware();
const res = await middleware(createReq("http://localhost:3000/some"));
expect(getToken).toHaveBeenCalledWith({
req: expect.objectContaining({ url: "http://localhost:3000/some" }),
secret: "secret",
});
expect(NextResponse.redirect).toHaveBeenCalled();
expect(res.type).toBe("redirect");
expect(String(res.url)).toContain("/auth/signin");
});
it("allows requests when auth is enabled and a token is present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
getToken.mockResolvedValueOnce({ sub: "user" });
const middleware = await loadMiddleware();
const res = await middleware(createReq("http://localhost:3000/"));
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });

View File

@@ -1,4 +1,5 @@
/* eslint-disable react/jsx-props-no-spreading */
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import Head from "next/head";
import "styles/globals.css";
@@ -69,28 +70,30 @@ const tailwindSafelist = [
function MyApp({ Component, pageProps }) {
return (
<SWRConfig
value={{
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
}}
>
<Head>
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
</Head>
<ColorProvider>
<ThemeProvider>
<SettingsProvider>
<TabProvider>
<Component {...pageProps} />
</TabProvider>
</SettingsProvider>
</ThemeProvider>
</ColorProvider>
</SWRConfig>
<SessionProvider session={pageProps.session}>
<SWRConfig
value={{
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
}}
>
<Head>
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
</Head>
<ColorProvider>
<ThemeProvider>
<SettingsProvider>
<TabProvider>
<Component {...pageProps} />
</TabProvider>
</SettingsProvider>
</ThemeProvider>
</ColorProvider>
</SWRConfig>
</SessionProvider>
);
}

View File

@@ -0,0 +1,114 @@
import { timingSafeEqual } from "node:crypto";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET;
const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL;
const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
// Map HOMEPAGE_* envs to what NextAuth expects
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
}
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
process.env.NEXTAUTH_URL = homepageExternalUrl;
}
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
const hasOidcConfig = Boolean(issuer && clientId && clientSecret);
const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret);
if (authEnabled) {
if (hasOidcConfig) {
if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) {
throw new Error("OIDC auth is enabled but required settings are missing.");
}
} else if (hasAnyOidcConfig) {
throw new Error("OIDC auth is enabled but required settings are missing.");
} else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) {
throw new Error("Password auth is enabled but required settings are missing.");
}
}
let providers = [];
if (authEnabled) {
if (hasOidcConfig) {
providers = [
{
id: "homepage-oidc",
name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC",
type: "oauth",
idToken: true,
issuer: cleanedIssuer,
wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`,
clientId,
clientSecret,
authorization: {
params: {
scope: defaultScope,
},
},
profile(profile) {
return {
id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email,
name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email,
email: profile.email ?? null,
image: profile.picture ?? null,
};
},
},
];
} else {
providers = [
CredentialsProvider({
name: "Password",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const provided = credentials?.password ?? "";
const expected = homepageAuthPassword ?? "";
if (!expected || provided.length !== expected.length) {
return null;
}
const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!isMatch) {
return null;
}
return {
id: "homepage",
name: "Homepage",
};
},
}),
];
}
}
export default NextAuth({
providers,
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
pages: {
signIn: "/auth/signin",
},
debug: true,
logger: {
error: (...args) => console.error("[nextauth][error]", ...args),
warn: (...args) => console.warn("[nextauth][warn]", ...args),
debug: (...args) => console.debug("[nextauth][debug]", ...args),
},
events: {
signIn: async (message) => console.debug("[nextauth][event][signIn]", message),
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
error: async (message) => console.error("[nextauth][event][error]", message),
},
});

View File

@@ -29,7 +29,7 @@ export default async function handler(req, res) {
if (serviceProxyHandler instanceof Function) {
// quick return for no endpoint services, calendar is an exception
if (!req.query.endpoint || serviceProxyHandler === calendarProxyHandler) {
return await serviceProxyHandler(req, res);
return serviceProxyHandler(req, res);
}
// map opaque endpoints to their actual endpoint
@@ -90,15 +90,15 @@ export default async function handler(req, res) {
}
if (endpointProxy instanceof Function) {
return await endpointProxy(req, res, map);
return endpointProxy(req, res, map);
}
return await serviceProxyHandler(req, res, map);
return serviceProxyHandler(req, res, map);
}
if (widget.allowedEndpoints instanceof RegExp) {
if (widget.allowedEndpoints.test(req.query.endpoint)) {
return await serviceProxyHandler(req, res);
return serviceProxyHandler(req, res);
}
}

View File

@@ -4,21 +4,6 @@ import createLogger from "utils/logger";
const logger = createLogger("resources");
function isMissingNetworkStat(networkData, interfaceName) {
return (
networkData.operstate === "unknown" &&
networkData.rx_bytes === 0 &&
networkData.rx_dropped === 0 &&
networkData.rx_errors === 0 &&
networkData.tx_bytes === 0 &&
networkData.tx_dropped === 0 &&
networkData.tx_errors === 0 &&
networkData.rx_sec === null &&
networkData.tx_sec === null &&
networkData.ms === 0
);
}
export default async function handler(req, res) {
const { type, target, interfaceName = "default" } = req.query;
@@ -79,17 +64,6 @@ export default async function handler(req, res) {
logger.debug("networkData:", JSON.stringify(networkData));
if (interfaceName && interfaceName !== "default") {
networkData = networkData.filter((network) => network.iface === interfaceName).at(0);
if (!networkData) {
// Fallback for e.g. docker where networkStats("*") may not return stats for host interfaces
const directNetworkData = await si.networkStats(interfaceName);
logger.debug("directNetworkData:", JSON.stringify(directNetworkData));
networkData = Array.isArray(directNetworkData) ? directNetworkData.at(0) : null;
// si returns unknown + zeroes when interface truly does not exist
if (!networkData || isMissingNetworkStat(networkData, interfaceName)) {
networkData = null;
}
}
if (!networkData) {
return res.status(404).json({
error: "Interface not found",

210
src/pages/auth/signin.jsx Normal file
View File

@@ -0,0 +1,210 @@
import classNames from "classnames";
import { getProviders, signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { BiShieldQuarter } from "react-icons/bi";
import { getSettings } from "utils/config/config";
export default function SignIn({ providers, settings }) {
const router = useRouter();
const [password, setPassword] = useState("");
const theme = settings?.theme || "dark";
const color = settings?.color || "slate";
const title = settings?.title || "Homepage";
const callbackUrl = useMemo(() => {
const value = router.query?.callbackUrl;
return typeof value === "string" ? value : "/";
}, [router.query?.callbackUrl]);
const error = router.query?.error;
let backgroundImage = "";
let opacity = settings?.backgroundOpacity ?? 0;
let backgroundBlur = false;
let backgroundSaturate = false;
let backgroundBrightness = false;
if (settings?.background) {
const bg = settings.background;
if (typeof bg === "object") {
backgroundImage = bg.image || "";
if (bg.opacity !== undefined) {
opacity = 1 - bg.opacity / 100;
}
backgroundBlur = bg.blur !== undefined;
backgroundSaturate = bg.saturate !== undefined;
backgroundBrightness = bg.brightness !== undefined;
} else {
backgroundImage = bg;
}
}
useEffect(() => {
const html = document.documentElement;
const body = document.body;
html.classList.remove("dark", "scheme-dark", "scheme-light");
html.classList.toggle("dark", theme === "dark");
html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light");
const desiredThemeClass = `theme-${color}`;
const themeClassesToRemove = Array.from(html.classList).filter(
(cls) => cls.startsWith("theme-") && cls !== desiredThemeClass,
);
if (themeClassesToRemove.length) {
html.classList.remove(...themeClassesToRemove);
}
if (!html.classList.contains(desiredThemeClass)) {
html.classList.add(desiredThemeClass);
}
body.style.backgroundImage = "";
body.style.backgroundColor = "";
body.style.backgroundAttachment = "";
}, [color, theme]);
if (!providers || Object.keys(providers).length === 0) {
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main
className={classNames(
"relative flex min-h-screen items-center justify-center px-6 py-12",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="relative w-full max-w-xl overflow-hidden rounded-3xl border border-white/40 bg-white/80 p-10 text-center shadow-2xl shadow-black/10 dark:border-white/10 dark:bg-slate-900/70">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-theme-500/20 via-theme-500/5 to-transparent"
/>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
<BiShieldQuarter className="h-6 w-6" />
</div>
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
Authentication not configured
</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
</div>
</main>
</>
);
}
const passwordProvider = providers
? Object.values(providers).find((provider) => provider.type === "credentials")
: null;
const hasPasswordProvider = Boolean(passwordProvider);
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main className="relative flex min-h-screen items-center justify-center px-6 py-12">
<div
className={classNames(
"relative w-full max-w-4xl overflow-hidden rounded-3xl border border-white/50 bg-white/80 shadow-2xl shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="pointer-events-none absolute -left-24 -top-20 h-64 w-64 rounded-full bg-theme-500/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-theme-500/10 blur-3xl" />
<div className="grid gap-10 px-8 py-12 md:grid-cols-[1.2fr_1fr] md:px-12">
<section className="flex flex-col justify-between">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-theme-500/30 bg-theme-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-theme-600 dark:text-theme-300">
Login Required
</div>
<h1 className="mt-6 text-3xl font-semibold text-gray-900 dark:text-slate-100">{title}</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-300">Login to view your dashboard.</p>
</div>
</section>
<section className="flex flex-col justify-center gap-6">
<div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70">
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
<div className="mt-6 space-y-3">
{hasPasswordProvider && (
<form
className="space-y-3"
onSubmit={async (event) => {
event.preventDefault();
await signIn(passwordProvider?.id ?? "credentials", {
redirect: true,
callbackUrl,
password,
});
}}
>
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300">Password</label>
<input
type="password"
name="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
className="w-full rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-gray-900 shadow-sm outline-none ring-0 transition focus:border-theme-500 focus:ring-2 focus:ring-theme-500/30 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-100"
required
/>
<button
type="submit"
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Sign in &rarr;</span>
</button>
</form>
)}
{!hasPasswordProvider &&
Object.values(providers).map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => signIn(provider.id, { callbackUrl })}
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Login via {provider.name} &rarr;</span>
</button>
))}
</div>
{hasPasswordProvider && error && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800/60 dark:bg-red-950/40 dark:text-red-200">
Invalid password. Please try again.
</p>
)}
</div>
</section>
</div>
</div>
</main>
</>
);
}
export async function getServerSideProps(context) {
const providers = await getProviders();
const settings = getSettings();
return {
props: { providers, settings },
};
}

View File

@@ -1,12 +1,4 @@
import { expect } from "vitest";
export function findServiceBlockByLabel(container, label) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
return blocks.find((b) => b.textContent?.includes(label));
}
export function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}

View File

@@ -313,7 +313,7 @@ export function cleanServiceGroups(groups) {
enableNowPlaying,
enableMediaControl,
// emby, jellyfin, tautulli, tracearr
// emby, jellyfin, tautulli
enableUser,
expandOneStreamToTwoRows,
showEpisodeNumber,
@@ -542,15 +542,12 @@ export function cleanServiceGroups(groups) {
if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
}
if (["emby", "jellyfin", "tautulli", "tracearr"].includes(type)) {
if (["emby", "jellyfin", "tautulli"].includes(type)) {
if (expandOneStreamToTwoRows !== undefined)
widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);
if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);
if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser);
}
if (type === "tracearr") {
if (view !== undefined) widget.view = view;
}
if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue);
}

View File

@@ -312,13 +312,6 @@ describe("utils/config/service-helpers", () => {
{ type: "healthchecks", uuid: "u" },
{ type: "speedtest", bitratePrecision: "3", version: "1" },
{ type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
{
type: "tracearr",
expandOneStreamToTwoRows: "true",
showEpisodeNumber: "true",
enableUser: "true",
view: "both",
},
{ type: "wgeasy", threshold: "10", version: "1" },
{ type: "technitium", range: "24h" },
{ type: "lubelogger", vehicleID: "12" },
@@ -357,14 +350,6 @@ describe("utils/config/service-helpers", () => {
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
expect(widgets.find((w) => w.type === "tracearr")).toEqual(
expect.objectContaining({
expandOneStreamToTwoRows: true,
showEpisodeNumber: true,
enableUser: true,
view: "both",
}),
);
expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
});

View File

@@ -74,21 +74,6 @@ const toNumber = (value) => {
return undefined;
};
const extractNumericToken = (value) => {
if (typeof value !== "string") return undefined;
const match = value.match(/[-+]?\d[\d\s.,]*/);
if (!match) return undefined;
const token = match[0].trim();
if (!token) return undefined;
const prefix = value.slice(0, match.index).trim();
const suffix = value.slice((match.index ?? 0) + match[0].length).trim();
if (/\d/.test(prefix) || /\d/.test(suffix)) return undefined;
return token;
};
const parseNumericValue = (value) => {
if (value === null || value === undefined) return undefined;
if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -100,9 +85,7 @@ const parseNumericValue = (value) => {
const direct = Number(trimmed);
if (!Number.isNaN(direct)) return direct;
const candidate = extractNumericToken(trimmed);
const numericString = candidate ?? trimmed;
const compact = numericString.replace(/\s+/g, "");
const compact = trimmed.replace(/\s+/g, "");
if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined;
const commaCount = (compact.match(/,/g) || []).length;

View File

@@ -136,9 +136,6 @@ describe("utils/highlights", () => {
const cfg = buildHighlightConfig(null, {
// string numeric rule values go through toNumber()
gt: { numeric: { when: "gt", value: "5", level: "warn" } },
withUnitSuffix: { numeric: { when: "gt", value: 5, level: "warn" } },
withUnitPrefix: { numeric: { when: "gt", value: 5, level: "warn" } },
localizedUnitSuffix: { numeric: { when: "gt", value: 0.5, level: "warn" } },
commaGrouped: { numeric: { when: "eq", value: 1234, level: "good" } },
commaDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
dotDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
@@ -146,12 +143,6 @@ describe("utils/highlights", () => {
});
expect(evaluateHighlight("gt", "6", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("withUnitSuffix", "5.2 ms", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("withUnitPrefix", "ms 5.2", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("localizedUnitSuffix", "0,71\u202Fms", cfg)).toMatchObject({
level: "warn",
source: "numeric",
});
expect(evaluateHighlight("commaGrouped", "1,234", cfg)).toMatchObject({ level: "good", source: "numeric" });
expect(evaluateHighlight("commaDecimal", "12,34", cfg)).toMatchObject({ level: "good", source: "numeric" });
// Include a space so Number(trimmed) fails and we exercise the dot parsing branch.
@@ -170,9 +161,6 @@ describe("utils/highlights", () => {
// "1.2.3" is not a valid grouped or decimal number for our parser.
expect(evaluateHighlight("num", "1.2.3", cfg)).toBeNull();
// Multiple numbers in one string should not be treated as a single numeric value.
expect(evaluateHighlight("num", "5/10 ms", cfg)).toBeNull();
// JSX-ish values should not be treated as numeric.
expect(evaluateHighlight("num", { props: { children: "x" } }, cfg)).toBeNull();
});

View File

@@ -6,7 +6,7 @@ export function formatApiCall(url, args) {
if (key === "url") {
value = value.replace(/\/+$/, ""); // remove trailing slashes
}
return value?.toString() || "";
return value || "";
};
return url.replace(find, replace).replace(find, replace);

View File

@@ -64,7 +64,6 @@ export default async function credentialedProxyHandler(req, res, map) {
"pangolin",
"tailscale",
"tandoor",
"tracearr",
"pterodactyl",
"vikunja",
"firefly",

View File

@@ -24,8 +24,7 @@ export default function Component({ service }) {
const { data: images, error: imagesError } = useWidgetAPI(widget, "images");
const { data: updates, error: updatesError } = useWidgetAPI(widget, "updates");
const error =
containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;
const error = containersError ?? imagesError ?? updatesError;
if (error) {
return <Container service={service} error={error} />;
}

View File

@@ -4,7 +4,6 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -14,6 +13,13 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/arcane/component", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -28,16 +34,6 @@ describe("widgets/arcane/component", () => {
expect(screen.getByText("arcane.environment_required")).toBeInTheDocument();
});
it("shows an error when API calls return detail errors", () => {
useWidgetAPI.mockImplementation(() => ({ data: { detail: "Specific API error" }, error: undefined }));
renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("Specific API error")).toBeInTheDocument();
});
it("renders placeholders while loading data", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));

View File

@@ -4,7 +4,6 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -14,6 +13,13 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/argocd/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,6 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -14,6 +13,13 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/audiobookshelf/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,6 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -14,6 +13,13 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/authentik/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,6 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -14,6 +13,13 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/autobrr/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,6 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -14,6 +13,13 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/azuredevops/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,6 +12,12 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/beszel/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,6 +12,12 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/caddy/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,6 +12,12 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/changedetectionio/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,6 +12,12 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/channelsdvrserver/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,6 +12,12 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/checkmk/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,6 +12,12 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/cloudflared/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -65,7 +65,7 @@ const components = {
jackett: dynamic(() => import("./jackett/component")),
jdownloader: dynamic(() => import("./jdownloader/component")),
jellyfin: dynamic(() => import("./jellyfin/component")),
jellyseerr: dynamic(() => import("./seerr/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")),
jellystat: dynamic(() => import("./jellystat/component")),
kavita: dynamic(() => import("./kavita/component")),
komga: dynamic(() => import("./komga/component")),
@@ -97,7 +97,7 @@ const components = {
ombi: dynamic(() => import("./ombi/component")),
opendtu: dynamic(() => import("./opendtu/component")),
opnsense: dynamic(() => import("./opnsense/component")),
overseerr: dynamic(() => import("./seerr/component")),
overseerr: dynamic(() => import("./overseerr/component")),
openmediavault: dynamic(() => import("./openmediavault/component")),
openwrt: dynamic(() => import("./openwrt/component")),
paperlessngx: dynamic(() => import("./paperlessngx/component")),
@@ -124,10 +124,8 @@ const components = {
rutorrent: dynamic(() => import("./rutorrent/component")),
sabnzbd: dynamic(() => import("./sabnzbd/component")),
scrutiny: dynamic(() => import("./scrutiny/component")),
seerr: dynamic(() => import("./seerr/component")),
slskd: dynamic(() => import("./slskd/component")),
sonarr: dynamic(() => import("./sonarr/component")),
sparkyfitness: dynamic(() => import("./sparkyfitness/component")),
speedtest: dynamic(() => import("./speedtest/component")),
spoolman: dynamic(() => import("./spoolman/component")),
stash: dynamic(() => import("./stash/component")),
@@ -140,7 +138,6 @@ const components = {
tautulli: dynamic(() => import("./tautulli/component")),
technitium: dynamic(() => import("./technitium/component")),
tdarr: dynamic(() => import("./tdarr/component")),
tracearr: dynamic(() => import("./tracearr/component")),
traefik: dynamic(() => import("./traefik/component")),
transmission: dynamic(() => import("./transmission/component")),
trilium: dynamic(() => import("./trilium/component")),

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,6 +12,12 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/crowdsec/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -16,6 +16,12 @@ vi.mock("../../components/widgets/queue/queueEntry", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/deluge/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/diskstation/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/dispatcharr/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/dockhand/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/downloadstation/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/esphome/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/evcc/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/filebrowser/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/fileflows/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/flood/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/freshrss/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/frigate/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { fritzboxDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/fritzbox/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gamedig/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gatus/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/ghostfolio/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gitea/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gitlab/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -8,9 +8,7 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
const statusMap = {
running: <ResolvedIcon icon="mdi-circle" width={32} height={32} />,
healthy: <ResolvedIcon icon="mdi-circle" width={32} height={32} />,
paused: <ResolvedIcon icon="mdi-circle-outline" width={32} height={32} />,
stopped: <ResolvedIcon icon="mdi-circle-double" width={32} height={32} />,
};
const defaultInterval = 1000;

View File

@@ -11,15 +11,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
// Avoid pulling Next/Image + ThemeContext requirements into these unit tests.
vi.mock("components/resolvedicon", () => ({ default: () => <span data-testid="resolvedicon" /> }));
vi.mock("next-i18next", () => ({
useTranslation: () => ({
t: (key, opts) => (key === "common.bytes" ? `${key}:${opts?.value}` : key),
}),
}));
// Avoid pulling Next/Image + ThemeContext requirements into these unit tests.
vi.mock("components/resolvedicon", () => ({ default: () => <span data-testid="resolvedicon" /> }));
import Component from "./containers";
describe("widgets/glances/metrics/containers", () => {
@@ -30,78 +21,4 @@ describe("widgets/glances/metrics/containers", () => {
});
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders a placeholder while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders nothing when there is an error", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: new Error("fail") });
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.queryByText("resources.cpu")).not.toBeInTheDocument();
expect(screen.queryByText("-")).not.toBeInTheDocument();
});
it("renders container rows using v3 keys and formats values", () => {
useWidgetAPI.mockReturnValue({
data: [
{
Id: "one",
Status: "running",
name: "alpha",
cpu_percent: 12.34,
memory: { usage: 1000, inactive_file: 400 },
},
{
Id: "two",
Status: "paused",
name: "beta",
cpu_percent: 99.99,
memory: { usage: 2000, inactive_file: 1000 },
},
],
error: undefined,
});
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
// data.splice(1) keeps only one item when chart is false
expect(screen.getByText("resources.cpu")).toBeInTheDocument();
expect(screen.getByText("resources.mem")).toBeInTheDocument();
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.queryByText("beta")).not.toBeInTheDocument();
expect(screen.getByText("12.3%")).toBeInTheDocument();
expect(screen.getByText("common.bytes:600")).toBeInTheDocument();
expect(screen.getAllByTestId("resolvedicon")).toHaveLength(1);
});
it("limits rows to 5 when chart is enabled", () => {
const data = Array.from({ length: 6 }).map((_, index) => ({
Id: `id-${index}`,
Status: "healthy",
name: `item-${index}`,
cpu_percent: index + 0.1,
memory: { usage: 100 * (index + 1), inactive_file: 0 },
}));
useWidgetAPI.mockReturnValue({ data, error: undefined });
renderWithProviders(<Component service={{ widget: { chart: true, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("item-0")).toBeInTheDocument();
expect(screen.getByText("item-4")).toBeInTheDocument();
expect(screen.queryByText("item-5")).not.toBeInTheDocument();
});
});

View File

@@ -46,7 +46,7 @@ export default function Component({ service }) {
let listYPosition = "bottom-4";
if (chart) {
headerYPosition = "-top-6";
listYPosition = "-top-2";
listYPosition = "-top-3";
}
return (

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gluetun/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gotify/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/grafana/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/hdhomerun/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/headscale/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/healthchecks/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { homeboxDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/homebox/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/immich/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/jackett/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,13 +4,19 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/jdownloader/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -0,0 +1,39 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
export const jellyseerrDefaultFields = ["pending", "approved", "available"];
export default function Component({ service }) {
const { widget } = service;
widget.fields = widget?.fields?.length ? widget.fields : jellyseerrDefaultFields;
const isIssueEnabled = widget.fields.includes("issues");
const { data: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
const { data: issueData, error: issueError } = useWidgetAPI(widget, isIssueEnabled ? "issue/count" : "");
if (statsError || (isIssueEnabled && issueError)) {
return <Container service={service} error={statsError ? statsError : issueError} />;
}
if (!statsData || (isIssueEnabled && !issueData)) {
return (
<Container service={service}>
<Block label="jellyseerr.pending" />
<Block label="jellyseerr.approved" />
<Block label="jellyseerr.available" />
<Block label="jellyseerr.issues" />
</Container>
);
}
return (
<Container service={service}>
<Block label="jellyseerr.pending" value={statsData.pending} />
<Block label="jellyseerr.approved" value={statsData.approved} />
<Block label="jellyseerr.available" value={statsData.available} />
<Block label="jellyseerr.issues" value={`${issueData?.open} / ${issueData?.total}`} />
</Container>
);
}

View File

@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { jellyseerrDefaultFields } from "./component";
describe("widgets/jellyseerr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults fields and filters to 3 blocks while loading when issues are not enabled", () => {
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: undefined }) // request/count
.mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
const service = { widget: { type: "jellyseerr", url: "http://x" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(jellyseerrDefaultFields);
expect(useWidgetAPI.mock.calls[1][1]).toBe("");
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("jellyseerr.pending")).toBeInTheDocument();
expect(screen.getByText("jellyseerr.approved")).toBeInTheDocument();
expect(screen.getByText("jellyseerr.available")).toBeInTheDocument();
expect(screen.queryByText("jellyseerr.issues")).toBeNull();
});
it("renders issues when enabled (and calls the issue/count endpoint)", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3 }, error: undefined })
.mockReturnValueOnce({ data: { open: 1, total: 2 }, error: undefined });
const service = {
widget: { type: "jellyseerr", url: "http://x", fields: ["pending", "approved", "available", "issues"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(useWidgetAPI.mock.calls[1][1]).toBe("issue/count");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("1 / 2")).toBeInTheDocument();
});
it("renders error UI when issues are enabled and issue/count errors", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { pending: 0, approved: 0, available: 0 }, error: undefined })
.mockReturnValueOnce({ data: undefined, error: { message: "nope" } });
renderWithProviders(
<Component service={{ widget: { type: "jellyseerr", url: "http://x", fields: ["issues"] } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
});

View File

@@ -4,7 +4,7 @@ import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("seerr widget config", () => {
describe("jellyseerr widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});

Some files were not shown because too many files have changed in this diff Show More