Compare commits

...

31 Commits

Author SHA1 Message Date
shamoon
b48d283dc2 Retry Omada login on HTML response; preserve cookies 2026-03-04 11:34:44 -08:00
shamoon
d313e0a124 Add some debug logging for omada 2026-03-02 09:22:39 -08:00
shamoon
51d718a21a Fix: small fixes for Omada proxy (#6372) 2026-02-28 11:36:43 -08:00
shamoon
29e2502d74 Fix: Await async proxy handlers (#6371) 2026-02-28 11:22:54 -08:00
shamoon
d529f81cb4 Enhancement: fallback for missing si network stats (#6367) 2026-02-27 10:39:02 -08:00
Erkan
1645c1b8a1 Documentation: clarify array value quoting in Docker label mapping syntax (#6356)
Co-authored-by: shamoon <shamoon@users.noreply.github.com>
2026-02-23 22:16:22 +00:00
shamoon
614a87d768 DRY 2026-02-20 20:25:34 -08:00
shamoon
862c5d9f38 Add missing tracearr to docs 2026-02-20 20:21:27 -08:00
shamoon
d3374dc461 Feature: sparkyfitness service widget (#6346) 2026-02-20 20:20:59 -08:00
shamoon
795e2505ca Fix links in CONTRIBUTING.md for development guidelines 2026-02-20 09:54:23 -08:00
shamoon
cb8421df0b feature request template search reminder 2026-02-16 14:38:34 -08:00
shamoon
152888d611 Enhancement: cover more basic statuses in containers list (#6334) 2026-02-16 08:43:06 -08:00
shamoon
ea527e4fb1 Enhancement: add "Temperature" label to list of possible CPU sensors (#6331) 2026-02-16 00:09:14 -08:00
shamoon
09bab7637e Chore: merge Overseerr into Seerr, add aliases (#6330) 2026-02-15 19:12:15 -08:00
shamoon
597f6ecf16 Enhancement: jellyseer completed (#6329) 2026-02-15 18:44:03 -08:00
shamoon
fe0b214334 Chore: rename Jellyseerr widget to Seerr and update references (#6322) 2026-02-14 16:11:00 -08:00
shamoon
cdc96438cd Adjust process list vertical offset 2026-02-14 07:14:14 -08:00
shamoon
ca7dfb56c8 Improvement: better handle highlighting with units (#6318) 2026-02-13 10:00:46 -08:00
shamoon
95852d23c2 Lower Codecov coverage thresholds 2026-02-09 19:36:33 -08:00
Bothari
84231a1754 Feature: add Tracearr widget for displaying active Plex streams (#6306)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-10 03:35:54 +00:00
Matt Popovich
f4f54cea60 Documentation: clarify jellyfin api key location, add ports (#6298)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-07 09:38:17 +00:00
shamoon
06595ef107 Revise PR template checklist for clarity and links 2026-02-06 07:53:26 -08:00
dependabot[bot]
91b9aa479a Chore(deps): Bump actions/setup-node from 4 to 6 (#6285)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 13:48:18 -08:00
dependabot[bot]
08cde2f597 Chore(deps): Bump actions/checkout from 4 to 6 (#6284)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 13:40:57 -08:00
shamoon
0ce175cda5 Bump version to 1.10.1 2026-02-05 07:02:59 -08:00
shamoon
7f1de58e71 Fix: safely stringify replacement values 2026-02-05 07:02:07 -08:00
shamoon
f729290e96 Enhancement: better display of Arcane widget errors (#6281) 2026-02-05 00:33:38 -08:00
shamoon
4974cd96b6 Chore: move to Zensical docs (#6279) 2026-02-04 23:05:11 -08:00
shamoon
4450a6e1d0 Merge branch 'main' into dev 2026-02-04 22:12:16 -08:00
shamoon
ac11efc5c7 Fix eslint warnings in test 2026-02-04 22:06:33 -08:00
shamoon
3c005d239e Add Codecov badge to README 2026-02-04 22:03:08 -08:00
179 changed files with 1931 additions and 1087 deletions

View File

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

View File

@@ -1,6 +1,10 @@
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.
- [ ] 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 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 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,7 +9,9 @@ on:
workflow_dispatch:
permissions:
contents: write
contents: read
pages: write
id-token: write
jobs:
pre-commit:
@@ -35,44 +37,34 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
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-
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
- run: sudo apt-get install pngquant
- run: pip install mkdocs-material mkdocs-redirects "mkdocs-material[imaging]"
- name: Test Docs Build
run: MKINSIDERS=false mkdocs build
run: uv run --frozen zensical build --clean
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: 3.x
- run: echo "cache_id=${{github.sha}}" >> $GITHUB_ENV
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
- 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 }}
- name: Build Docs
run: uv run --frozen zensical build --clean
- uses: actions/upload-pages-artifact@v4
with:
path: site
- uses: actions/deploy-pages@v4
id: deployment

View File

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

2
.gitignore vendored
View File

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

1
.python-version Normal file
View File

@@ -0,0 +1 @@
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/more/development/) and specifically the [guidelines for new service widgets](https://gethomepage.dev/more/development/#service-widget-guidelines) if you are considering making one.
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.
## Use a Consistent Coding Style
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks).
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).
## License

View File

@@ -16,6 +16,8 @@
<p align="center">
<a href="https://github.com/gethomepage/homepage/actions/workflows/docker-publish.yml"><img alt="GitHub Workflow Status (with event)" src="https://img.shields.io/github/actions/workflow/status/gethomepage/homepage/docker-publish.yml"></a>
&nbsp;
<a href="https://codecov.io/gh/gethomepage/homepage"><img src="https://codecov.io/gh/gethomepage/homepage/graph/badge.svg?token=7SKFL4D9K7"/></a>
&nbsp;
<a href="https://crowdin.com/project/gethomepage" target="_blank"><img src="https://badges.crowdin.net/gethomepage/localized.svg"></a>
&nbsp;
<a href="https://discord.gg/k4ruYNrudu"><img alt="Discord" src="https://img.shields.io/discord/1019316731635834932"></a>
@@ -154,16 +156,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 Material for MkDocs for documentation. To run the documentation locally, first install the dependencies:
Homepage uses Zensical for documentation. To run the documentation locally, first install the dependencies:
```bash
pip install -r requirements.txt
uv sync
```
Then run the development server:
```bash
mkdocs serve # or build, to build the static site
uv run zensical serve # or build, to build the static site
```
# Support & Suggestions

View File

@@ -177,6 +177,16 @@ 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

@@ -7,13 +7,17 @@ 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.
_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._
!!! warning
**Any disk you wish to access must be mounted to your container as a volume.**
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.
```yaml
- resources:
@@ -75,3 +79,10 @@ 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)
- [Jellyseerr](jellyseerr.md)
- [Seerr](seerr.md)
- [Jellystat](jellystat.md)
- [Kavita](kavita.md)
- [Komga](komga.md)
@@ -101,7 +101,6 @@ 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 Jellyfin at `Settings > Advanced > Api Keys`.
You can create an API key from inside the Jellyfin Administration Dashboard under `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
url: http://jellyfin.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
version: 2 # optional, default is 1
enableBlocks: true # optional, defaults to false

View File

@@ -1,18 +0,0 @@
---
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

@@ -1,17 +0,0 @@
---
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
url: http://tautulli.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false

View File

@@ -0,0 +1,20 @@
---
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

@@ -0,0 +1,15 @@
---
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

@@ -0,0 +1,21 @@
---
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,7 +91,6 @@ 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
@@ -125,7 +124,6 @@ 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
@@ -151,8 +149,10 @@ 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,6 +165,7 @@ 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.0",
"version": "1.10.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",

View File

@@ -184,6 +184,13 @@
"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",
@@ -282,17 +289,13 @@
"approved": "Approved",
"available": "Available"
},
"jellyseerr": {
"seerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available",
"issues": "Open Issues"
},
"overseerr": {
"pending": "Pending",
"completed": "Completed",
"processing": "Processing",
"approved": "Approved",
"available": "Available"
"issues": "Open Issues"
},
"netalertx": {
"total": "Total",
@@ -1171,5 +1174,11 @@
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
},
"sparkyfitness": {
"eaten": "Eaten",
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
}
}

8
pyproject.toml Normal file
View File

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

View File

@@ -1,47 +0,0 @@
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

@@ -344,4 +344,17 @@ 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,17 +90,74 @@ describe("pages/api/widgets/resources", () => {
});
it("returns 404 when requested network interface does not exist", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]);
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,
},
]);
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

@@ -339,7 +339,7 @@ describe("pages/index Index routing + SWR branches", () => {
localStorage.setItem("hash", "old-hash");
const fetchSpy = vi.fn(async () => ({ ok: true }));
// eslint-disable-next-line no-global-assign
fetch = fetchSpy;
let reloadSpy;

View File

@@ -277,7 +277,7 @@ describe("components/quicklaunch", () => {
const fetchSpy = vi.fn(async () => ({
json: async () => ["test", ["test 1", "test 2", "test 3", "test 4", "test 5"]],
}));
// eslint-disable-next-line no-global-assign
fetch = fetchSpy;
renderWithProviders(<Wrapper />, {
@@ -315,7 +315,6 @@ describe("components/quicklaunch", () => {
expect(input).toHaveValue("test 1");
// eslint-disable-next-line no-global-assign
fetch = originalFetch;
});

View File

@@ -9,6 +9,8 @@ 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,6 +58,26 @@ 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"];
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
function convertToFahrenheit(t) {
return (t * 9) / 5 + 32;

View File

@@ -32,7 +32,13 @@ vi.mock("@headlessui/react", async () => {
const ctx = useContext(ListboxContext);
const content = typeof children === "function" ? children({ active: false }) : children;
return (
<div role="option" data-provider={value?.name} onClick={() => ctx?.onChange?.(value)} {...props}>
<div
role="option"
data-provider={value?.name}
aria-selected={ctx?.value === value}
onClick={() => ctx?.onChange?.(value)}
{...props}
>
{content}
</div>
);
@@ -166,7 +172,7 @@ describe("components/widgets/search", () => {
const fetchSpy = vi.fn(async () => ({
json: async () => ["hel", ["hello", "help", "helm", "helium", "held"]],
}));
// eslint-disable-next-line no-global-assign
fetch = fetchSpy;
renderWithProviders(<Search options={{ provider: ["google"], showSearchSuggestions: true, target: "_self" }} />, {
@@ -192,7 +198,7 @@ describe("components/widgets/search", () => {
expect(openSpy).toHaveBeenCalledWith("https://www.google.com/search?q=hello", "_self");
openSpy.mockRestore();
// eslint-disable-next-line no-global-assign
fetch = originalFetch;
});
});

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 serviceProxyHandler(req, res);
return await 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 endpointProxy(req, res, map);
return await endpointProxy(req, res, map);
}
return serviceProxyHandler(req, res, map);
return await serviceProxyHandler(req, res, map);
}
if (widget.allowedEndpoints instanceof RegExp) {
if (widget.allowedEndpoints.test(req.query.endpoint)) {
return serviceProxyHandler(req, res);
return await serviceProxyHandler(req, res);
}
}

View File

@@ -4,6 +4,21 @@ 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;
@@ -64,6 +79,17 @@ 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",

View File

@@ -1,4 +1,12 @@
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
// emby, jellyfin, tautulli, tracearr
enableUser,
expandOneStreamToTwoRows,
showEpisodeNumber,
@@ -542,12 +542,15 @@ export function cleanServiceGroups(groups) {
if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
}
if (["emby", "jellyfin", "tautulli"].includes(type)) {
if (["emby", "jellyfin", "tautulli", "tracearr"].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,6 +312,13 @@ 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" },
@@ -350,6 +357,14 @@ 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,6 +74,21 @@ 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;
@@ -85,7 +100,9 @@ const parseNumericValue = (value) => {
const direct = Number(trimmed);
if (!Number.isNaN(direct)) return direct;
const compact = trimmed.replace(/\s+/g, "");
const candidate = extractNumericToken(trimmed);
const numericString = candidate ?? trimmed;
const compact = numericString.replace(/\s+/g, "");
if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined;
const commaCount = (compact.match(/,/g) || []).length;

View File

@@ -136,6 +136,9 @@ 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" } },
@@ -143,6 +146,12 @@ 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.
@@ -161,6 +170,9 @@ 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 || "";
return value?.toString() || "";
};
return url.replace(find, replace).replace(find, replace);

View File

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

View File

@@ -24,7 +24,8 @@ 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;
const error =
containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;
if (error) {
return <Container service={service} error={error} />;
}

View File

@@ -4,6 +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";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ 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();
@@ -34,6 +28,16 @@ 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,6 +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";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ 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,6 +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";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ 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,6 +4,7 @@ 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() }));
@@ -13,13 +14,6 @@ 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,6 +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";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ 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,6 +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";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ 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("./jellyseerr/component")),
jellyseerr: dynamic(() => import("./seerr/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("./overseerr/component")),
overseerr: dynamic(() => import("./seerr/component")),
openmediavault: dynamic(() => import("./openmediavault/component")),
openwrt: dynamic(() => import("./openwrt/component")),
paperlessngx: dynamic(() => import("./paperlessngx/component")),
@@ -124,8 +124,10 @@ 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")),
@@ -138,6 +140,7 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ 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 { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -16,12 +16,6 @@ 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,7 +8,9 @@ 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,6 +11,15 @@ 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", () => {
@@ -21,4 +30,78 @@ 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-3";
listYPosition = "-top-2";
}
return (

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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

@@ -1,39 +0,0 @@
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

@@ -1,63 +0,0 @@
// @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,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { karakeepDefaultFields } 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/karakeep/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/komga/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/kopia/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/lidarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/linkwarden/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/lubelogger/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/mailcow/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/mastodon/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/mealie/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/medusa/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } 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/mikrotik/component", () => {
beforeEach(() => {
vi.clearAllMocks();

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