Compare commits

..
1 Commits
Author SHA1 Message Date
Crowdin Bot b33f0ae360 New Crowdin translations by GitHub Action
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-09-12 12:24:47 +00:00
85 changed files with 536 additions and 2603 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@9af557de76d70c480f88065d336f445a362f402b # v3.1.0 uses: crowdin/github-action@71fdb8814261dd703be4ca7c6450d21b1868da4b # v3.0.1
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+4 -4
View File
@@ -62,7 +62,7 @@ jobs:
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
version: 10 version: 10
run_install: false run_install: false
@@ -99,14 +99,14 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- run: sudo apt-get install pngquant - run: sudo apt-get install pngquant
- name: Test Docs Build - name: Test Docs Build
run: uv run --frozen zensical build --clean run: uv run --frozen zensical build --clean
@@ -45,7 +45,7 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- run: sudo apt-get install pngquant - run: sudo apt-get install pngquant
- name: Build Docs - name: Build Docs
run: uv run --frozen zensical build --clean run: uv run --frozen zensical build --clean
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
version: 10 version: 10
run_install: false run_install: false
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with: with:
version: 9 version: 9
@@ -31,7 +31,7 @@ jobs:
# Run Vitest directly so `--shard` is parsed as an option # Run Vitest directly so `--shard` is parsed as an option
- run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks - run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info files: ./coverage/lcov.info
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

-58
View File
@@ -430,64 +430,6 @@ headerStyle: clean
headerStyle: boxedWidgets headerStyle: boxedWidgets
``` ```
## Card Style
Service and bookmark cards use a drop shadow by default. The `hairline` style replaces it with a thin border that brightens on hover.
<img width="896" alt="default cards" src="../../assets/style-default.webp">
```yaml
# default, shadowed cards
```
---
<img width="896" alt="hairline cards" src="../../assets/card-style-hairline.webp">
```yaml
cardStyle: hairline
```
## Block Style
Widget blocks (the value / label pairs inside a service card) can use the `refined` style to make values heavier and easier to read.
<img width="896" alt="default blocks" src="../../assets/style-default.webp">
```yaml
# default, standard blocks
```
---
<img width="896" alt="refined blocks" src="../../assets/block-style-refined.webp">
```yaml
blockStyle: refined
```
## Font
Homepage uses Manrope by default. You can switch to IBM Plex Mono instead, either everywhere or for data only.
<img width="896" alt="IBM Plex Mono everywhere" src="../../assets/font-plex.webp">
```yaml
font: plex # the whole dashboard
```
---
<img width="896" alt="IBM Plex Mono for data only" src="../../assets/font-plex-data.webp">
```yaml
font: plexData # only values, stats, the clock and status tags
```
With `plexData`, labels and names stay in Manrope while numbers line up in columns.
Both fonts are bundled with Homepage, so no external requests are made.
## Base URL ## Base URL
In some proxy configurations, it may be necessary to set the documents base URL. You can do this by providing a `base` value, like so: In some proxy configurations, it may be necessary to set the documents base URL. You can do this by providing a `base` value, like so:
-36
View File
@@ -1,36 +0,0 @@
---
title: Custom API
description: Custom API Information Widget Configuration
---
_(Find the Custom API service widget [here](../services/customapi.md))_
The Custom API information widget shows values from a custom self-hosted or third party API in your Homepage header, alongside an icon.
```yaml
- customapi:
url: http://custom.api.host.or.ip:port/path/to/exact/api/endpoint
icon: mdi-api # optional, see below
label: My API # optional, shown below the values
href: https://custom.api.host # optional, makes the widget a link
refreshInterval: 10000 # optional - in milliseconds, defaults to 10s
username: username # auth - optional
password: password # auth - optional
method: GET # optional, e.g. POST
headers: # optional, must be object
X-API-Token: token
requestBody: # optional, can be string or object
mappings:
- field: key
label: Field 1
- field: path.to.key2
label: Field 2
format: number # optional - defaults to text
- field: path.to.key3
label: Field 3
format: percent
```
The `icon` accepts the same values as [service icons](../../configs/services.md#icons), e.g. `mdi-api`, `si-github`, `sh-homepage`, a dashboard icon name like `homepage.png`, or a full URL. If no icon is set, a generic API icon is shown.
Mappings support the same `field`, `format`, `remap`, `scale`, `prefix` and `suffix` options as the [Custom API service widget](../services/customapi.md). The `additionalField` option and the `display` modes are not supported.
-1
View File
@@ -7,7 +7,6 @@ search:
You can also find a list of all available info widgets in the sidebar navigation. You can also find a list of all available info widgets in the sidebar navigation.
- [Custom API](customapi.md)
- [Date & Time](datetime.md) - [Date & Time](datetime.md)
- [Glances](glances.md) - [Glances](glances.md)
- [Greeting](greeting.md) - [Greeting](greeting.md)
+1 -2
View File
@@ -6,7 +6,6 @@ description: Arcane Widget Configuration
Learn more about [Arcane](https://github.com/getarcaneapp/arcane). Learn more about [Arcane](https://github.com/getarcaneapp/arcane).
**Allowed fields** (max 4): `running`, `stopped`, `total`, `images`, `images_used`, `images_unused`, `image_updates`. **Allowed fields** (max 4): `running`, `stopped`, `total`, `images`, `images_used`, `images_unused`, `image_updates`.
**Default fields**: `running`, `stopped`, `total`, `image_updates`. **Default fields**: `running`, `stopped`, `total`, `image_updates`.
```yaml ```yaml
@@ -18,4 +17,4 @@ widget:
fields: ["running", "stopped", "total", "image_updates"] # optional fields: ["running", "stopped", "total", "image_updates"] # optional
``` ```
Using an API key for the widget requires permissions for: `containers:list`, `images:list`, and `image-updates:read`. Using an api key for the widget requires permissions for: `containers:list` `images:list and `image-updates:read`
-2
View File
@@ -3,8 +3,6 @@ title: Custom API
description: Custom Widget Configuration from the API description: Custom Widget Configuration from the API
--- ---
_(Find the Custom API information widget [here](../info/customapi.md))_
This widget can show information from custom self-hosted or third party API. This widget can show information from custom self-hosted or third party API.
Fields need to be defined in the `mappings` section YAML object to correlate with the value in the APIs JSON object. Final field definition needs to be the key with the desired value information. Fields need to be defined in the `mappings` section YAML object to correlate with the value in the APIs JSON object. Final field definition needs to be the key with the desired value information.
-46
View File
@@ -1,46 +0,0 @@
---
title: Feed
description: RSS / Atom feed widget
---
This widget shows the latest items from an RSS 2.0 or Atom feed.
```yaml
widget:
type: feed
url: https://feeds.bbci.co.uk/news/rss.xml
maxItems: 5 # optional - defaults to 5
layout: list # optional - possible values list, grid - defaults to list
images: true # optional - set to false to hide images - defaults to true
```
## Layouts
`list` shows one row per item. Items with an image get a small thumbnail.
![Feed widget list layout](../../assets/widget_feed_list.webp)
![Feed widget list layout with thumbnails](../../assets/widget_feed_thumbnails.webp)
`grid` shows image tiles that re-flow to fit the width of the widget, e.g.:
```yaml
- NASA Image of the Day:
widget:
type: feed
url: https://www.nasa.gov/feeds/iotd-feed/
layout: grid
maxItems: 8
```
![Feed widget grid layout](../../assets/widget_feed_grid.webp)
## Images
Images are taken from the feed's `media:thumbnail` / `media:content` tags or image enclosures, falling back to the first image in the item's content. Some feeds do not include images at all.
Images are loaded by your browser directly from the feed's host, at whatever size the feed provides. Feeds that link full-size originals can be slow to load; set `images: false` for those.
## Notes
Feeds are fetched by homepage, not your browser, and cached for 10 minutes.
-2
View File
@@ -39,7 +39,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Emby](emby.md) - [Emby](emby.md)
- [ESPHome](esphome.md) - [ESPHome](esphome.md)
- [EVCC](evcc.md) - [EVCC](evcc.md)
- [Feed](feed.md)
- [Filebrowser](filebrowser.md) - [Filebrowser](filebrowser.md)
- [Fileflows](fileflows.md) - [Fileflows](fileflows.md)
- [Firefly III](firefly.md) - [Firefly III](firefly.md)
@@ -120,7 +119,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [PyLoad](pyload.md) - [PyLoad](pyload.md)
- [qBittorrent](qbittorrent.md) - [qBittorrent](qbittorrent.md)
- [QNAP](qnap.md) - [QNAP](qnap.md)
- [qui](qui.md)
- [Radarr](radarr.md) - [Radarr](radarr.md)
- [Readarr](readarr.md) - [Readarr](readarr.md)
- [ROMM](romm.md) - [ROMM](romm.md)
+3 -3
View File
@@ -5,7 +5,7 @@ description: Nginx Proxy Manager Widget Configuration
Learn more about [Nginx Proxy Manager](https://nginxproxymanager.com/). Learn more about [Nginx Proxy Manager](https://nginxproxymanager.com/).
Use a "view-only" user for the username and password. Login with the same admin username and password used to access the web UI.
Allowed fields: `["enabled", "disabled", "total"]`. Allowed fields: `["enabled", "disabled", "total"]`.
@@ -13,6 +13,6 @@ Allowed fields: `["enabled", "disabled", "total"]`.
widget: widget:
type: npm type: npm
url: http://npm.host.or.ip url: http://npm.host.or.ip
username: view_only_username username: admin_username
password: view_only_password password: admin_password
``` ```
-30
View File
@@ -1,30 +0,0 @@
---
title: qui
description: qui Widget Configuration
---
Learn more about [qui](https://github.com/autobrr/qui).
Generate an API key in qui under **Settings → API Keys**.
The widget has two modes:
- **Aggregated (default):** omit `instance` to show combined stats across all qBittorrent instances monitored by qui.
- **Per-instance:** set `instance` to the qui ID (visible in the qui UI) to show just that one.
```yaml
widget:
type: qui
url: http://qui.host.or.ip:7476
key: quiapikeyquiapikeyquiapikey
instance: 1 # optional; omit for aggregated stats across all instances
fields: ["leech", "download", "seed", "upload"] # optional
```
## Fields
Allowed fields: `["leech", "download", "seed", "upload", "total", "errored", "ratio", "freeSpace"]` (maximum of 4).
Default fields: `["leech", "download", "seed", "upload"]`.
Note: `ratio` and `freeSpace` are only available with the 'single instance' version of the widget.
-3
View File
@@ -65,7 +65,6 @@ nav:
- widgets/services/emby.md - widgets/services/emby.md
- widgets/services/esphome.md - widgets/services/esphome.md
- widgets/services/evcc.md - widgets/services/evcc.md
- widgets/services/feed.md
- widgets/services/filebrowser.md - widgets/services/filebrowser.md
- widgets/services/fileflows.md - widgets/services/fileflows.md
- widgets/services/firefly.md - widgets/services/firefly.md
@@ -149,7 +148,6 @@ nav:
- widgets/services/pyload.md - widgets/services/pyload.md
- widgets/services/qbittorrent.md - widgets/services/qbittorrent.md
- widgets/services/qnap.md - widgets/services/qnap.md
- widgets/services/qui.md
- widgets/services/radarr.md - widgets/services/radarr.md
- widgets/services/readarr.md - widgets/services/readarr.md
- widgets/services/romm.md - widgets/services/romm.md
@@ -196,7 +194,6 @@ nav:
- widgets/services/zabbix.md - widgets/services/zabbix.md
- "Information Widgets": - "Information Widgets":
- widgets/info/index.md - widgets/info/index.md
- widgets/info/customapi.md
- widgets/info/datetime.md - widgets/info/datetime.md
- widgets/info/glances.md - widgets/info/glances.md
- widgets/info/greeting.md - widgets/info/greeting.md
-6
View File
@@ -106,12 +106,6 @@ function relativeDate(date, formatter) {
const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(delta)); const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(delta));
const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
if (units[unitIndex] === "day") {
// compare calendar days, not elapsed 24h blocks
const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
return formatter.format(Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000), "day");
}
return formatter.format(Math.floor(delta / divisor), units[unitIndex]); return formatter.format(Math.floor(delta / divisor), units[unitIndex]);
} }
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "2.4.0", "version": "2.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -23,7 +23,7 @@
"gamedig": "^5.3.3", "gamedig": "^5.3.3",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
"js-yaml": "^5.4.2", "js-yaml": "^5.4.1",
"json-rpc-2.0": "^1.7.1", "json-rpc-2.0": "^1.7.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
@@ -39,7 +39,6 @@
"react-i18next": "^17.0.12", "react-i18next": "^17.0.12",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
"sax": "^1.6.1",
"swr": "^2.5.1", "swr": "^2.5.1",
"systeminformation": "^5.33.1", "systeminformation": "^5.33.1",
"tough-cookie": "^6.0.2", "tough-cookie": "^6.0.2",
+125 -128
View File
@@ -36,8 +36,8 @@ importers:
specifier: ^2.2.1 specifier: ^2.2.1
version: 2.2.1 version: 2.2.1
js-yaml: js-yaml:
specifier: ^5.4.2 specifier: ^5.4.1
version: 5.4.2 version: 5.4.1
json-rpc-2.0: json-rpc-2.0:
specifier: ^1.7.1 specifier: ^1.7.1
version: 1.7.1 version: 1.7.1
@@ -83,9 +83,6 @@ importers:
recharts: recharts:
specifier: ^3.1.2 specifier: ^3.1.2
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1) version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1)
sax:
specifier: ^1.6.1
version: 1.6.1
swr: swr:
specifier: ^2.5.1 specifier: ^2.5.1
version: 2.5.1(react@19.2.8) version: 2.5.1(react@19.2.8)
@@ -434,160 +431,160 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.4': '@img/sharp-darwin-arm64@0.35.3':
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-darwin-x64@0.35.4': '@img/sharp-darwin-x64@0.35.3':
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.4': '@img/sharp-freebsd-wasm32@0.35.3':
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
os: [freebsd] os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.3': '@img/sharp-libvips-darwin-arm64@1.3.2':
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.3': '@img/sharp-libvips-darwin-x64@1.3.2':
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.3': '@img/sharp-libvips-linux-arm64@1.3.2':
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.3': '@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.3': '@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.3': '@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.3': '@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.3': '@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.3': '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.3': '@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-linux-arm64@0.35.4': '@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-arm@0.35.4': '@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-ppc64@0.35.4': '@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-riscv64@0.35.4': '@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-s390x@0.35.4': '@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-x64@0.35.4': '@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.4': '@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.4': '@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-wasm32@0.35.4': '@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.4': '@img/sharp-webcontainers-wasm32@0.35.3':
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [wasm32] cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.4': '@img/sharp-win32-arm64@0.35.3':
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@img/sharp-win32-ia32@0.35.4': '@img/sharp-win32-ia32@0.35.3':
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
engines: {node: ^20.9.0} engines: {node: ^20.9.0}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@img/sharp-win32-x64@0.35.4': '@img/sharp-win32-x64@0.35.3':
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -2484,8 +2481,8 @@ packages:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true hasBin: true
js-yaml@5.4.2: js-yaml@5.4.1:
resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==}
hasBin: true hasBin: true
jsdom@30.0.1: jsdom@30.0.1:
@@ -3321,8 +3318,8 @@ packages:
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.35.4: sharp@0.35.3:
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
peerDependencies: peerDependencies:
'@types/node': '*' '@types/node': '*'
@@ -4209,108 +4206,108 @@ snapshots:
'@img/colour@1.1.0': '@img/colour@1.1.0':
optional: true optional: true
'@img/sharp-darwin-arm64@0.35.4': '@img/sharp-darwin-arm64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.3 '@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true optional: true
'@img/sharp-darwin-x64@0.35.4': '@img/sharp-darwin-x64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.3 '@img/sharp-libvips-darwin-x64': 1.3.2
optional: true optional: true
'@img/sharp-freebsd-wasm32@0.35.4': '@img/sharp-freebsd-wasm32@0.35.3':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.4 '@img/sharp-wasm32': 0.35.3
optional: true optional: true
'@img/sharp-libvips-darwin-arm64@1.3.3': '@img/sharp-libvips-darwin-arm64@1.3.2':
optional: true optional: true
'@img/sharp-libvips-darwin-x64@1.3.3': '@img/sharp-libvips-darwin-x64@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linux-arm64@1.3.3': '@img/sharp-libvips-linux-arm64@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linux-arm@1.3.3': '@img/sharp-libvips-linux-arm@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linux-ppc64@1.3.3': '@img/sharp-libvips-linux-ppc64@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linux-riscv64@1.3.3': '@img/sharp-libvips-linux-riscv64@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linux-s390x@1.3.3': '@img/sharp-libvips-linux-s390x@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linux-x64@1.3.3': '@img/sharp-libvips-linux-x64@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.3': '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.3': '@img/sharp-libvips-linuxmusl-x64@1.3.2':
optional: true optional: true
'@img/sharp-linux-arm64@0.35.4': '@img/sharp-linux-arm64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.3 '@img/sharp-libvips-linux-arm64': 1.3.2
optional: true optional: true
'@img/sharp-linux-arm@0.35.4': '@img/sharp-linux-arm@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.3 '@img/sharp-libvips-linux-arm': 1.3.2
optional: true optional: true
'@img/sharp-linux-ppc64@0.35.4': '@img/sharp-linux-ppc64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.3 '@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true optional: true
'@img/sharp-linux-riscv64@0.35.4': '@img/sharp-linux-riscv64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.3 '@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true optional: true
'@img/sharp-linux-s390x@0.35.4': '@img/sharp-linux-s390x@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.3 '@img/sharp-libvips-linux-s390x': 1.3.2
optional: true optional: true
'@img/sharp-linux-x64@0.35.4': '@img/sharp-linux-x64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.3 '@img/sharp-libvips-linux-x64': 1.3.2
optional: true optional: true
'@img/sharp-linuxmusl-arm64@0.35.4': '@img/sharp-linuxmusl-arm64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3 '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true optional: true
'@img/sharp-linuxmusl-x64@0.35.4': '@img/sharp-linuxmusl-x64@0.35.3':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.3 '@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true optional: true
'@img/sharp-wasm32@0.35.4': '@img/sharp-wasm32@0.35.3':
dependencies: dependencies:
'@emnapi/runtime': 1.11.3 '@emnapi/runtime': 1.11.3
optional: true optional: true
'@img/sharp-webcontainers-wasm32@0.35.4': '@img/sharp-webcontainers-wasm32@0.35.3':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.4 '@img/sharp-wasm32': 0.35.3
optional: true optional: true
'@img/sharp-win32-arm64@0.35.4': '@img/sharp-win32-arm64@0.35.3':
optional: true optional: true
'@img/sharp-win32-ia32@0.35.4': '@img/sharp-win32-ia32@0.35.3':
optional: true optional: true
'@img/sharp-win32-x64@0.35.4': '@img/sharp-win32-x64@0.35.3':
optional: true optional: true
'@internationalized/date@3.12.3': '@internationalized/date@3.12.3':
@@ -4362,7 +4359,7 @@ snapshots:
form-data: 4.0.6 form-data: 4.0.6
hpagent: 1.2.0 hpagent: 1.2.0
isomorphic-ws: 5.0.0(ws@8.21.2) isomorphic-ws: 5.0.0(ws@8.21.2)
js-yaml: 5.4.2 js-yaml: 5.4.1
jsonpath-plus: 10.4.0 jsonpath-plus: 10.4.0
openid-client: 6.8.5 openid-client: 6.8.5
rfc4648: 1.5.4 rfc4648: 1.5.4
@@ -6266,7 +6263,7 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
js-yaml@5.4.2: js-yaml@5.4.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@@ -6597,7 +6594,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.3 '@next/swc-linux-x64-musl': 16.3.3
'@next/swc-win32-arm64-msvc': 16.3.3 '@next/swc-win32-arm64-msvc': 16.3.3
'@next/swc-win32-x64-msvc': 16.3.3 '@next/swc-win32-x64-msvc': 16.3.3
sharp: 0.35.4(@types/node@26.2.0) sharp: 0.35.3(@types/node@26.2.0)
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- '@types/node' - '@types/node'
@@ -7082,37 +7079,37 @@ snapshots:
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sharp@0.35.4(@types/node@26.2.0): sharp@0.35.3(@types/node@26.2.0):
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.8.5 semver: 7.8.5
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.4 '@img/sharp-darwin-arm64': 0.35.3
'@img/sharp-darwin-x64': 0.35.4 '@img/sharp-darwin-x64': 0.35.3
'@img/sharp-freebsd-wasm32': 0.35.4 '@img/sharp-freebsd-wasm32': 0.35.3
'@img/sharp-libvips-darwin-arm64': 1.3.3 '@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.3 '@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.3 '@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.3 '@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.3 '@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.3 '@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.3 '@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.3 '@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3 '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.3 '@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-linux-arm': 0.35.4 '@img/sharp-linux-arm': 0.35.3
'@img/sharp-linux-arm64': 0.35.4 '@img/sharp-linux-arm64': 0.35.3
'@img/sharp-linux-ppc64': 0.35.4 '@img/sharp-linux-ppc64': 0.35.3
'@img/sharp-linux-riscv64': 0.35.4 '@img/sharp-linux-riscv64': 0.35.3
'@img/sharp-linux-s390x': 0.35.4 '@img/sharp-linux-s390x': 0.35.3
'@img/sharp-linux-x64': 0.35.4 '@img/sharp-linux-x64': 0.35.3
'@img/sharp-linuxmusl-arm64': 0.35.4 '@img/sharp-linuxmusl-arm64': 0.35.3
'@img/sharp-linuxmusl-x64': 0.35.4 '@img/sharp-linuxmusl-x64': 0.35.3
'@img/sharp-webcontainers-wasm32': 0.35.4 '@img/sharp-webcontainers-wasm32': 0.35.3
'@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.4 '@img/sharp-win32-x64': 0.35.3
'@types/node': 26.2.0 '@types/node': 26.2.0
optional: true optional: true
+45 -45
View File
@@ -21,7 +21,7 @@
"seconds": "s" "seconds": "s"
}, },
"auth": { "auth": {
"signout": "Abmelden" "signout": "Sign out"
}, },
"widget": { "widget": {
"missing_type": "Fehlender Widget-Typ: {{type}}", "missing_type": "Fehlender Widget-Typ: {{type}}",
@@ -40,8 +40,8 @@
}, },
"search": { "search": {
"placeholder": "Suche…", "placeholder": "Suche…",
"provider": "Suchmaschine: {{name}}", "provider": "Search engine: {{name}}",
"select_provider": "Suchmaschine auswählen" "select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -77,13 +77,13 @@
"no_data": "Keine Speicherdaten verfügbar" "no_data": "Keine Speicherdaten verfügbar"
}, },
"duplicati": { "duplicati": {
"jobs": "Aufgaben", "jobs": "Jobs",
"stored": "Gespeichert", "stored": "Stored",
"lastBackup": "Zuletzt ausgeführt", "lastBackup": "Last Run",
"nextRun": "Nächste Ausführung", "nextRun": "Next Run",
"running": "Wird ausgeführt", "running": "Running",
"warnings": "Warnungen", "warnings": "Warnings",
"errors": "Fehler" "errors": "Errors"
}, },
"docker": { "docker": {
"rx": "RX", "rx": "RX",
@@ -100,11 +100,11 @@
"not_found": "Nicht gefunden", "not_found": "Nicht gefunden",
"exited": "Beendet", "exited": "Beendet",
"partial": "Teilweise", "partial": "Teilweise",
"created": "Erstellt am", "created": "Created",
"paused": "Pausiert", "paused": "Paused",
"restarting": "Wird neu gestartet", "restarting": "Restarting",
"removing": "Wird entfernt", "removing": "Removing",
"dead": "Tot" "dead": "Dead"
}, },
"ping": { "ping": {
"error": "Fehler", "error": "Fehler",
@@ -140,7 +140,7 @@
"series": "Serien", "series": "Serien",
"episodes": "Episoden", "episodes": "Episoden",
"songs": "Titel", "songs": "Titel",
"albums": "Alben" "albums": "Albums"
}, },
"esphome": { "esphome": {
"offline": "Offline", "offline": "Offline",
@@ -364,15 +364,15 @@
"address": "Adresse", "address": "Adresse",
"expires": "Läuft ab", "expires": "Läuft ab",
"never": "Nie", "never": "Nie",
"user": "Benutzer", "user": "User",
"hostname": "Hostname", "hostname": "Hostname",
"name": "Name", "name": "Name",
"client_version": "Client-Version", "client_version": "Client Version",
"os": "Betriebssystem", "os": "OS",
"created": "Erstellt am", "created": "Created",
"authorized": "Autorisiert", "authorized": "Authorized",
"is_external": "Ist extern", "is_external": "Is External",
"update_available": "Aktualisierung verfügbar", "update_available": "Update Available",
"tags": "Tags", "tags": "Tags",
"last_seen": "Zuletzt gesehen", "last_seen": "Zuletzt gesehen",
"now": "Jetzt", "now": "Jetzt",
@@ -383,8 +383,8 @@
"minutes": "{{number}}min", "minutes": "{{number}}min",
"seconds": "{{number}}s", "seconds": "{{number}}s",
"ago": "Vor {{value}}", "ago": "Vor {{value}}",
"true": "Ja", "true": "Yes",
"false": "Nein" "false": "No"
}, },
"technitium": { "technitium": {
"totalQueries": "Anfragen", "totalQueries": "Anfragen",
@@ -479,7 +479,7 @@
"users": "Benutzer", "users": "Benutzer",
"loginsLast24H": "Anmeldungen (24 h)", "loginsLast24H": "Anmeldungen (24 h)",
"failedLoginsLast24H": "Fehlversuche (24 h)", "failedLoginsLast24H": "Fehlversuche (24 h)",
"authorizationsLast24H": "Autorisierungen (24h)" "authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "RAM", "mem": "RAM",
@@ -488,7 +488,7 @@
"vms": "VMs" "vms": "VMs"
}, },
"pulse": { "pulse": {
"nodes": "Knoten", "nodes": "Nodes",
"vms": "VMs", "vms": "VMs",
"lxcs": "LXCs" "lxcs": "LXCs"
}, },
@@ -657,13 +657,13 @@
"total": "Total" "total": "Total"
}, },
"pangolin": { "pangolin": {
"orgs": "Organisationen", "orgs": "Orgs",
"sites": "Seiten", "sites": "Seiten",
"resources": "Ressourcen", "resources": "Ressourcen",
"targets": "Ziele", "targets": "Ziele",
"traffic": "Traffic", "traffic": "Traffic",
"in": "Eingehend", "in": "In",
"out": "Ausgehend" "out": "Out"
}, },
"peanut": { "peanut": {
"battery_charge": "Akkuladung", "battery_charge": "Akkuladung",
@@ -959,27 +959,27 @@
"storage": "Speicher" "storage": "Speicher"
}, },
"maintainerr": { "maintainerr": {
"itemsHandled": "Items bearbeitet", "itemsHandled": "Items Handled",
"episodesHandled": "Episoden behandelt", "episodesHandled": "Episodes Handled",
"moviesHandled": "Filme behandelt", "moviesHandled": "Movies Handled",
"showsHandled": "Serien behandelt", "showsHandled": "Shows Handled",
"seasonsHandled": "Staffeln bearbeitet", "seasonsHandled": "Seasons Handled",
"reclaimable": "Zurückforderbar", "reclaimable": "Reclaimable",
"movieReclaimable": "Film zurückforderbar", "movieReclaimable": "Movie Reclaimable",
"showReclaimable": "Serie zurückforderbar", "showReclaimable": "Show Reclaimable",
"totalCapacity": "Gesamte Kapazität" "totalCapacity": "Total Capacity"
}, },
"netdata": { "netdata": {
"warnings": "Warnungen", "warnings": "Warnungen",
"criticals": "Kritisch" "criticals": "Kritisch"
}, },
"ntfy": { "ntfy": {
"title": "Titel", "title": "Title",
"priority": "Priorität", "priority": "Priority",
"lastReceived": "Zuletzt erhalten", "lastReceived": "Last Received",
"message": "Nachricht", "message": "Message",
"tags": "Schlagwörter", "tags": "Tags",
"none": "Keine", "none": "None",
"min": "Min", "min": "Min",
"low": "Low", "low": "Low",
"default": "Default", "default": "Default",
-13
View File
@@ -256,16 +256,6 @@
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memUsage": "MEM Usage", "memUsage": "MEM Usage",
@@ -1268,8 +1258,5 @@
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"leagues": "Leagues" "leagues": "Leagues"
},
"feed": {
"noItems": "No items"
} }
} }
+146 -146
View File
@@ -39,9 +39,9 @@
"wait": "お待ちください" "wait": "お待ちください"
}, },
"search": { "search": {
"placeholder": "検索 ...", "placeholder": "検索…",
"provider": "Search engine: {{name}}", "provider": "Search engine: {{name}}",
"select_provider": "検索エンジンを選択" "select_provider": "Select search engine"
}, },
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
@@ -132,18 +132,18 @@
"songs": "曲" "songs": "曲"
}, },
"jellyfin": { "jellyfin": {
"playing": "再生中", "playing": "Playing",
"transcoding": "変換中", "transcoding": "Transcoding",
"bitrate": "ビットレート", "bitrate": "Bitrate",
"no_active": "アクティブ・ストリーム無し", "no_active": "No Active Streams",
"movies": "映画", "movies": "Movies",
"series": "シリーズ", "series": "Series",
"episodes": "エピソード", "episodes": "Episodes",
"songs": "曲", "songs": "Songs",
"albums": "アルバム" "albums": "Albums"
}, },
"esphome": { "esphome": {
"offline": "オフライン ", "offline": "",
"offline_alt": "オフライン", "offline_alt": "オフライン",
"online": "オンライン", "online": "オンライン",
"total": "Total", "total": "Total",
@@ -241,18 +241,18 @@
}, },
"rutorrent": { "rutorrent": {
"active": "アクティブ", "active": "アクティブ",
"upload": "アップロード", "upload": "Upload",
"download": "ダウンロード" "download": "Download"
}, },
"transmission": { "transmission": {
"download": "ダウンロード", "download": "Download",
"upload": "アップロード", "upload": "Upload",
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
"qbittorrent": { "qbittorrent": {
"download": "ダウンロード", "download": "Download",
"upload": "アップロード", "upload": "Upload",
"leech": "Leech", "leech": "Leech",
"seed": "Seed" "seed": "Seed"
}, },
@@ -285,7 +285,7 @@
"queued": "待機中", "queued": "待機中",
"series": "Series", "series": "Series",
"queue": "Queue", "queue": "Queue",
"unknown": "不明" "unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
@@ -365,15 +365,15 @@
"expires": "失効", "expires": "失効",
"never": "なし", "never": "なし",
"user": "User", "user": "User",
"hostname": "ホスト名", "hostname": "Hostname",
"name": "名前", "name": "Name",
"client_version": "クライアントバージョン", "client_version": "Client Version",
"os": "OS", "os": "OS",
"created": "作成済み", "created": "Created",
"authorized": "承認済み", "authorized": "Authorized",
"is_external": "Is External", "is_external": "Is External",
"update_available": "アップデートが利用できます", "update_available": "Update Available",
"tags": "タグ", "tags": "Tags",
"last_seen": "最終日時", "last_seen": "最終日時",
"now": "現在", "now": "現在",
"years": "{{number}}年", "years": "{{number}}年",
@@ -383,11 +383,11 @@
"minutes": "{{number}}分", "minutes": "{{number}}分",
"seconds": "{{number}}秒", "seconds": "{{number}}秒",
"ago": "{{value}} 前", "ago": "{{value}} 前",
"true": "はい", "true": "Yes",
"false": "いいえ" "false": "No"
}, },
"technitium": { "technitium": {
"totalQueries": "クエリ", "totalQueries": "Queries",
"totalNoError": "成功", "totalNoError": "成功",
"totalServerFailure": "失敗", "totalServerFailure": "失敗",
"totalNxDomain": "NXドメイン", "totalNxDomain": "NXドメイン",
@@ -395,7 +395,7 @@
"totalAuthoritative": "正式", "totalAuthoritative": "正式",
"totalRecursive": "再帰的", "totalRecursive": "再帰的",
"totalCached": "キャッシュ済み", "totalCached": "キャッシュ済み",
"totalBlocked": "ブロック中", "totalBlocked": "Blocked",
"totalDropped": "ドロップ済み", "totalDropped": "ドロップ済み",
"totalClients": "クライアント" "totalClients": "クライアント"
}, },
@@ -411,13 +411,13 @@
"middleware": "ミドルウェア" "middleware": "ミドルウェア"
}, },
"trilium": { "trilium": {
"version": "バージョン", "version": "Version",
"notesCount": "メモ", "notesCount": "Notes",
"dbSize": "データベースサイズ", "dbSize": "Database Size",
"unknown": "不明" "unknown": "Unknown"
}, },
"navidrome": { "navidrome": {
"nothing_streaming": "アクティブストリーム無し", "nothing_streaming": "No Active Streams",
"please_wait": "お待ちください" "please_wait": "お待ちください"
}, },
"npm": { "npm": {
@@ -482,7 +482,7 @@
"authorizationsLast24H": "Auths (24h)" "authorizationsLast24H": "Auths (24h)"
}, },
"proxmox": { "proxmox": {
"mem": "メモリ", "mem": "MEM",
"cpu": "CPU", "cpu": "CPU",
"lxc": "LXC", "lxc": "LXC",
"vms": "VM" "vms": "VM"
@@ -494,17 +494,17 @@
}, },
"glances": { "glances": {
"cpu": "CPU", "cpu": "CPU",
"load": "負荷", "load": "Load",
"wait": "お待ちください", "wait": "Please wait",
"temp": "温度", "temp": "TEMP",
"_temp": "温度", "_temp": "温度",
"warn": "警告", "warn": "警告",
"uptime": "稼働時間", "uptime": "UP",
"total": "合計", "total": "Total",
"free": "空き容量", "free": "Free",
"used": "使用済み", "used": "Used",
"days": "日", "days": "d",
"hours": "時", "hours": "h",
"crit": "クリティカル", "crit": "クリティカル",
"read": "Read", "read": "Read",
"write": "書き込み", "write": "書き込み",
@@ -545,39 +545,39 @@
"57-day": "着氷性の小雨", "57-day": "着氷性の小雨",
"57-night": "Freezing Drizzle", "57-night": "Freezing Drizzle",
"61-day": "小雨", "61-day": "小雨",
"61-night": "小雨", "61-night": "Light Rain",
"63-day": "雨", "63-day": "雨",
"63-night": "雨", "63-night": "Rain",
"65-day": "大雨", "65-day": "大雨",
"65-night": "大雨", "65-night": "Heavy Rain",
"66-day": "着氷性の雨", "66-day": "着氷性の雨",
"66-night": "着氷性の雨", "66-night": "Freezing Rain",
"67-day": "着氷性の雨", "67-day": "Freezing Rain",
"67-night": "着氷性の雨", "67-night": "Freezing Rain",
"71-day": "小雪", "71-day": "小雪",
"71-night": "小雪", "71-night": "Light Snow",
"73-day": "雪", "73-day": "雪",
"73-night": "雪", "73-night": "Snow",
"75-day": "大雪", "75-day": "大雪",
"75-night": "Heavy Snow", "75-night": "Heavy Snow",
"77-day": "霧雪", "77-day": "霧雪",
"77-night": "Snow Grains", "77-night": "Snow Grains",
"80-day": "弱いにわか雨", "80-day": "弱いにわか雨",
"80-night": "弱いにわか雨", "80-night": "Light Showers",
"81-day": "にわか雨", "81-day": "にわか雨",
"81-night": "にわか雨", "81-night": "Showers",
"82-day": "強いにわか雨", "82-day": "強いにわか雨",
"82-night": "強いにわか雨", "82-night": "Heavy Showers",
"85-day": "にわか雪", "85-day": "にわか雪",
"85-night": "にわか雪", "85-night": "Snow Showers",
"86-day": "にわか雪", "86-day": "Snow Showers",
"86-night": "にわか雪", "86-night": "Snow Showers",
"95-day": "雷雨", "95-day": "雷雨",
"95-night": "雷雨", "95-night": "Thunderstorm",
"96-day": "雷雨・ひょう", "96-day": "雷雨・ひょう",
"96-night": "雹を伴う雷雨", "96-night": "Thunderstorm With Hail",
"99-day": "雹を伴う雷雨", "99-day": "Thunderstorm With Hail",
"99-night": "雹を伴う雷雨" "99-night": "Thunderstorm With Hail"
}, },
"homebridge": { "homebridge": {
"available_update": "システム", "available_update": "システム",
@@ -645,7 +645,7 @@
"signalQuality": "クオリティ", "signalQuality": "クオリティ",
"symbolQuality": "Quality", "symbolQuality": "Quality",
"networkRate": "Bitrate", "networkRate": "Bitrate",
"clientIP": "クライアントIP" "clientIP": "クライアント IP"
}, },
"scrutiny": { "scrutiny": {
"passed": "合格", "passed": "合格",
@@ -661,7 +661,7 @@
"sites": "Sites", "sites": "Sites",
"resources": "Resources", "resources": "Resources",
"targets": "Targets", "targets": "Targets",
"traffic": "トラフィック", "traffic": "Traffic",
"in": "In", "in": "In",
"out": "Out" "out": "Out"
}, },
@@ -674,7 +674,7 @@
"low_battery": "バッテリー残量低下" "low_battery": "バッテリー残量低下"
}, },
"nextdns": { "nextdns": {
"wait": "お待ちください", "wait": "Please Wait",
"no_devices": "デバイス データを受信していません" "no_devices": "デバイス データを受信していません"
}, },
"mikrotik": { "mikrotik": {
@@ -695,7 +695,7 @@
"limit": "リミット" "limit": "リミット"
}, },
"opnsense": { "opnsense": {
"cpu": "CPU負荷", "cpu": "CPU Load",
"memory": "アクティブ・メモリ", "memory": "アクティブ・メモリ",
"wanUpload": "WANアップロード", "wanUpload": "WANアップロード",
"wanDownload": "WANダウンロード" "wanDownload": "WANダウンロード"
@@ -707,14 +707,14 @@
"layers": "レイヤー" "layers": "レイヤー"
}, },
"octoprint": { "octoprint": {
"printer_state": "ステータス", "printer_state": "Status",
"temp_tool": "ツール温度", "temp_tool": "ツール温度",
"temp_bed": "ベッド温度", "temp_bed": "ベッド温度",
"job_completion": "完了" "job_completion": "完了"
}, },
"cloudflared": { "cloudflared": {
"origin_ip": "オリジンIP", "origin_ip": "オリジンIP",
"status": "ステータス" "status": "Status"
}, },
"pfsense": { "pfsense": {
"load": "読み込み平均", "load": "読み込み平均",
@@ -733,17 +733,17 @@
"memory_usage": "メモリ" "memory_usage": "メモリ"
}, },
"immich": { "immich": {
"users": "ユーザ", "users": "Users",
"photos": "写真", "photos": "写真",
"videos": "ビデオ", "videos": "Videos",
"storage": "ストレージ" "storage": "ストレージ"
}, },
"uptimekuma": { "uptimekuma": {
"up": "サイトUp", "up": "サイトUp",
"down": "サイトDown", "down": "サイトDown",
"uptime": "稼働時間", "uptime": "Uptime",
"incident": "事件", "incident": "事件",
"m": "分" "m": "m"
}, },
"atsumeru": { "atsumeru": {
"series": "Series", "series": "Series",
@@ -772,8 +772,8 @@
}, },
"photoprism": { "photoprism": {
"albums": "Albums", "albums": "Albums",
"photos": "写真", "photos": "Photos",
"videos": "動画", "videos": "Videos",
"people": "人" "people": "人"
}, },
"fileflows": { "fileflows": {
@@ -884,12 +884,12 @@
}, },
"gamedig": { "gamedig": {
"status": "Status", "status": "Status",
"online": "オンライン", "online": "Online",
"offline": "オフライン ", "offline": "Offline",
"name": "名前", "name": "名前",
"map": "マップ", "map": "マップ",
"currentPlayers": "現在のプレーヤー", "currentPlayers": "現在のプレーヤー",
"players": "プレイヤー", "players": "Players",
"maxPlayers": "最大プレイヤー数", "maxPlayers": "最大プレイヤー数",
"bots": "ボット", "bots": "ボット",
"ping": "Ping" "ping": "Ping"
@@ -902,21 +902,21 @@
}, },
"mealie": { "mealie": {
"recipes": "レシピ", "recipes": "レシピ",
"users": "ユーザ", "users": "Users",
"categories": "カテゴリ", "categories": "Categories",
"tags": "タグ" "tags": "タグ"
}, },
"openmediavault": { "openmediavault": {
"downloading": "ダウンロード中", "downloading": "ダウンロード中",
"total": "合計", "total": "Total",
"running": "実行中", "running": "Running",
"stopped": "停止中", "stopped": "Stopped",
"passed": "Passed", "passed": "Passed",
"failed": "Failed" "failed": "Failed"
}, },
"openwrt": { "openwrt": {
"uptime": "Uptime", "uptime": "Uptime",
"cpuLoad": "CPU 平均負荷(5分)", "cpuLoad": "CPU 平均負荷(5 分)",
"up": "Up", "up": "Up",
"down": "Down", "down": "Down",
"bytesTx": "送信済み", "bytesTx": "送信済み",
@@ -924,7 +924,7 @@
}, },
"uptimerobot": { "uptimerobot": {
"status": "Status", "status": "Status",
"uptime": "稼働時間", "uptime": "Uptime",
"lastDown": "最後のダウンタイム", "lastDown": "最後のダウンタイム",
"downDuration": "ダウンタイム時間", "downDuration": "ダウンタイム時間",
"sitesUp": "Sites Up", "sitesUp": "Sites Up",
@@ -954,9 +954,9 @@
}, },
"mailcow": { "mailcow": {
"domains": "Domains", "domains": "Domains",
"mailboxes": "受信箱", "mailboxes": "Mailboxes",
"mails": "メール", "mails": "Mails",
"storage": "ストレージ" "storage": "Storage"
}, },
"maintainerr": { "maintainerr": {
"itemsHandled": "Items Handled", "itemsHandled": "Items Handled",
@@ -974,7 +974,7 @@
"criticals": "重大" "criticals": "重大"
}, },
"ntfy": { "ntfy": {
"title": "タイトル", "title": "Title",
"priority": "Priority", "priority": "Priority",
"lastReceived": "Last Received", "lastReceived": "Last Received",
"message": "Message", "message": "Message",
@@ -989,7 +989,7 @@
"plantit": { "plantit": {
"events": "イベント", "events": "イベント",
"plants": "植物", "plants": "植物",
"photos": "写真", "photos": "Photos",
"species": "種" "species": "種"
}, },
"gitea": { "gitea": {
@@ -1137,27 +1137,27 @@
"karakeep": { "karakeep": {
"bookmarks": "Bookmarks", "bookmarks": "Bookmarks",
"favorites": "Favorites", "favorites": "Favorites",
"archived": "アーカイブ済み", "archived": "Archived",
"highlights": "ハイライト", "highlights": "Highlights",
"lists": "リスト", "lists": "Lists",
"tags": "タグ" "tags": "Tags"
}, },
"slskd": { "slskd": {
"slskStatus": "ネットワーク", "slskStatus": "Network",
"connected": "接続されています", "connected": "Connected",
"disconnected": "切断されています", "disconnected": "Disconnected",
"updateStatus": "更新", "updateStatus": "Update",
"update_yes": "利用可能", "update_yes": "Available",
"update_no": "最新", "update_no": "Up to Date",
"downloads": "ダウンロード", "downloads": "Downloads",
"uploads": "アップロード", "uploads": "Uploads",
"sharedFiles": "ファイル" "sharedFiles": "Files"
}, },
"jellystat": { "jellystat": {
"songs": "曲", "songs": "Songs",
"movies": "映画", "movies": "Movies",
"episodes": "エピソード", "episodes": "Episodes",
"other": "その他" "other": "Other"
}, },
"checkmk": { "checkmk": {
"serviceErrors": "Service issues", "serviceErrors": "Service issues",
@@ -1198,11 +1198,11 @@
"TOO_MANY_MISSING_DISKS": "Too Many Missing Disks", "TOO_MANY_MISSING_DISKS": "Too Many Missing Disks",
"NEW_DISK_TOO_SMALL": "New Disk Too Small", "NEW_DISK_TOO_SMALL": "New Disk Too Small",
"NO_DATA_DISKS": "No Data Disks", "NO_DATA_DISKS": "No Data Disks",
"notifications": "通知", "notifications": "Notifications",
"status": "ステータス", "status": "Status",
"cpu": "CPU", "cpu": "CPU",
"memoryUsed": "メモリ使用量", "memoryUsed": "Memory Used",
"memoryAvailable": "使用可能なメモリ", "memoryAvailable": "Memory Available",
"arrayUsed": "Array Used", "arrayUsed": "Array Used",
"arrayFree": "Array Free", "arrayFree": "Array Free",
"poolUsed": "{{pool}} Used", "poolUsed": "{{pool}} Used",
@@ -1210,53 +1210,53 @@
}, },
"backrest": { "backrest": {
"num_plans": "Plans", "num_plans": "Plans",
"num_success_30": "成功", "num_success_30": "Successes",
"num_failure_30": "失敗", "num_failure_30": "Failures",
"num_success_latest": "成功", "num_success_latest": "Succeeding",
"num_failure_latest": "失敗", "num_failure_latest": "Failing",
"bytes_added_30": "バイトが追加されました" "bytes_added_30": "Bytes Added"
}, },
"yourspotify": { "yourspotify": {
"songs": "曲", "songs": "Songs",
"time": "時間", "time": "Time",
"artists": "アーティスト" "artists": "Artists"
}, },
"arcane": { "arcane": {
"containers": "コンテナ", "containers": "Containers",
"images": "イメージ", "images": "Images",
"image_updates": "イメージの更新", "image_updates": "Image Updates",
"images_unused": "未使用", "images_unused": "Unused",
"environment_required": "Enviroment ID が必要です" "environment_required": "Environment ID Required"
}, },
"dockhand": { "dockhand": {
"running": "実行中", "running": "Running",
"stopped": "停止中", "stopped": "Stopped",
"cpu": "CPU", "cpu": "CPU",
"memory": "メモリ", "memory": "Memory",
"images": "イメージ", "images": "Images",
"volumes": "ボリューム", "volumes": "Volumes",
"events_today": "今日のイベント", "events_today": "Events Today",
"pending_updates": "保留中の更新", "pending_updates": "Pending Updates",
"stacks": "スタック数", "stacks": "Stacks",
"paused": "一時停止中", "paused": "Paused",
"total": "合計", "total": "Total",
"environment_not_found": "環境が見つかりません" "environment_not_found": "Environment Not Found"
}, },
"sparkyfitness": { "sparkyfitness": {
"eaten": "摂取カロリー", "eaten": "Eaten",
"burned": "消費カロリー", "burned": "Burned",
"remaining": "残り", "remaining": "Remaining",
"steps": "ステップ" "steps": "Steps"
}, },
"syncthing": { "syncthing": {
"connected": "接続されています", "connected": "Connected",
"synced": "同期しました", "synced": "Synced",
"errors": "エラー", "errors": "Errors",
"storage": "ストレージ" "storage": "Storage"
}, },
"sportarr": { "sportarr": {
"wanted": "募集中", "wanted": "Wanted",
"queued": "処理待ち", "queued": "Queued",
"leagues": "リーグ" "leagues": "Leagues"
} }
} }
@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({
getPrivateWidgetOptions: vi.fn(),
httpProxy: vi.fn(),
logger: { debug: vi.fn() },
}));
vi.mock("utils/config/widget-helpers", () => ({
getPrivateWidgetOptions,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/widgets/customapi";
const json = (body) => Buffer.from(JSON.stringify(body));
async function callRoute(options, index = "0") {
getPrivateWidgetOptions.mockResolvedValueOnce(options);
const res = createMockRes();
await handler({ query: { index } }, res);
return res;
}
describe("pages/api/widgets/customapi", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each([
["no widget at that index", undefined],
["a widget without a url", { method: "GET" }],
["an invalid url", { url: "api.local/data" }],
])("returns 400 without calling upstream for %s", async (_, options) => {
const res = await callRoute(options, "3");
expect(getPrivateWidgetOptions).toHaveBeenCalledWith("customapi", "3");
expect(res.statusCode).toBe(400);
expect(httpProxy).not.toHaveBeenCalled();
});
it("proxies an authenticated POST with custom headers and a JSON body", async () => {
httpProxy.mockResolvedValueOnce([200, "application/json", json({ json: { hello: "world" } })]);
const res = await callRoute({
url: "https://httpbin.org/post",
username: "admin",
password: "hunter2",
method: "POST",
headers: { "X-Secret-Token": "super-secret", "Content-Type": "application/json" },
requestBody: { hello: "world" },
});
expect(httpProxy).toHaveBeenCalledWith(new URL("https://httpbin.org/post"), {
method: "POST",
headers: {
"User-Agent": "homepage",
Accept: "application/json",
"X-Secret-Token": "super-secret",
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from("admin:hunter2").toString("base64")}`,
},
body: '{"hello":"world"}',
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ json: { hello: "world" } });
});
it("sends a string body as-is and lets config override default headers", async () => {
httpProxy.mockResolvedValueOnce([200, "application/json", json({ data: { viewer: { login: "shamoon" } } })]);
const query = '{"query":"{ viewer { login } }"}';
const res = await callRoute({
url: "https://api.github.com/graphql",
method: "POST",
headers: { Authorization: "Bearer ghp_token", "User-Agent": "my-dashboard" },
requestBody: query,
});
expect(httpProxy).toHaveBeenCalledWith(expect.any(URL), {
method: "POST",
headers: { "User-Agent": "my-dashboard", Accept: "application/json", Authorization: "Bearer ghp_token" },
body: query,
});
expect(res.body).toEqual({ data: { viewer: { login: "shamoon" } } });
});
it.each([
["an HTTP error", [403, "application/json", json({ message: "API rate limit exceeded for 1.2.3.4" })]],
[
"a network failure",
[500, "application/json", { error: { message: "connect ECONNREFUSED", rawError: { address: "10.0.0.5" } } }],
],
["a non-JSON response", [200, "text/html", Buffer.from("<html><body>Login</body></html>")]],
])("returns only a sanitized error for %s", async (_, upstream) => {
httpProxy.mockResolvedValueOnce(upstream);
const res = await callRoute({ url: "https://api.github.com/repos/gethomepage/homepage?token=abc" });
expect(httpProxy).toHaveBeenCalledWith(expect.any(URL), {
method: "GET",
headers: { "User-Agent": "homepage", Accept: "application/json" },
});
expect(res.statusCode).toBe(upstream[0] === 200 ? 500 : upstream[0]);
expect(res.body.error.url).toBe("api.github.com (see logs for details)");
expect(JSON.stringify(res.body)).not.toMatch(/rate limit|ECONNREFUSED|10\.0\.0\.5|<html>|token=abc/);
expect(res.setHeader).not.toHaveBeenCalled();
});
});
+1 -6
View File
@@ -7,10 +7,6 @@ import { SettingsContext } from "utils/contexts/settings";
export default function Item({ bookmark, iconOnly = false }) { export default function Item({ bookmark, iconOnly = false }) {
const description = bookmark.description ?? new URL(bookmark.href).hostname; const description = bookmark.description ?? new URL(bookmark.href).hostname;
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const cardStyle =
settings.cardStyle === "hairline"
? "shadow-xs inset-ring inset-ring-black/5 dark:inset-ring-white/5 hover:inset-ring-black/10 dark:hover:inset-ring-white/10"
: "shadow-md shadow-theme-900/10 dark:shadow-theme-900/20";
return ( return (
<li <li
@@ -26,8 +22,7 @@ export default function Item({ bookmark, iconOnly = false }) {
target={bookmark.target ?? settings.target ?? "_blank"} target={bookmark.target ?? settings.target ?? "_blank"}
className={classNames( className={classNames(
settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`, settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`,
cardStyle, "text-left cursor-pointer transition-all rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10",
"text-left cursor-pointer transition-all rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10",
iconOnly ? "h-[60px] w-[60px] grid" : "block w-full mb-3", iconOnly ? "h-[60px] w-[60px] grid" : "block w-full mb-3",
)} )}
> >
-10
View File
@@ -21,16 +21,6 @@ describe("components/bookmarks/item", () => {
expect(screen.getByRole("link").getAttribute("target")).toBe("_self"); expect(screen.getByRole("link").getAttribute("target")).toBe("_self");
}); });
it("applies hairline card styles when cardStyle is set", () => {
renderWithProviders(<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A" }} iconOnly={false} />, {
settings: { cardStyle: "hairline" },
});
const className = screen.getByRole("link").className;
expect(className).toContain("inset-ring");
expect(className).not.toContain("shadow-md");
});
it("renders icon-only layout with icon when provided", () => { it("renders icon-only layout with icon when provided", () => {
renderWithProviders( renderWithProviders(
<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A", icon: "mdi-home" }} iconOnly />, <Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A", icon: "mdi-home" }} iconOnly />,
+1 -6
View File
@@ -19,10 +19,6 @@ export default function Item({ service, groupName, useEqualHeights }) {
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const showStats = service.showStats === false ? false : settings.showStats; const showStats = service.showStats === false ? false : settings.showStats;
const statusStyle = service.statusStyle !== undefined ? service.statusStyle : settings.statusStyle; const statusStyle = service.statusStyle !== undefined ? service.statusStyle : settings.statusStyle;
const cardStyle =
settings.cardStyle === "hairline"
? "shadow-xs inset-ring inset-ring-black/5 dark:inset-ring-white/5 hover:inset-ring-black/10 dark:hover:inset-ring-white/10"
: "shadow-md shadow-theme-900/10 dark:shadow-theme-900/20";
const [statsOpen, setStatsOpen] = useState(service.showStats); const [statsOpen, setStatsOpen] = useState(service.showStats);
const [statsClosing, setStatsClosing] = useState(false); const [statsClosing, setStatsClosing] = useState(false);
@@ -43,8 +39,7 @@ export default function Item({ service, groupName, useEqualHeights }) {
className={classNames( className={classNames(
settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`, settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? "-" : ""}${settings.cardBlur}`,
useEqualHeights && "h-[calc(100%-0.5rem)]", useEqualHeights && "h-[calc(100%-0.5rem)]",
cardStyle, "transition-all mb-2 p-1 rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10 relative overflow-clip service-card",
"transition-all mb-2 p-1 rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10 relative overflow-clip service-card",
)} )}
> >
<div className="flex select-none z-0 service-title"> <div className="flex select-none z-0 service-title">
+2 -16
View File
@@ -4,14 +4,11 @@ import { useContext, useMemo } from "react";
import { BlockHighlightContext } from "./highlight-context"; import { BlockHighlightContext } from "./highlight-context";
import { SettingsContext } from "utils/contexts/settings";
import { evaluateHighlight, getHighlightClass } from "utils/highlights"; import { evaluateHighlight, getHighlightClass } from "utils/highlights";
export default function Block({ value, highlightValue, label, field }) { export default function Block({ value, highlightValue, label, field }) {
const { t } = useTranslation(); const { t } = useTranslation();
const highlightConfig = useContext(BlockHighlightContext); const highlightConfig = useContext(BlockHighlightContext);
const { settings } = useContext(SettingsContext);
const refined = settings?.blockStyle === "refined";
const highlight = useMemo(() => { const highlight = useMemo(() => {
if (!highlightConfig) return null; if (!highlightConfig) return null;
@@ -48,20 +45,9 @@ export default function Block({ value, highlightValue, label, field }) {
data-highlight-level={highlight?.level} data-highlight-level={highlight?.level}
data-highlight-source={highlight?.source} data-highlight-source={highlight?.source}
> >
<div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div>
<div <div
className={classNames( className={classNames("font-bold text-xs uppercase", applyToValueOnly && "text-theme-700 dark:text-theme-200")}
refined ? "text-[13px] font-medium tabular-nums" : "font-thin text-sm",
"service-block-value",
)}
>
{value === undefined || value === null ? "-" : value}
</div>
<div
className={classNames(
refined ? "text-[10px] font-semibold uppercase tracking-wider opacity-60" : "font-bold text-xs uppercase",
applyToValueOnly && "text-theme-700 dark:text-theme-200",
"service-block-label",
)}
> >
{t(label)} {t(label)}
</div> </div>
@@ -39,24 +39,6 @@ describe("components/services/widget/block", () => {
expect(el.className).toContain("danger-class"); expect(el.className).toContain("danger-class");
}); });
it("uses the standard block styles by default", () => {
const { container } = renderWithProviders(<Block label="some.label" value="1" />, { settings: {} });
expect(container.querySelector(".service-block-value").className).toContain("font-thin");
expect(container.querySelector(".service-block-label").className).toContain("font-bold");
});
it("applies refined block styles when blockStyle is set", () => {
const { container } = renderWithProviders(<Block label="some.label" value="1" />, {
settings: { blockStyle: "refined" },
});
const value = container.querySelector(".service-block-value");
expect(value.className).toContain("tabular-nums");
expect(value.className).not.toContain("font-thin");
expect(container.querySelector(".service-block-label").className).toContain("tracking-wider");
});
it("prefers highlightValue over the rendered value for numeric highlighting", () => { it("prefers highlightValue over the rendered value for numeric highlighting", () => {
const highlightConfig = { const highlightConfig = {
levels: { warn: "warn-class" }, levels: { warn: "warn-class" },
@@ -1,53 +0,0 @@
import classNames from "classnames";
import { useTranslation } from "next-i18next/pages";
import { TbApi } from "react-icons/tb";
import useSWR from "swr";
import Container from "../widget/container";
import Error from "../widget/error";
import Raw from "../widget/raw";
import WidgetLabel from "../widget/widget_label";
import ResolvedIcon from "components/resolvedicon";
import { formatValue, getValue } from "widgets/customapi/utils";
export default function CustomApi({ options }) {
const { t } = useTranslation();
const { index, icon, label, mappings = [], refreshInterval = 10000 } = options;
const { data, error } = useSWR(`/api/widgets/customapi?${new URLSearchParams({ index }).toString()}`, {
refreshInterval: Math.max(1000, refreshInterval),
});
if (error || (data?.error && !mappings.some((mapping) => mapping.field === "error"))) {
return <Error options={options} />;
}
return (
<Container options={options} additionalClassNames="information-widget-customapi">
<Raw>
<div className="flex flex-row items-center">
<div className="flex-none mr-2">
{icon ? (
<ResolvedIcon icon={icon} width={24} height={24} />
) : (
<TbApi className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className={classNames("flex flex-wrap items-center gap-0.5", !data && "animate-pulse")}>
{mappings.map((mapping) => (
<div key={mapping.label} className="flex flex-col items-center justify-center px-1 text-xs">
<span className="text-theme-800 dark:text-theme-200">{mapping.label}</span>
<span className="text-theme-800/70 dark:text-theme-200/50">
{data ? formatValue(t, mapping, getValue(mapping.field, data)) : "-"}
</span>
</div>
))}
</div>
</div>
{label && <WidgetLabel label={label} />}
</Raw>
</Container>
);
}
@@ -1,117 +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 { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("components/resolvedicon", () => ({
default: ({ icon }) => <div data-testid="resolved-icon" data-icon={icon} />,
}));
import CustomApi from "./customapi";
const settings = { settings: { target: "_self" } };
describe("components/widgets/customapi", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows GitHub repo stats once loaded, with the fallback icon and a label", () => {
const options = {
index: 6,
label: "Homepage",
refreshInterval: 300000,
mappings: [
{ field: "stargazers_count", label: "Stars", format: "number" },
{ field: "open_issues_count", label: "Open", format: "number" },
{ field: "forks_count", label: "Forks", format: "number" },
],
};
useSWR.mockReturnValue({ data: undefined, error: undefined });
const { container, rerender } = renderWithProviders(<CustomApi options={options} />, settings);
expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=6", { refreshInterval: 300000 });
expect(screen.getAllByText("-")).toHaveLength(3);
expect(screen.queryByTestId("resolved-icon")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
useSWR.mockReturnValue({
data: { stargazers_count: 32809, open_issues_count: 1, forks_count: 2127, owner: { login: "gethomepage" } },
error: undefined,
});
rerender(<CustomApi options={options} />);
expect(screen.queryByText("-")).toBeNull();
expect(screen.getByText("Stars").nextSibling).toHaveTextContent("32809");
expect(screen.getByText("Open").nextSibling).toHaveTextContent("1");
expect(screen.getByText("Forks").nextSibling).toHaveTextContent("2127");
expect(screen.getByText("Homepage")).toHaveClass("information-widget-label");
});
it("shows a nested weather value with a configured icon and suffix", () => {
useSWR.mockReturnValue({
data: { current: { time: "2026-09-22T20:15", temperature_2m: 7.8, wind_speed_10m: 12.4 } },
error: undefined,
});
renderWithProviders(
<CustomApi
options={{
index: 7,
icon: "mdi-thermometer",
mappings: [{ field: "current.temperature_2m", label: "Temp", format: "float", suffix: "°C" }],
}}
/>,
settings,
);
expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=7", { refreshInterval: 10000 });
expect(screen.getByTestId("resolved-icon")).toHaveAttribute("data-icon", "mdi-thermometer");
expect(screen.getByText("7.8 °C")).toBeInTheDocument();
});
it("shows a status API's error field as data when it is mapped", () => {
useSWR.mockReturnValue({ data: { error: "E_NONE", pending: 3 }, error: undefined });
renderWithProviders(
<CustomApi
options={{
index: 0,
mappings: [
{ field: "error", label: "Status", remap: [{ value: "E_NONE", to: "OK" }] },
{ field: "pending", label: "Queue", format: "number" },
],
}}
/>,
settings,
);
expect(screen.queryByText("widget.api_error")).toBeNull();
expect(screen.getByText("OK")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
it("shows the error state when the upstream API fails, clamping a too-fast refresh", () => {
useSWR.mockReturnValue({
data: { error: { message: "HTTP Error", url: "api.github.com (see logs for details)" } },
error: undefined,
});
renderWithProviders(
<CustomApi
options={{ index: 6, refreshInterval: 500, mappings: [{ field: "stargazers_count", label: "Stars" }] }}
/>,
settings,
);
expect(useSWR).toHaveBeenCalledWith("/api/widgets/customapi?index=6", { refreshInterval: 1000 });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
expect(screen.queryByText("Stars")).toBeNull();
});
});
-1
View File
@@ -16,7 +16,6 @@ const widgetMappings = {
longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")), longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")),
kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")), kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")),
stocks: dynamic(() => import("components/widgets/stocks/stocks")), stocks: dynamic(() => import("components/widgets/stocks/stocks")),
customapi: dynamic(() => import("components/widgets/customapi/customapi")),
}; };
export default function Widget({ widget, style }) { export default function Widget({ widget, style }) {
+2 -2
View File
@@ -25,12 +25,12 @@ export default function Resource({
}`} }`}
> >
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between gap-2"> <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between gap-2">
<div className="pl-0.5 resource-value">{value}</div> <div className="pl-0.5">{value}</div>
<div className="pr-1">{label}</div> <div className="pr-1">{label}</div>
</div> </div>
{expanded && ( {expanded && (
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between gap-2"> <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between gap-2">
<div className="pl-0.5 resource-value">{expandedValue}</div> <div className="pl-0.5">{expandedValue}</div>
<div className="pr-1">{expandedLabel}</div> <div className="pr-1">{expandedLabel}</div>
</div> </div>
)} )}
-1
View File
@@ -3,7 +3,6 @@ import { appWithTranslation } from "next-i18next/pages";
import Head from "next/head"; import Head from "next/head";
import "styles/globals.css"; import "styles/globals.css";
import "styles/manrope.css"; import "styles/manrope.css";
import "styles/plex-mono.css";
import "styles/theme.css"; import "styles/theme.css";
import { SWRConfig } from "swr"; import { SWRConfig } from "swr";
-46
View File
@@ -1,46 +0,0 @@
import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
import createLogger from "utils/logger";
import { sanitizeErrorURL } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
const logger = createLogger("customapi");
export default async function handler(req, res) {
const { index } = req.query;
const options = await getPrivateWidgetOptions("customapi", index);
if (!options?.url) {
return res.status(400).json({ error: "Missing Custom API URL" });
}
let url;
try {
url = new URL(options.url);
} catch {
return res.status(400).json({ error: "Invalid Custom API URL" });
}
const headers = { "User-Agent": "homepage", Accept: "application/json", ...(options.headers ?? {}) };
if (options.username && options.password) {
headers.Authorization = `Basic ${Buffer.from(`${options.username}:${options.password}`).toString("base64")}`;
}
const params = { method: options.method ?? "GET", headers };
if (options.requestBody) {
params.body = typeof options.requestBody === "object" ? JSON.stringify(options.requestBody) : options.requestBody;
}
const [status, , data] = await httpProxy(url, params);
if (status >= 400) {
logger.debug("HTTP Error %d calling %s//%s%s", status, url.protocol, url.host, url.pathname);
return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url) } });
}
try {
return res.status(status).json(JSON.parse(Buffer.from(data).toString()));
} catch {
return res.status(500).json({ error: { message: "Invalid JSON", url: sanitizeErrorURL(url) } });
}
}
+2 -4
View File
@@ -552,9 +552,7 @@ export default function Wrapper({ initialSettings, fallback }) {
const html = document.documentElement; const html = document.documentElement;
const body = document.body; const body = document.body;
html.classList.remove("dark", "scheme-dark", "scheme-light", "font-plex", "font-plex-data"); html.classList.remove("dark", "scheme-dark", "scheme-light");
if (initialSettings.font === "plex") html.classList.add("font-plex");
if (initialSettings.font === "plexData") html.classList.add("font-plex-data");
html.classList.toggle("dark", theme === "dark"); html.classList.toggle("dark", theme === "dark");
html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light"); html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light");
@@ -573,7 +571,7 @@ export default function Wrapper({ initialSettings, fallback }) {
body.style.backgroundImage = ""; body.style.backgroundImage = "";
body.style.backgroundColor = ""; body.style.backgroundColor = "";
body.style.backgroundAttachment = ""; body.style.backgroundAttachment = "";
}, [backgroundImage, opacity, theme, color, initialSettings.color, initialSettings.font]); }, [backgroundImage, opacity, theme, color, initialSettings.color]);
return ( return (
<> <>
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-56
View File
@@ -1,56 +0,0 @@
@font-face {
font-family: "IBM Plex Mono";
font-weight: 400;
font-style: normal;
src:
local("IBM Plex Mono"),
url("./font/IBMPlexMono-Regular.woff2") format("woff2");
font-display: swap;
}
@font-face {
font-family: "IBM Plex Mono";
font-weight: 500;
font-style: normal;
src:
local("IBM Plex Mono Medium"),
url("./font/IBMPlexMono-Medium.woff2") format("woff2");
font-display: swap;
}
@font-face {
font-family: "IBM Plex Mono";
font-weight: 600;
font-style: normal;
src:
local("IBM Plex Mono SemiBold"),
url("./font/IBMPlexMono-SemiBold.woff2") format("woff2");
font-display: swap;
}
@font-face {
font-family: "IBM Plex Mono";
font-weight: 700;
font-style: normal;
src:
local("IBM Plex Mono Bold"),
url("./font/IBMPlexMono-Bold.woff2") format("woff2");
font-display: swap;
}
/* font-size-adjust keeps mono from reading larger than Manrope at the same size */
.font-plex,
.font-plex body {
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size-adjust: 0.5;
letter-spacing: -0.01em;
}
.font-plex-data .service-block-value,
.font-plex-data .service-tags,
.font-plex-data .tabular-nums,
.font-plex-data .information-widget-resource .resource-value,
.font-plex-data .service-container {
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size-adjust: 0.5;
}
+1 -3
View File
@@ -9,7 +9,5 @@ export function renderWithProviders(ui, { settings = {} } = {}) {
setSettings: () => {}, setSettings: () => {},
}; };
const wrapper = ({ children }) => <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>; return render(<SettingsContext.Provider value={value}>{ui}</SettingsContext.Provider>);
return render(ui, { wrapper });
} }
-12
View File
@@ -353,9 +353,6 @@ export function cleanServiceGroups(groups) {
expandOneStreamToTwoRows, expandOneStreamToTwoRows,
showEpisodeNumber, showEpisodeNumber,
// feed
layout,
// frigate // frigate
enableRecentEvents, enableRecentEvents,
@@ -433,9 +430,6 @@ export function cleanServiceGroups(groups) {
// proxmoxbackupserver // proxmoxbackupserver
datastore, datastore,
// qui
instance,
// speedtest // speedtest
bitratePrecision, bitratePrecision,
@@ -546,9 +540,6 @@ export function cleanServiceGroups(groups) {
if (type === "portainer") { if (type === "portainer") {
if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes); if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes);
} }
if (type === "qui") {
if (instance !== undefined) widget.instance = instance;
}
if (type === "proxmox") { if (type === "proxmox") {
if (node) widget.node = node; if (node) widget.node = node;
} }
@@ -684,9 +675,6 @@ export function cleanServiceGroups(groups) {
if (showTime) widget.showTime = showTime; if (showTime) widget.showTime = showTime;
if (timezone) widget.timezone = timezone; if (timezone) widget.timezone = timezone;
} }
if (type === "feed") {
if (layout) widget.layout = layout;
}
if (type === "dockhand") { if (type === "dockhand") {
if (environment) widget.environment = environment; if (environment) widget.environment = environment;
} }
-34
View File
@@ -403,7 +403,6 @@ describe("utils/config/service-helpers", () => {
{ type: "radarr", enableQueue: "true" }, { type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" }, { type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" }, { type: "qnap", volume: "vol1" },
{ type: "qui", instance: 2 },
{ type: "dispatcharr", enableActiveStreams: "true" }, { type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" }, { type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" }, { type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
@@ -469,7 +468,6 @@ describe("utils/config/service-helpers", () => {
expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }), expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
); );
expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" })); expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
expect(widgets.find((w) => w.type === "qui")).toEqual(expect.objectContaining({ instance: 2 }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual( expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }), expect.objectContaining({ bitratePrecision: 3, version: 1 }),
); );
@@ -526,38 +524,6 @@ describe("utils/config/service-helpers", () => {
]); ]);
}); });
it("cleanServiceGroups keeps feed layout and drops server-side options", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Core",
services: [
{
name: "News",
widgets: [
{
type: "feed",
url: "https://example.com/feed.xml?token=secret",
maxItems: 3,
images: false,
layout: "grid",
},
],
},
],
groups: [],
},
];
const feedWidget = cleanServiceGroups(rawGroups)[0].services[0].widgets[0];
expect(feedWidget).toEqual(expect.objectContaining({ type: "feed", layout: "grid" }));
expect(feedWidget).not.toHaveProperty("url");
expect(feedWidget).not.toHaveProperty("maxItems");
expect(feedWidget).not.toHaveProperty("images");
});
it("findGroupByName deep-searches and annotates parent", async () => { it("findGroupByName deep-searches and annotates parent", async () => {
const mod = await import("./service-helpers"); const mod = await import("./service-helpers");
const { findGroupByName } = mod; const { findGroupByName } = mod;
+2 -5
View File
@@ -31,7 +31,7 @@ export async function cleanWidgetGroups(widgets) {
const optionKeys = Object.keys(sanitizedOptions); const optionKeys = Object.keys(sanitizedOptions);
// delete private options from the sanitized options // delete private options from the sanitized options
["username", "password", "key", "apiKey", "headers", "requestBody", "method"].forEach((pO) => { ["username", "password", "key", "apiKey"].forEach((pO) => {
if (optionKeys.includes(pO)) { if (optionKeys.includes(pO)) {
delete sanitizedOptions[pO]; delete sanitizedOptions[pO];
} }
@@ -57,7 +57,7 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
const privateOptions = const privateOptions =
widgets.map((widget) => { widgets.map((widget) => {
const { index, url, username, password, key, apiKey, headers, requestBody, method } = widget.options; const { index, url, username, password, key, apiKey } = widget.options;
return { return {
type: widget.type, type: widget.type,
@@ -68,9 +68,6 @@ export async function getPrivateWidgetOptions(type, widgetIndex) {
password, password,
key, key,
apiKey, apiKey,
headers,
requestBody,
method,
}, },
}; };
}) || {}; }) || {};
-11
View File
@@ -60,17 +60,6 @@ describe("utils/config/widget-helpers", () => {
expect(cleaned[2].options.apiKey).toBeUndefined(); expect(cleaned[2].options.apiKey).toBeUndefined();
}); });
it("cleanWidgetGroups removes customapi request options", async () => {
const cleaned = await cleanWidgetGroups([
{
type: "customapi",
options: { index: 0, url: "http://x", headers: { a: "b" }, requestBody: "body", method: "POST", icon: "mdi-x" },
},
]);
expect(cleaned[0].options).toEqual({ index: 0, icon: "mdi-x" });
});
it("getPrivateWidgetOptions returns private options for a specific widget", async () => { it("getPrivateWidgetOptions returns private options for a specific widget", async () => {
fs.readFile.mockResolvedValueOnce("ignored"); fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]); yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]);
+8 -20
View File
@@ -118,19 +118,9 @@ function readConfig(file) {
return existsSync(path) ? readFileSync(path, "utf8") : ""; return existsSync(path) ? readFileSync(path, "utf8") : "";
} }
const PLACEHOLDER_PATTERN = /(["']?)\{\{([^{}]*)\}\}\1/g;
const HOMEPAGE_KEY_PATTERN = /HOMEPAGE_(?:VAR|FILE)_/;
const PLACEHOLDER_TOKEN_PATTERN = /__HOMEPAGE_MCP_PLACEHOLDER_(\d+)__/g;
// mask {{HOMEPAGE_*}} placeholders so they survive a parse/dump round-trip
function parseYamlConfig(file) { function parseYamlConfig(file) {
const placeholders = []; const parsed = loadYaml(readConfig(file) || "");
const masked = readConfig(file).replace(PLACEHOLDER_PATTERN, (match, quote, key) => { return parsed ?? [];
if (!HOMEPAGE_KEY_PATTERN.test(key)) return match;
placeholders.push(match);
return `__HOMEPAGE_MCP_PLACEHOLDER_${placeholders.length - 1}__`;
});
return { data: loadYaml(masked) ?? [], placeholders };
} }
function validateYaml(file, content) { function validateYaml(file, content) {
@@ -176,10 +166,8 @@ function ensureWriteEnabled() {
return null; return null;
} }
function dumpYamlConfig(file, content, placeholders) { function dumpYamlConfig(file, content) {
const dumped = yaml const dumped = yaml.dump(content, { lineWidth: -1, noRefs: true });
.dump(content, { lineWidth: -1, noRefs: true })
.replace(PLACEHOLDER_TOKEN_PATTERN, (match, index) => placeholders[index] ?? match);
mkdirSync(CONF_DIR, { recursive: true }); mkdirSync(CONF_DIR, { recursive: true });
writeFileSync(configPath(file), dumped, "utf8"); writeFileSync(configPath(file), dumped, "utf8");
return dumped; return dumped;
@@ -204,7 +192,7 @@ function addService(args) {
}; };
} }
const { data: services, placeholders } = parseYamlConfig("services.yaml"); const services = parseYamlConfig("services.yaml");
if (!Array.isArray(services)) { if (!Array.isArray(services)) {
throw new Error("services.yaml must contain a top-level array"); throw new Error("services.yaml must contain a top-level array");
} }
@@ -232,7 +220,7 @@ function addService(args) {
} }
group[groupName].push({ [serviceName]: serviceConfig }); group[groupName].push({ [serviceName]: serviceConfig });
const content = dumpYamlConfig("services.yaml", services, placeholders); const content = dumpYamlConfig("services.yaml", services);
return textContent( return textContent(
JSON.stringify({ written: "services.yaml", added: { group: groupName, service: serviceName }, content }, null, 2), JSON.stringify({ written: "services.yaml", added: { group: groupName, service: serviceName }, content }, null, 2),
); );
@@ -254,7 +242,7 @@ function addInfoWidget(args) {
}; };
} }
const { data: widgets, placeholders } = parseYamlConfig("widgets.yaml"); const widgets = parseYamlConfig("widgets.yaml");
if (!Array.isArray(widgets)) { if (!Array.isArray(widgets)) {
throw new Error("widgets.yaml must contain a top-level array"); throw new Error("widgets.yaml must contain a top-level array");
} }
@@ -264,7 +252,7 @@ function addInfoWidget(args) {
assertPlainObject(options, "options"); assertPlainObject(options, "options");
widgets.push({ [type]: options }); widgets.push({ [type]: options });
const content = dumpYamlConfig("widgets.yaml", widgets, placeholders); const content = dumpYamlConfig("widgets.yaml", widgets);
return textContent(JSON.stringify({ written: "widgets.yaml", added: { type }, content }, null, 2)); return textContent(JSON.stringify({ written: "widgets.yaml", added: { type }, content }, null, 2));
} }
-36
View File
@@ -184,42 +184,6 @@ describe("utils/mcp/homepage-mcp", () => {
); );
}); });
it("preserves env placeholders when adding a service", async () => {
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
writeFileSync(
path.join(configDir, "services.yaml"),
"- Media:\n" +
" - Jellyfin:\n" +
" href: http://{{HOMEPAGE_VAR_HOST}}:8096\n" +
" widget:\n" +
" type: jellyfin\n" +
" key: {{HOMEPAGE_VAR_JELLYFIN_KEY}}\n" +
' password: "{{HOMEPAGE_FILE_PASSWORD}}"\n',
);
const mod = await loadMcpWithConfigDir(configDir);
const response = mod.handleMcpRequest({
jsonrpc: "2.0",
id: 11,
method: "tools/call",
params: { name: "add_service", arguments: { group: "Tools", name: "Grafana" } },
});
expect(response.result.isError).toBeUndefined();
expect(readFileSync(path.join(configDir, "services.yaml"), "utf8")).toBe(
"- Media:\n" +
" - Jellyfin:\n" +
" href: http://{{HOMEPAGE_VAR_HOST}}:8096\n" +
" widget:\n" +
" type: jellyfin\n" +
" key: {{HOMEPAGE_VAR_JELLYFIN_KEY}}\n" +
' password: "{{HOMEPAGE_FILE_PASSWORD}}"\n' +
"- Tools:\n" +
" - Grafana: {}\n",
);
});
it("does not add a duplicate service in the same group", async () => { it("does not add a duplicate service in the same group", async () => {
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true"; process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")); const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
-46
View File
@@ -1,46 +0,0 @@
import { createRequire } from "node:module";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const i18nextConfig = require("../../next-i18next.config.js");
function getRelativeDateFormatter() {
let relativeDateFormatter;
i18nextConfig.use[0].init({
services: {
formatter: {
add(name, formatter) {
if (name === "relativeDate") relativeDateFormatter = formatter;
},
},
},
});
return relativeDateFormatter;
}
describe("relativeDate formatter", () => {
const formatRelativeDate = getRelativeDateFormatter();
const options = { numeric: "auto" };
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 8, 23, 16, 0));
});
afterEach(() => {
vi.useRealTimers();
});
it.each([
["hours away on the same day", new Date(2026, 8, 23, 20, 0), "in 4 hours"],
["next calendar day", new Date(2026, 8, 24, 20, 0), "tomorrow"],
["two calendar days away but under 48h", new Date(2026, 8, 25, 10, 40), "in 2 days"],
["previous calendar day", new Date(2026, 8, 22, 10, 0), "yesterday"],
["two calendar days ago but under 48h", new Date(2026, 8, 21, 20, 0), "2 days ago"],
])("formats %s", (_description, value, expected) => {
expect(formatRelativeDate(value.toISOString(), "en", options)).toBe(expected);
});
});
-2
View File
@@ -37,7 +37,6 @@ const components = {
emby: dynamic(() => import("./emby/component")), emby: dynamic(() => import("./emby/component")),
esphome: dynamic(() => import("./esphome/component")), esphome: dynamic(() => import("./esphome/component")),
evcc: dynamic(() => import("./evcc/component")), evcc: dynamic(() => import("./evcc/component")),
feed: dynamic(() => import("./feed/component")),
filebrowser: dynamic(() => import("./filebrowser/component")), filebrowser: dynamic(() => import("./filebrowser/component")),
fileflows: dynamic(() => import("./fileflows/component")), fileflows: dynamic(() => import("./fileflows/component")),
firefly: dynamic(() => import("./firefly/component")), firefly: dynamic(() => import("./firefly/component")),
@@ -123,7 +122,6 @@ const components = {
pyload: dynamic(() => import("./pyload/component")), pyload: dynamic(() => import("./pyload/component")),
qbittorrent: dynamic(() => import("./qbittorrent/component")), qbittorrent: dynamic(() => import("./qbittorrent/component")),
qnap: dynamic(() => import("./qnap/component")), qnap: dynamic(() => import("./qnap/component")),
qui: dynamic(() => import("./qui/component")),
radarr: dynamic(() => import("./radarr/component")), radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")), readarr: dynamic(() => import("./readarr/component")),
romm: dynamic(() => import("./romm/component")), romm: dynamic(() => import("./romm/component")),
+150 -2
View File
@@ -1,13 +1,161 @@
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "next-i18next/pages"; import { useTranslation } from "next-i18next/pages";
import { formatValue, getColor, getValue } from "./utils";
import Block from "components/services/widget/block"; import Block from "components/services/widget/block";
import Container from "components/services/widget/container"; import Container from "components/services/widget/container";
import * as shvl from "utils/config/shvl"; import * as shvl from "utils/config/shvl";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
function getValue(field, data) {
let value = data;
let lastField = field;
let key = "";
// Support APIs that return arrays or scalars directly.
if (typeof field === "undefined") {
return value;
}
// shvl is easier, everything else is kept for backwards compatibility.
if (typeof field === "string") {
return shvl.get(data, field, null) ?? data[field] ?? null;
}
while (typeof lastField === "object") {
key = Object.keys(lastField)[0] ?? null;
if (key === null) {
break;
}
value = value[key];
lastField = lastField[key];
}
if (typeof value === "undefined") {
return null;
}
return value[lastField] ?? null;
}
function getSize(data) {
if (Array.isArray(data) || typeof data === "string") {
return data.length;
} else if (typeof data === "object" && data !== null) {
return Object.keys(data).length;
}
return NaN;
}
function formatValue(t, mapping, rawValue) {
let value = rawValue;
// Remap the value.
const remaps = mapping?.remap ?? [];
for (let i = 0; i < remaps.length; i += 1) {
const remap = remaps[i];
if (remap?.any || remap?.value === value) {
value = remap.to;
break;
}
}
// Scale the value. Accepts either a number to multiply by or a string
// like "12/345".
const scale = mapping?.scale;
if (typeof scale === "number") {
value *= scale;
} else if (typeof scale === "string") {
const parts = scale.split("/");
const numerator = parts[0] ? parseFloat(parts[0]) : 1;
const denominator = parts[1] ? parseFloat(parts[1]) : 1;
value = (value * numerator) / denominator;
}
// Format the value using a known type.
switch (mapping?.format) {
case "number":
value = t("common.number", { value: parseInt(value, 10) });
break;
case "float":
value = t("common.number", { value });
break;
case "percent":
value = t("common.percent", { value });
break;
case "duration":
value = t("common.duration", { value });
break;
case "bytes":
value = t("common.bytes", { value });
break;
case "bitrate":
value = t("common.bitrate", { value });
break;
case "date":
value = t("common.date", {
value,
lng: mapping?.locale,
dateStyle: mapping?.dateStyle ?? "long",
timeStyle: mapping?.timeStyle,
});
break;
case "relativeDate":
value = t("common.relativeDate", {
value,
lng: mapping?.locale,
style: mapping?.style,
numeric: mapping?.numeric,
});
break;
case "size":
value = t("common.number", { value: getSize(value) });
break;
case "text":
default:
// nothing
}
// Apply fixed prefix.
const prefix = mapping?.prefix;
if (prefix) {
value = `${prefix} ${value}`;
}
// Apply fixed suffix.
const suffix = mapping?.suffix;
if (suffix) {
value = `${value} ${suffix}`;
}
return value;
}
function getColor(mapping, customData) {
const value = getValue(mapping.additionalField.field, customData);
const { color } = mapping.additionalField;
switch (color) {
case "adaptive":
try {
const number = parseFloat(value);
return number > 0 ? "text-emerald-300" : "text-rose-300";
} catch (e) {
return "";
}
case "black":
return `text-black`;
case "white":
return `text-white`;
case "theme":
return `text-theme-500`;
default:
return "";
}
}
export default function Component({ service }) { export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
-151
View File
@@ -1,151 +0,0 @@
import * as shvl from "utils/config/shvl";
export function getValue(field, data) {
let value = data;
let lastField = field;
let key = "";
// Support APIs that return arrays or scalars directly.
if (typeof field === "undefined") {
return value;
}
// shvl is easier, everything else is kept for backwards compatibility.
if (typeof field === "string") {
return shvl.get(data, field, null) ?? data[field] ?? null;
}
while (typeof lastField === "object") {
key = Object.keys(lastField)[0] ?? null;
if (key === null) {
break;
}
value = value[key];
lastField = lastField[key];
}
if (typeof value === "undefined") {
return null;
}
return value[lastField] ?? null;
}
export function getSize(data) {
if (Array.isArray(data) || typeof data === "string") {
return data.length;
} else if (typeof data === "object" && data !== null) {
return Object.keys(data).length;
}
return NaN;
}
export function formatValue(t, mapping, rawValue) {
let value = rawValue;
// Remap the value.
const remaps = mapping?.remap ?? [];
for (let i = 0; i < remaps.length; i += 1) {
const remap = remaps[i];
if (remap?.any || remap?.value === value) {
value = remap.to;
break;
}
}
// Scale the value. Accepts either a number to multiply by or a string
// like "12/345".
const scale = mapping?.scale;
if (typeof scale === "number") {
value *= scale;
} else if (typeof scale === "string") {
const parts = scale.split("/");
const numerator = parts[0] ? parseFloat(parts[0]) : 1;
const denominator = parts[1] ? parseFloat(parts[1]) : 1;
value = (value * numerator) / denominator;
}
// Format the value using a known type.
switch (mapping?.format) {
case "number":
value = t("common.number", { value: parseInt(value, 10) });
break;
case "float":
value = t("common.number", { value });
break;
case "percent":
value = t("common.percent", { value });
break;
case "duration":
value = t("common.duration", { value });
break;
case "bytes":
value = t("common.bytes", { value });
break;
case "bitrate":
value = t("common.bitrate", { value });
break;
case "date":
value = t("common.date", {
value,
lng: mapping?.locale,
dateStyle: mapping?.dateStyle ?? "long",
timeStyle: mapping?.timeStyle,
});
break;
case "relativeDate":
value = t("common.relativeDate", {
value,
lng: mapping?.locale,
style: mapping?.style,
numeric: mapping?.numeric,
});
break;
case "size":
value = t("common.number", { value: getSize(value) });
break;
case "text":
default:
// nothing
}
// Apply fixed prefix.
const prefix = mapping?.prefix;
if (prefix) {
value = `${prefix} ${value}`;
}
// Apply fixed suffix.
const suffix = mapping?.suffix;
if (suffix) {
value = `${value} ${suffix}`;
}
return value;
}
export function getColor(mapping, customData) {
const value = getValue(mapping.additionalField.field, customData);
const { color } = mapping.additionalField;
switch (color) {
case "adaptive":
try {
const number = parseFloat(value);
return number > 0 ? "text-emerald-300" : "text-rose-300";
} catch (e) {
return "";
}
case "black":
return `text-black`;
case "white":
return `text-white`;
case "theme":
return `text-theme-500`;
default:
return "";
}
}
-82
View File
@@ -1,82 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatValue, getColor, getSize, getValue } from "./utils";
const t = (key, { value }) => `${key.replace("common.", "")}:${value}`;
const data = {
status: "online",
stats: { cpu: 12.5, change: -1.2, users: ["a", "b"] },
"dotted.key": 7,
};
describe("widgets/customapi/utils", () => {
it.each([
["the root when no field is set", undefined, data],
["a dotted path", "stats.cpu", 12.5],
["a literal key containing a dot", "dotted.key", 7],
["null for a missing path", "stats.missing", null],
["a legacy object path", { stats: "cpu" }, 12.5],
["null for a legacy path through a missing key", { missing: "cpu" }, null],
["null for an empty legacy path", {}, null],
])("getValue returns %s", (_, field, expected) => {
expect(getValue(field, data)).toEqual(expected);
});
it.each([
[["a", "b", "c"], 3],
["abcd", 4],
[{ a: 1, b: 2 }, 2],
[null, NaN],
[5, NaN],
])("getSize(%j) is %s", (input, expected) => {
expect(getSize(input)).toEqual(expected);
});
it.each([
[{}, "raw", "raw"],
[{ format: "number" }, "42.9", "number:42"],
[{ format: "float" }, 4.2, "number:4.2"],
[{ format: "percent", scale: 100 }, 0.25, "percent:25"],
[{ format: "duration" }, 90, "duration:90"],
[{ format: "bytes", scale: "1024/1" }, 2, "bytes:2048"],
[{ format: "bitrate", scale: "/8" }, 800, "bitrate:100"],
[{ format: "date" }, "2026-09-22", "date:2026-09-22"],
[{ format: "relativeDate" }, "2026-09-22", "relativeDate:2026-09-22"],
[{ format: "size" }, ["a", "b"], "number:2"],
[
{
remap: [
{ value: 0, to: "Off" },
{ value: 1, to: "On" },
],
},
1,
"On",
],
[
{
remap: [
{ value: "ok", to: "Healthy" },
{ any: true, to: "Unknown" },
],
},
"degraded",
"Unknown",
],
[{ format: "number", prefix: "$", suffix: "USD" }, 10, "$ number:10 USD"],
])("formatValue with %j formats %j as %j", (mapping, raw, expected) => {
expect(formatValue(t, mapping, raw)).toBe(expected);
});
it.each([
["adaptive", "stats.cpu", "text-emerald-300"],
["adaptive", "stats.change", "text-rose-300"],
["black", "stats.cpu", "text-black"],
["white", "stats.cpu", "text-white"],
["theme", "stats.cpu", "text-theme-500"],
[undefined, "stats.cpu", ""],
])("getColor %s for %s is %j", (color, field, expected) => {
expect(getColor({ additionalField: { field, color } }, data)).toBe(expected);
});
});
-125
View File
@@ -1,125 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import classNames from "classnames";
import { useTranslation } from "next-i18next/pages";
import { useContext } from "react";
import Container from "components/services/widget/container";
import { SettingsContext } from "utils/contexts/settings";
import useWidgetAPI from "utils/proxy/use-widget-api";
const rowClassName = "rounded-md bg-theme-200/50 dark:bg-theme-900/20 text-theme-700 dark:text-theme-200 text-xs";
const hideBrokenImage = (e) => {
e.currentTarget.style.visibility = "hidden";
};
function FeedLink({ item, target, className, children }) {
if (!item.link) return <div className={className}>{children}</div>;
return (
<a
href={item.link}
target={target}
rel="noopener noreferrer"
className={classNames(className, "hover:bg-theme-300/50 dark:hover:bg-theme-800/20")}
>
{children}
</a>
);
}
function RelativeDate({ date }) {
const { t } = useTranslation();
if (!date) return null;
return (
<span className="shrink-0 text-theme-500 dark:text-theme-400">
{t("common.relativeDate", { value: date, formatParams: { value: { style: "narrow", numeric: "auto" } } })}
</span>
);
}
function ListItem({ item, target }) {
return (
<FeedLink
item={item}
target={target}
className={classNames(rowClassName, "flex items-center gap-2 px-2", item.image ? "py-1" : "h-5")}
>
{item.image && (
<img
src={item.image}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={hideBrokenImage}
className="w-12 h-8 shrink-0 rounded-sm object-cover"
/>
)}
<span className="grow truncate text-left">{item.title}</span>
<RelativeDate date={item.date} />
</FeedLink>
);
}
function GridItem({ item, target }) {
return (
<FeedLink item={item} target={target} className={classNames(rowClassName, "flex flex-col overflow-hidden")}>
<div className="relative aspect-video w-full overflow-hidden bg-theme-200/50 dark:bg-theme-900/30">
{item.image && (
<img
src={item.image}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={hideBrokenImage}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-0.5 px-2 py-1.5 text-left">
<span className="line-clamp-2">{item.title}</span>
<RelativeDate date={item.date} />
</div>
</FeedLink>
);
}
export default function Component({ service }) {
const { t } = useTranslation();
const { settings } = useContext(SettingsContext);
const { widget } = service;
const { data, error } = useWidgetAPI(widget, undefined, undefined, { refreshInterval: 10 * 60 * 1000 });
if (error) {
return <Container service={service} error={error} />;
}
const target = service.target ?? settings?.target ?? "_blank";
const isGrid = widget.layout === "grid";
const Item = isGrid ? GridItem : ListItem;
let content;
if (!data) {
content = [0, 1, 2].map((i) => (
<div key={i} className={classNames(rowClassName, "animate-pulse", isGrid ? "aspect-video" : "h-5")} />
));
} else if (!data.items?.length) {
content = <div className={classNames(rowClassName, "h-5 px-2 flex items-center")}>{t("feed.noItems")}</div>;
} else {
content = data.items.map((item, i) => <Item key={`${item.link ?? item.title}-${i}`} item={item} target={target} />);
}
return (
<Container service={service}>
<div
className={classNames(
"w-full p-1",
isGrid ? "grid grid-cols-[repeat(auto-fill,minmax(8rem,1fr))] gap-2" : "flex flex-col gap-1",
)}
>
{content}
</div>
</Container>
);
}
-80
View File
@@ -1,80 +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 from "./component";
const items = [
{
title: "With image",
link: "https://example.com/1",
date: "2020-01-01T00:00:00Z",
image: "https://example.com/1.jpg",
},
{ title: "No link", link: null, date: null },
];
function render(widget = {}, settings = {}) {
return renderWithProviders(<Component service={{ widget: { type: "feed", ...widget } }} />, {
settings: { hideErrors: false, ...settings },
});
}
describe("widgets/feed/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = render();
expect(container.querySelectorAll(".animate-pulse")).toHaveLength(3);
});
it("renders an error", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "Invalid feed" } });
render();
expect(screen.getAllByText(/Invalid feed/).length).toBeGreaterThan(0);
});
it("renders an empty feed", () => {
useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined });
render();
expect(screen.getByText("feed.noItems")).toBeInTheDocument();
});
it("renders list items with links and images", () => {
useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
const { container } = render({}, { target: "_self" });
const link = screen.getByText("With image").closest("a");
expect(link).toHaveAttribute("href", "https://example.com/1");
expect(link).toHaveAttribute("target", "_self");
expect(link.querySelector("img")).toHaveAttribute("src", "https://example.com/1.jpg");
expect(screen.getByText("No link").closest("a")).toBeNull();
expect(container.querySelectorAll("img")).toHaveLength(1);
});
it("renders the grid layout with image placeholders", () => {
useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
const { container } = render({ layout: "grid" });
expect(container.querySelector(".grid")).not.toBeNull();
expect(container.querySelectorAll(".aspect-video")).toHaveLength(2);
expect(container.querySelectorAll("img")).toHaveLength(1);
});
it("hides images that fail to load", () => {
useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
const { container } = render();
const img = container.querySelector("img");
img.dispatchEvent(new Event("error"));
expect(img.style.visibility).toBe("hidden");
});
});
-61
View File
@@ -1,61 +0,0 @@
import cache from "memory-cache";
import { httpUrl, parseFeed } from "./utils";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import { sanitizeErrorURL } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
const logger = createLogger("feedProxyHandler");
const CACHE_MS = 10 * 60 * 1000;
export default async function feedProxyHandler(req, res) {
const { group, service, index } = req.query;
const widget = await getServiceWidget(group, service, index);
if (!widget?.url) {
return res.status(400).json({ error: "Missing feed URL" });
}
const href = httpUrl(widget.url);
if (!href) {
return res.status(400).json({ error: "Invalid feed URL" });
}
const url = new URL(href);
const cacheKey = `feed:${url.href}`;
let items = cache.get(cacheKey);
if (!items) {
const [status, , data] = await httpProxy(url, {
headers: {
"User-Agent": `gethomepage/${process.env.NEXT_PUBLIC_VERSION || "dev"}`,
Accept: "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.9, */*;q=0.8",
},
});
if (status !== 200) {
logger.debug("HTTP %d retrieving feed %s//%s%s", status, url.protocol, url.host, url.pathname);
return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url) } });
}
try {
items = parseFeed(Buffer.from(data).toString());
} catch (e) {
logger.debug("Error parsing feed %s//%s%s: %s", url.protocol, url.host, url.pathname, e.message);
return res.status(500).json({ error: { message: "Invalid feed", url: sanitizeErrorURL(url) } });
}
// only successful fetches are cached
cache.put(cacheKey, items, CACHE_MS);
}
const limit = parseInt(widget.maxItems, 10);
const maxItems = limit > 0 ? limit : 5;
const showImages = widget.images !== false && widget.images !== "false";
return res.status(200).json({
items: items.slice(0, maxItems).map(({ image, ...item }) => (showImages && image ? { ...item, image } : item)),
});
}
-183
View File
@@ -1,183 +0,0 @@
import cache from "memory-cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
import feedProxyHandler from "./proxy";
const items = Array.from(
{ length: 7 },
(_, i) => `<item><title>Item ${i}</title><link>https://example.com/${i}</link>
<media:thumbnail url="https://example.com/${i}.jpg" /></item>`,
).join("");
const feed = `<rss xmlns:media="http://search.yahoo.com/mrss/"><channel>${items}</channel></rss>`;
const req = { query: { group: "g", service: "svc", index: "0" } };
describe("widgets/feed/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache.clear();
});
it("returns 400 when the url is missing or invalid", async () => {
getServiceWidget.mockResolvedValueOnce({ type: "feed" });
let res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Missing feed URL" });
getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "nope" });
res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid feed URL" });
getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "file:///etc/passwd" });
res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid feed URL" });
expect(httpProxy).not.toHaveBeenCalled();
});
it("returns the first 5 items by default", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(httpProxy.mock.calls[0][0].href).toBe("https://example.com/feed.xml");
expect(res.statusCode).toBe(200);
expect(res.body.items).toHaveLength(5);
expect(res.body.items[0]).toEqual({
title: "Item 0",
link: "https://example.com/0",
date: null,
image: "https://example.com/0.jpg",
});
});
it.each([-1, 0, "abc"])("falls back to 5 items for maxItems %j", async (maxItems) => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml", maxItems });
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.body.items).toHaveLength(5);
});
it("respects maxItems and drops images when disabled", async () => {
getServiceWidget.mockResolvedValue({
type: "feed",
url: "https://example.com/feed.xml",
maxItems: "2",
images: false,
});
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.body.items).toEqual([
{ title: "Item 0", link: "https://example.com/0", date: null },
{ title: "Item 1", link: "https://example.com/1", date: null },
]);
});
it("passes through http errors without leaking the url", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml?token=secret" });
httpProxy.mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: { message: "HTTP Error", url: "example.com (see logs for details)" } });
});
it("never includes any part of the configured url in the response", async () => {
getServiceWidget.mockResolvedValue({
type: "feed",
url: "https://user:hunter2@nas.lan/feeds/s3cr3t/rss.xml?token=abc123",
});
const relative = `<rss><channel>
<item><title>Path</title><link>item/1</link><enclosure url="img.jpg" type="image/jpeg" /></item>
<item><title>Root</title><link>/x</link></item>
<item><title>Fragment</title><link>#top</link></item>
</channel></rss>`;
httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(relative)]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.body.items.map((item) => item.link)).toEqual([null, null, null]);
expect(JSON.stringify(res.body)).not.toMatch(/user|hunter2|nas\.lan|s3cr3t|token|abc123/);
});
it("returns 500 for unparseable feeds", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
httpProxy.mockResolvedValueOnce([200, "text/html", Buffer.from("<html></html>")]);
const res = createMockRes();
await feedProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: { message: "Invalid feed", url: "example.com (see logs for details)" } });
});
it("shares one cached fetch across widgets with different options", async () => {
getServiceWidget
.mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", maxItems: 1 })
.mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", images: false });
httpProxy.mockResolvedValue([200, "application/rss+xml", Buffer.from(feed)]);
const first = createMockRes();
await feedProxyHandler(req, first);
const second = createMockRes();
await feedProxyHandler(req, second);
expect(httpProxy).toHaveBeenCalledTimes(1);
expect(first.body.items).toHaveLength(1);
expect(second.body.items).toHaveLength(5);
expect(second.body.items[0].image).toBeUndefined();
});
it("does not cache failures", async () => {
getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
httpProxy
.mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")])
.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
const failed = createMockRes();
await feedProxyHandler(req, failed);
const recovered = createMockRes();
await feedProxyHandler(req, recovered);
expect(failed.statusCode).toBe(404);
expect(recovered.statusCode).toBe(200);
expect(httpProxy).toHaveBeenCalledTimes(2);
});
});
-114
View File
@@ -1,114 +0,0 @@
import sax from "sax";
import { xml2js } from "xml-js";
const asArray = (value) => (value === undefined || value === null ? [] : [].concat(value));
function getText(node) {
const first = asArray(node)[0];
if (first === undefined) return "";
if (typeof first !== "object") return String(first);
return asArray(first._cdata ?? first._text).join("");
}
export function httpUrl(value, baseUrl) {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed, baseUrl);
return ["http:", "https:"].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
function parseDate(value) {
const time = Date.parse(value);
return Number.isNaN(time) ? null : new Date(time).toISOString();
}
const attrs = (node) => node?._attributes ?? {};
const isImage = (node) => attrs(node).medium === "image" || attrs(node).type?.startsWith("image/");
function findImage(item) {
const groups = [item, ...asArray(item["media:group"])];
const candidates = [
...groups.flatMap((group) => asArray(group["media:thumbnail"])),
...groups.flatMap((group) => asArray(group["media:content"])).filter(isImage),
...asArray(item.enclosure).filter(isImage),
...asArray(item.link).filter((link) => attrs(link).rel === "enclosure" && isImage(link)),
];
const image = candidates.find((node) => attrs(node).url || attrs(node).href);
return image ? attrs(image).url || attrs(image).href : null;
}
const isPixel = ({ width, height }) => ["0", "1"].includes(width) || ["0", "1"].includes(height);
// first usable <img> in item html using sax tokenizer
function findHtmlImage(htmls, baseUrl) {
for (const html of htmls) {
if (!html) continue;
let image = null;
const parser = sax.parser(false, { lowercase: true });
parser.onopentag = ({ name, attributes }) => {
if (!image && name === "img" && !isPixel(attributes)) image = httpUrl(attributes.src, baseUrl);
};
parser.onerror = () => {
parser.error = null;
parser.resume();
};
parser.write(html).close();
if (image) return image;
}
return null;
}
function parseRssItem(item) {
const guid = asArray(item.guid)[0];
const guidLink = attrs(guid).isPermaLink !== "false" ? getText(guid) : null;
return {
title: getText(item.title),
link: getText(item.link) || guidLink,
date: getText(item.pubDate) || getText(item["dc:date"]),
image: findImage(item),
html: [getText(item["content:encoded"]), getText(item.description)],
};
}
const alternateLink = (node) => asArray(node.link).find((l) => (attrs(l).rel ?? "alternate") === "alternate");
function parseAtomEntry(entry) {
const link = alternateLink(entry) ?? asArray(entry.link)[0];
return {
title: getText(entry.title),
link: attrs(link).href,
date: getText(entry.published) || getText(entry.updated),
image: findImage(entry),
html: [getText(entry.content), getText(entry.summary)],
};
}
export function parseFeed(xml) {
const doc = xml2js(xml, { compact: true });
let items;
let site;
if (doc.rss) {
items = asArray(doc.rss.channel?.item).map(parseRssItem);
site = getText(doc.rss.channel?.link);
} else if (doc.feed) {
items = asArray(doc.feed.entry).map(parseAtomEntry);
site = attrs(alternateLink(doc.feed)).href;
} else throw new Error("Unsupported feed format");
// resolve against the feed's own site link, never the configured url
const baseUrl = httpUrl(site) ?? undefined;
return items
.map((item) => ({
title: item.title.trim(),
link: httpUrl(item.link, baseUrl),
date: parseDate(item.date),
image: httpUrl(item.image, baseUrl) ?? findHtmlImage(item.html, baseUrl),
}))
.filter((item) => item.title);
}
-169
View File
@@ -1,169 +0,0 @@
import { describe, expect, it } from "vitest";
import { httpUrl, parseFeed } from "./utils";
const rss = `<?xml version="1.0"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Example</title>
<link>https://example.com/</link>
<item>
<title><![CDATA[Tom & Jerry]]></title>
<link>https://example.com/one</link>
<pubDate>Tue, 22 Sep 2026 10:00:00 GMT</pubDate>
<media:thumbnail url="https://img.example.com/one.jpg" />
</item>
<item>
<title>Enclosure image</title>
<guid>https://example.com/two</guid>
<dc:date>2026-09-21T10:00:00Z</dc:date>
<enclosure url="/two.png" type="image/png" length="1" />
</item>
<item>
<title>Audio only</title>
<link>javascript:alert(1)</link>
<guid isPermaLink="false">abc-123</guid>
<enclosure url="https://example.com/three.mp3" type="audio/mpeg" length="1" />
</item>
<item>
<description>No title, skipped</description>
</item>
</channel>
</rss>`;
const atom = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<title>Example</title>
<entry>
<title> Fish &amp; chips
</title>
<link rel="enclosure" type="image/jpeg" href="https://example.com/one.jpg" />
<link rel="alternate" href="https://example.com/one" />
<updated>2026-09-22T12:00:00Z</updated>
</entry>
<entry>
<title>YouTube style</title>
<link href="https://example.com/two" />
<published>2026-09-20T12:00:00Z</published>
<updated>not a date</updated>
<media:group>
<media:content url="https://example.com/two.mp4" type="video/mp4" />
<media:thumbnail url="https://example.com/two.jpg" />
</media:group>
</entry>
</feed>`;
describe("widgets/feed/utils", () => {
it("parses rss 2.0 items", () => {
expect(parseFeed(rss)).toEqual([
{
title: "Tom & Jerry",
link: "https://example.com/one",
date: "2026-09-22T10:00:00.000Z",
image: "https://img.example.com/one.jpg",
},
{
title: "Enclosure image",
link: "https://example.com/two",
date: "2026-09-21T10:00:00.000Z",
image: "https://example.com/two.png",
},
{ title: "Audio only", link: null, date: null, image: null },
]);
});
it("parses atom entries", () => {
expect(parseFeed(atom)).toEqual([
{
title: "Fish & chips",
link: "https://example.com/one",
date: "2026-09-22T12:00:00.000Z",
image: "https://example.com/one.jpg",
},
{
title: "YouTube style",
link: "https://example.com/two",
date: "2026-09-20T12:00:00.000Z",
image: "https://example.com/two.jpg",
},
]);
});
it("falls back to the first usable image in item html", () => {
const xml = `<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel>
<link>https://example.com/</link>
<item>
<title>Content image</title>
<content:encoded><![CDATA[<p>Hi <img src="https://t.example.com/p.gif" width="1" height="1">
<img src="data:image/gif;base64,R0"><IMG SRC="/big.jpg?w=925&#038;h=925"><img src="/second.jpg">]]></content:encoded>
</item>
<item>
<title>Description image</title>
<description>&lt;img src="https://example.com/desc.png"&gt; unclosed &lt;b&gt;</description>
</item>
<item>
<title>Media wins</title>
<media:thumbnail url="https://example.com/thumb.jpg" />
<description><![CDATA[<img src="https://example.com/html.jpg">]]></description>
</item>
<item>
<title>No usable image</title>
<description><![CDATA[<img src="javascript:alert(1)"><img>]]></description>
</item>
</channel></rss>`;
expect(parseFeed(xml).map((item) => item.image)).toEqual([
"https://example.com/big.jpg?w=925&h=925",
"https://example.com/desc.png",
"https://example.com/thumb.jpg",
null,
]);
});
it("finds images in atom html content and summary", () => {
const xml = `<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title>Content</title><content type="html">&lt;img src="https://example.com/c.jpg"&gt;</content></entry>
<entry><title>Summary</title><summary type="html">&lt;img src="https://example.com/s.jpg"&gt;</summary></entry>
</feed>`;
expect(parseFeed(xml).map((item) => item.image)).toEqual([
"https://example.com/c.jpg",
"https://example.com/s.jpg",
]);
});
it("resolves relative urls against the feed's alternate link, never its self link", () => {
const entry = `<entry><title>Relative</title><link href="/posts/1" /></entry>`;
const selfOnly = `<feed xmlns="http://www.w3.org/2005/Atom">
<link rel="self" href="https://example.com/user.private.atom?token=abc123" />${entry}</feed>`;
const withSite = `<feed xmlns="http://www.w3.org/2005/Atom">
<link rel="self" href="https://example.com/user.private.atom?token=abc123" />
<link rel="alternate" href="https://example.com/blog/" />${entry}</feed>`;
expect(parseFeed(selfOnly)[0].link).toBeNull();
expect(parseFeed(withSite)[0].link).toBe("https://example.com/posts/1");
});
it("handles single-item and empty feeds", () => {
const single = "<rss><channel><item><title>Only</title></item></channel></rss>";
expect(parseFeed(single)).toEqual([{ title: "Only", link: null, date: null, image: null }]);
expect(parseFeed("<feed></feed>")).toEqual([]);
});
it("rejects unsupported documents", () => {
expect(() => parseFeed("<html><body>nope</body></html>")).toThrow("Unsupported feed format");
expect(() => parseFeed("not xml <")).toThrow();
});
it.each([
["https://a.com/x", undefined, "https://a.com/x"],
["/x", "https://a.com/feed", "https://a.com/x"],
["data:image/png;base64,AAAA", undefined, null],
["not a url", undefined, null],
["", undefined, null],
[" \n ", "https://a.com/feed", null],
[" /x ", "https://a.com/feed", "https://a.com/x"],
])("httpUrl(%j, %j) is %j", (value, base, expected) => {
expect(httpUrl(value, base)).toBe(expected);
});
});
-8
View File
@@ -1,8 +0,0 @@
import feedProxyHandler from "./proxy";
const widget = {
api: "{url}",
proxyHandler: feedProxyHandler,
};
export default widget;
-12
View File
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("feed widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.api).toBe("{url}");
});
});
+4 -7
View File
@@ -11,13 +11,10 @@ const widget = {
}, },
issues: { issues: {
endpoint: "repos/issues/search", endpoint: "repos/issues/search",
map: (data) => { map: (data) => ({
const items = asJson(data); pulls: asJson(data).filter((issue) => issue.pull_request),
return { issues: asJson(data).filter((issue) => !issue.pull_request),
pulls: items.filter((issue) => issue.pull_request), }),
issues: items.filter((issue) => !issue.pull_request),
};
},
}, },
repositories: { repositories: {
endpoint: "repos/search", endpoint: "repos/search",
+1 -10
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config"; import { expectWidgetConfigShape } from "test-utils/widget-config";
@@ -8,13 +8,4 @@ describe("gitea widget config", () => {
it("exports a valid widget config", () => { it("exports a valid widget config", () => {
expectWidgetConfigShape(widget); expectWidgetConfigShape(widget);
}); });
it("splits issues and pull requests", () => {
const result = widget.mappings.issues.map(
Buffer.from(JSON.stringify([{ id: 1, pull_request: {} }, { id: 2 }, { id: 3 }])),
);
expect(result.pulls.map((i) => i.id)).toEqual([1]);
expect(result.issues.map((i) => i.id)).toEqual([2, 3]);
});
}); });
+4 -7
View File
@@ -8,13 +8,10 @@ const widget = {
mappings: { mappings: {
counters: { counters: {
endpoint: "feeds/counters", endpoint: "feeds/counters",
map: (data) => { map: (data) => ({
const { reads, unreads } = asJson(data); read: Object.values(asJson(data).reads).reduce((acc, i) => acc + i, 0),
return { unread: Object.values(asJson(data).unreads).reduce((acc, i) => acc + i, 0),
read: Object.values(reads).reduce((acc, i) => acc + i, 0), }),
unread: Object.values(unreads).reduce((acc, i) => acc + i, 0),
};
},
}, },
}, },
}; };
+1 -9
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config"; import { expectWidgetConfigShape } from "test-utils/widget-config";
@@ -8,12 +8,4 @@ describe("miniflux widget config", () => {
it("exports a valid widget config", () => { it("exports a valid widget config", () => {
expectWidgetConfigShape(widget); expectWidgetConfigShape(widget);
}); });
it("sums read and unread counters", () => {
const result = widget.mappings.counters.map(
Buffer.from(JSON.stringify({ reads: { 1: 2, 2: 3 }, unreads: { 1: 4, 2: 1 } })),
);
expect(result).toEqual({ read: 5, unread: 5 });
});
}); });
+7 -5
View File
@@ -41,15 +41,17 @@ export default function Component({ service }) {
status = t("peanut.low_battery"); status = t("peanut.low_battery");
break; break;
default: default:
status = typeof upsStatus === "string" && upsStatus ? upsStatus : "-"; status = upsStatus;
} }
const percent = (value) => (value === undefined || value === null ? "-" : t("common.percent", { value }));
return ( return (
<Container service={service}> <Container service={service}>
<Block label="peanut.battery_charge" value={percent(batteryCharge)} highlightValue={batteryCharge} /> <Block
<Block label="peanut.ups_load" value={percent(upsLoad)} highlightValue={upsLoad} /> label="peanut.battery_charge"
value={t("common.percent", { value: batteryCharge })}
highlightValue={batteryCharge}
/>
<Block label="peanut.ups_load" value={t("common.percent", { value: upsLoad })} highlightValue={upsLoad} />
<Block label="peanut.ups_status" value={status} /> <Block label="peanut.ups_status" value={status} />
</Container> </Container>
); );
-11
View File
@@ -51,15 +51,4 @@ describe("widgets/peanut/component", () => {
expect(screen.getByText("peanut.online")).toBeInTheDocument(); expect(screen.getByText("peanut.online")).toBeInTheDocument();
expect(data).toEqual({ "battery.charge": 55, "ups.load": 12, "ups.status": "OL" }); expect(data).toEqual({ "battery.charge": 55, "ups.load": 12, "ups.status": "OL" });
}); });
it("renders dashes when readings are missing", () => {
useWidgetAPI.mockReturnValue({
data: { "device.model": "OR500LCDRM1Ua", "ups.status": 0 },
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "peanut" } }} />, { settings: { hideErrors: false } });
expect(screen.getAllByText("-")).toHaveLength(3);
});
}); });
+1 -2
View File
@@ -10,8 +10,7 @@ const widget = {
}, },
"nodes/localhost/tasks": { "nodes/localhost/tasks": {
endpoint: "nodes/localhost/tasks", endpoint: "nodes/localhost/tasks",
params: ["errors", "limit"], params: ["errors", "limit", "since"],
optionalParams: ["since"],
}, },
"nodes/localhost/status": { "nodes/localhost/status": {
endpoint: "nodes/localhost/status", endpoint: "nodes/localhost/status",
@@ -9,8 +9,7 @@ describe("proxmoxbackupserver widget config", () => {
expectWidgetConfigShape(widget); expectWidgetConfigShape(widget);
}); });
it("requires failed task query params for the tasks endpoint, with since optional", () => { it("requires failed task query params for the tasks endpoint", () => {
expect(widget.mappings["nodes/localhost/tasks"].params).toEqual(["errors", "limit"]); expect(widget.mappings["nodes/localhost/tasks"].params).toEqual(["errors", "limit", "since"]);
expect(widget.mappings["nodes/localhost/tasks"].optionalParams).toEqual(["since"]);
}); });
}); });
-77
View File
@@ -1,77 +0,0 @@
import { useTranslation } from "next-i18next/pages";
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
import withWidgetFields from "utils/widget-fields";
const DEFAULT_FIELDS = ["leech", "download", "seed", "upload"];
export default function Component({ service: configuredService }) {
const { t } = useTranslation();
const service = withWidgetFields(configuredService, DEFAULT_FIELDS);
const { widget } = service;
// A specific instance id selects per-instance stats; otherwise aggregate all instances.
const perInstance = widget.instance != null && widget.instance !== "";
const mapping = perInstance ? "torrents" : "torrentsAll";
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, mapping);
if (torrentError) {
return <Container service={service} error={torrentError} />;
}
if (!torrentData || !torrentData.stats) {
return (
<Container service={service}>
<Block label="qui.leech" />
<Block label="qui.download" />
<Block label="qui.seed" />
<Block label="qui.upload" />
<Block label="qui.total" />
<Block label="qui.errored" />
<Block label="qui.ratio" />
<Block label="qui.freeSpace" />
</Container>
);
}
const { stats } = torrentData;
const status = torrentData.counts?.status;
const serverState = torrentData.serverState;
// counts.status uses qBittorrent's sidebar semantics (complete vs incomplete). Seed/Leech show
// "active / total" — actively transferring over complete/incomplete. Fall back to stats-only.
const completed = status ? status.completed : stats.seeding;
const incomplete = status ? status.all - status.completed : stats.downloading;
const seedValue = `${t("common.number", { value: stats.seeding })} / ${t("common.number", { value: completed })}`;
const leechValue = `${t("common.number", { value: stats.downloading })} / ${t("common.number", { value: incomplete })}`;
return (
<Container service={service}>
<Block label="qui.leech" value={leechValue} />
<Block
label="qui.download"
value={t("common.bibyterate", { value: stats.totalDownloadSpeed, decimals: 1 })}
highlightValue={stats.totalDownloadSpeed}
/>
<Block label="qui.seed" value={seedValue} />
<Block
label="qui.upload"
value={t("common.bibyterate", { value: stats.totalUploadSpeed, decimals: 1 })}
highlightValue={stats.totalUploadSpeed}
/>
<Block label="qui.total" value={t("common.number", { value: status ? status.all : stats.total })} />
<Block label="qui.errored" value={t("common.number", { value: status ? status.errored : stats.error })} />
{serverState && (
<Block label="qui.ratio" value={t("common.number", { value: parseFloat(serverState.global_ratio) })} />
)}
{serverState && (
<Block
label="qui.freeSpace"
value={t("common.bbytes", { value: serverState.free_space_on_disk, maximumFractionDigits: 1 })}
/>
)}
</Container>
);
}
-149
View File
@@ -1,149 +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";
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";
// Aggregated response (cross-instance) — no serverState, as qui can't merge it.
const aggregatedData = {
stats: { downloading: 1, seeding: 2, totalDownloadSpeed: 100, totalUploadSpeed: 200, total: 10, error: 0 },
counts: { status: { all: 10, completed: 8, errored: 3 } },
};
// Per-instance response — includes serverState (ratio / free space).
const perInstanceData = {
stats: { downloading: 0, seeding: 3, totalDownloadSpeed: 0, totalUploadSpeed: 141939, total: 6477, error: 0 },
counts: { status: { all: 6477, completed: 6477, errored: 0 } },
serverState: { global_ratio: "8.58", free_space_on_disk: 49820224065536 },
};
describe("widgets/qui/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders four placeholder blocks while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("qui.leech")).toBeInTheDocument();
expect(screen.getByText("qui.download")).toBeInTheDocument();
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.getByText("qui.upload")).toBeInTheDocument();
});
it("shows placeholders for selected optional fields while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "qui", instance: 1, fields: ["seed", "ratio"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// Container filters placeholders by fields, so an optional placeholder must exist to be shown.
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.getByText("qui.ratio")).toBeInTheDocument();
});
it("renders error UI when the endpoint errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, { settings: { hideErrors: false } });
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("uses the aggregated endpoint and shows active/total for seed and leech", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, {
settings: { hideErrors: false },
});
// No instance id -> aggregated (cross-instance) mapping.
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "qui.leech", "1 / 2"); // active downloading / incomplete (all - completed)
expectBlockValue(container, "qui.download", "100");
expectBlockValue(container, "qui.seed", "2 / 8"); // active seeding / completed
expectBlockValue(container, "qui.upload", "200");
});
it("uses the per-instance endpoint and renders serverState-backed ratio and freeSpace", () => {
useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
const service = { widget: { type: "qui", instance: 1, fields: ["seed", "upload", "ratio", "freeSpace"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// Instance id set -> per-instance mapping.
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrents");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "qui.seed", "3 / 6477");
expectBlockValue(container, "qui.upload", "141939");
expectBlockValue(container, "qui.ratio", "8.58");
expectBlockValue(container, "qui.freeSpace", "49820224065536");
});
it("renders the optional total and errored fields when selected", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const service = { widget: { type: "qui", fields: ["total", "errored"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expectBlockValue(container, "qui.total", "10");
expectBlockValue(container, "qui.errored", "3");
});
it("falls back to stats when counts are missing", () => {
const { counts, ...statsOnly } = aggregatedData;
useWidgetAPI.mockReturnValue({ data: statsOnly, error: undefined });
const service = { widget: { type: "qui", instance: "", fields: ["leech", "seed", "total", "errored"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
expectBlockValue(container, "qui.leech", "1 / 1");
expectBlockValue(container, "qui.seed", "2 / 2");
expectBlockValue(container, "qui.total", "10");
expectBlockValue(container, "qui.errored", "0");
});
it("caps configured fields at four blocks", () => {
useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
const service = {
widget: { type: "qui", instance: 1, fields: ["total", "errored", "ratio", "freeSpace", "leech", "seed"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.queryByText("qui.leech")).toBeNull();
expect(screen.queryByText("qui.seed")).toBeNull();
});
it("omits ratio/freeSpace in aggregated mode where serverState is absent", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const service = { widget: { type: "qui", fields: ["seed", "ratio", "freeSpace"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.queryByText("qui.ratio")).toBeNull();
expect(screen.queryByText("qui.freeSpace")).toBeNull();
expect(container.querySelectorAll(".service-block")).toHaveLength(1);
});
});
-19
View File
@@ -1,19 +0,0 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
torrents: {
endpoint: "instances/{instance}/torrents?limit=1",
validate: ["stats"],
},
torrentsAll: {
endpoint: "torrents/cross-instance?limit=1",
validate: ["stats"],
},
},
};
export default widget;
-11
View File
@@ -1,11 +0,0 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("qui widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});
+10 -13
View File
@@ -1,4 +1,4 @@
import { asJson } from "utils/proxy/api-helpers"; import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers";
import genericProxyHandler from "utils/proxy/handlers/generic"; import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = { const widget = {
@@ -8,18 +8,15 @@ const widget = {
mappings: { mappings: {
movie: { movie: {
endpoint: "movie", endpoint: "movie",
map: (data) => { map: (data) => ({
const movieData = asJson(data) ?? []; wanted: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile && item.isAvailable).length,
return { have: jsonArrayFilter(data, (item) => item.hasFile).length,
wanted: movieData.filter((item) => item.monitored && !item.hasFile && item.isAvailable).length, missing: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile).length,
have: movieData.filter((item) => item.hasFile).length, all: asJson(data).map((entry) => ({
missing: movieData.filter((item) => item.monitored && !item.hasFile).length, title: entry.title,
all: movieData.map((entry) => ({ id: entry.id,
title: entry.title, })),
id: entry.id, }),
})),
};
},
}, },
"queue/status": { "queue/status": {
endpoint: "queue/status", endpoint: "queue/status",
-23
View File
@@ -33,27 +33,4 @@ describe("radarr widget config", () => {
expect(queue.map((entry) => entry.movieId)).toEqual([2, 1]); expect(queue.map((entry) => entry.movieId)).toEqual([2, 1]);
}); });
it("maps movie counts and titles", () => {
const movies = widget.mappings.movie.map(
Buffer.from(
JSON.stringify([
{ id: 1, title: "A", monitored: true, hasFile: false, isAvailable: true },
{ id: 2, title: "B", monitored: true, hasFile: true },
{ id: 3, title: "C", monitored: true, hasFile: false, isAvailable: false },
]),
),
);
expect(movies).toEqual({
wanted: 1,
have: 1,
missing: 2,
all: [
{ title: "A", id: 1 },
{ title: "B", id: 2 },
{ title: "C", id: 3 },
],
});
});
}); });
+4 -7
View File
@@ -8,13 +8,10 @@ const widget = {
mappings: { mappings: {
workers: { workers: {
endpoint: "workers/status", endpoint: "workers/status",
map: (data) => { map: (data) => ({
const workers = asJson(data).workers_status; total_workers: asJson(data).workers_status.length,
return { active_workers: asJson(data).workers_status.filter((worker) => !worker.idle).length,
total_workers: workers.length, }),
active_workers: workers.filter((worker) => !worker.idle).length,
};
},
}, },
pending: { pending: {
method: "POST", method: "POST",
+1 -9
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config"; import { expectWidgetConfigShape } from "test-utils/widget-config";
@@ -8,12 +8,4 @@ describe("unmanic widget config", () => {
it("exports a valid widget config", () => { it("exports a valid widget config", () => {
expectWidgetConfigShape(widget); expectWidgetConfigShape(widget);
}); });
it("counts total and active workers", () => {
const result = widget.mappings.workers.map(
Buffer.from(JSON.stringify({ workers_status: [{ idle: true }, { idle: false }, { idle: false }] })),
);
expect(result).toEqual({ total_workers: 3, active_workers: 2 });
});
}); });
-4
View File
@@ -31,7 +31,6 @@ import duplicati from "./duplicati/widget";
import emby from "./emby/widget"; import emby from "./emby/widget";
import esphome from "./esphome/widget"; import esphome from "./esphome/widget";
import evcc from "./evcc/widget"; import evcc from "./evcc/widget";
import feed from "./feed/widget";
import filebrowser from "./filebrowser/widget"; import filebrowser from "./filebrowser/widget";
import fileflows from "./fileflows/widget"; import fileflows from "./fileflows/widget";
import firefly from "./firefly/widget"; import firefly from "./firefly/widget";
@@ -113,7 +112,6 @@ import pulse from "./pulse/widget";
import pyload from "./pyload/widget"; import pyload from "./pyload/widget";
import qbittorrent from "./qbittorrent/widget"; import qbittorrent from "./qbittorrent/widget";
import qnap from "./qnap/widget"; import qnap from "./qnap/widget";
import qui from "./qui/widget";
import radarr from "./radarr/widget"; import radarr from "./radarr/widget";
import readarr from "./readarr/widget"; import readarr from "./readarr/widget";
import romm from "./romm/widget"; import romm from "./romm/widget";
@@ -193,7 +191,6 @@ const widgets = {
emby, emby,
esphome, esphome,
evcc, evcc,
feed,
filebrowser, filebrowser,
fileflows, fileflows,
firefly, firefly,
@@ -280,7 +277,6 @@ const widgets = {
pyload, pyload,
qbittorrent, qbittorrent,
qnap, qnap,
qui,
radarr, radarr,
readarr, readarr,
romm, romm,