Merge branch 'dev'

This commit is contained in:
shamoon
2026-05-08 15:54:10 -07:00
53 changed files with 2095 additions and 639 deletions

View File

@@ -15,7 +15,7 @@ body:
options: options:
- label: I confirm this was discussed, and the maintainers asked that I open an issue. - label: I confirm this was discussed, and the maintainers asked that I open an issue.
required: true required: true
- label: I am aware that if I create this issue without a discussion, it will be removed without a response. - label: I am aware that if I create this issue without a discussion, it will be closed without a response.
required: true required: true
- type: input - type: input
id: discussion id: discussion

View File

@@ -19,7 +19,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@7ca9c452bfe9197d3bb7fa83a4d7e2b0c9ae835d # v2 uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true

View File

@@ -7,6 +7,7 @@ on:
branches: branches:
- main - main
- feature/** - feature/**
- fix/**
- dev - dev
tags: [ 'v*.*.*' ] tags: [ 'v*.*.*' ]
pull_request: pull_request:
@@ -52,7 +53,7 @@ jobs:
latest=auto latest=auto
- name: Next.js build cache - name: Next.js build cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: .next/cache path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx') }} key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx') }}
@@ -60,13 +61,13 @@ 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@a8198c4bff370c8506180b035930dea56dbd5288 # v5 uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
with: with:
version: 10 version: 10
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'
@@ -83,7 +84,7 @@ jobs:
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -91,7 +92,7 @@ jobs:
- name: Login to Docker Hub - name: Login to Docker Hub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -104,7 +105,7 @@ jobs:
- 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}

View File

@@ -25,7 +25,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@94527f2e458b27549849d47d273a16bec83a01e9 # v7 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- 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
@@ -37,18 +37,18 @@ jobs:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- 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
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with: with:
path: site path: site
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
id: deployment id: deployment

View File

@@ -23,13 +23,13 @@ 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@a8198c4bff370c8506180b035930dea56dbd5288 # v5 uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
with: with:
version: 10 version: 10
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'

View File

@@ -13,6 +13,6 @@ jobs:
anti-slop: anti-slop:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: peakoss/anti-slop@a5a4b2440c9de6f65b64f0718a0136a1fdb04f6f # v0 - uses: peakoss/anti-slop@57858eead489d08b255fab2af45a506c2ca6eab2 # v0
with: with:
max-failures: 4 max-failures: 4

View File

@@ -26,14 +26,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- if: github.event_name == 'workflow_dispatch' && github.event.inputs.version != '' - if: github.event_name == 'workflow_dispatch' && github.event.inputs.version != ''
uses: release-drafter/release-drafter@a6acf82562eee06318b77ab8cb0b11ed81c677a7 # v7 uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7
with: with:
config-name: release-drafter.yml config-name: release-drafter.yml
version: ${{ github.event.inputs.version }} version: ${{ github.event.inputs.version }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: github.event_name != 'workflow_dispatch' || github.event.inputs.version == '' - if: github.event_name != 'workflow_dispatch' || github.event.inputs.version == ''
uses: release-drafter/release-drafter@a6acf82562eee06318b77ab8cb0b11ed81c677a7 # v7 uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7
with: with:
config-name: release-drafter.yml config-name: release-drafter.yml
env: env:
@@ -47,7 +47,7 @@ jobs:
pull-requests: write pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: release-drafter/release-drafter/autolabeler@ebb69bb56f1b0ebd19897745035726b19bef973e - uses: release-drafter/release-drafter/autolabeler@563bf132657a13ded0b01fcb723c5a58cdd824e2
with: with:
config-name: release-drafter.yml config-name: release-drafter.yml
env: env:

View File

@@ -57,7 +57,7 @@ jobs:
name: 'Close Answered Discussions' name: 'Close Answered Discussions'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {
@@ -113,7 +113,7 @@ jobs:
name: 'Close Outdated Discussions' name: 'Close Outdated Discussions'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {
@@ -204,7 +204,7 @@ jobs:
name: 'Close Unsupported Feature Requests' name: 'Close Unsupported Feature Requests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {

View File

@@ -15,11 +15,11 @@ jobs:
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 - uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
with: with:
version: 9 version: 9
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: 20 node-version: 20
cache: pnpm cache: pnpm
@@ -28,7 +28,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@75cd11691c0faa626561e295848008c8a7dddffe # v5 uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info files: ./coverage/lcov.info

View File

@@ -122,7 +122,7 @@ Use the `gethomepage.dev/pod-selector` selector to specify the pod used for the
### Traefik IngressRoute support ### Traefik IngressRoute support
Homepage can also read ingresses defined using the Traefik IngressRoute custom resource definition. Due to the complex nature of Traefik routing rules, it is required for the `gethomepage.dev/href` annotation to be set: If enabled (with `traefik: true` in kubernetes.yaml), homepage can also read ingresses defined using the Traefik IngressRoute custom resource definition. Due to the complex nature of Traefik routing rules, it is required for the `gethomepage.dev/href` annotation to be set:
```yaml ```yaml
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1

View File

@@ -16,6 +16,7 @@ The Glances widget allows you to monitor the resources (CPU, memory, storage, te
cpu: true # optional, enabled by default, disable by setting to false cpu: true # optional, enabled by default, disable by setting to false
mem: true # optional, enabled by default, disable by setting to false mem: true # optional, enabled by default, disable by setting to false
cputemp: true # disabled by default cputemp: true # disabled by default
cpuSensorLabel: Package id # optional additional cputemp sensor label prefix
unit: imperial # optional for temp, default is metric unit: imperial # optional for temp, default is metric
uptime: true # disabled by default uptime: true # disabled by default
disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below) disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below)
@@ -24,6 +25,8 @@ The Glances widget allows you to monitor the resources (CPU, memory, storage, te
label: MyMachine # optional label: MyMachine # optional
``` ```
The built-in `cputemp` sensor matching already checks common prefixes such as `cpu_thermal`, `Core`, `Tctl`, and `Temperature`. Use `cpuSensorLabel` to add your own Glances sensor label prefix when your system reports CPU temperatures under a different name.
Multiple disks can be specified as: Multiple disks can be specified as:
```yaml ```yaml

View File

@@ -0,0 +1,36 @@
---
title: ntfy
description: ntfy Widget Configuration
---
Learn more about [ntfy](https://github.com/binwiederhier/ntfy).
This widget shows the latest notification for a ntfy topic, including the title or body, priority level, and when it was received. Works with both self-hosted ntfy instances and the public [ntfy.sh](https://ntfy.sh) service.
Allowed fields: `["title", "message", "priority", "lastReceived", "tags"]`.
Default fields: `["title", "message", "priority", "lastReceived"]`.
If more than 4 fields are provided, only the first 4 are displayed.
## Authentication
ntfy supports both public and private topics. For private instances or access-controlled topics, you can authenticate using either a **Bearer token** (ntfy access token) or **Basic auth** (username/password).
| Auth Method | Config Fields | Notes |
| ------------ | ------------------------------ | --------------------------------- |
| None | _(omit key/username/password)_ | For public topics |
| Bearer token | `key` | ntfy access tokens (`tk_` prefix) |
| Basic auth | `username` + `password` | Username/password credentials |
See the [ntfy documentation](https://docs.ntfy.sh/config/#access-control) for details on access control.
```yaml
widget:
type: ntfy
url: http://ntfy.host.or.ip:port # required
topic: mytopic # required
# key: tk_accesstoken # optional — for token auth
# username: user # optional — for basic auth
# password: pass # optional — for basic auth
```

View File

@@ -13,4 +13,5 @@ widget:
url: http://pyload.host.or.ip:port url: http://pyload.host.or.ip:port
username: username username: username
password: password # only needed if set password: password # only needed if set
key: pyloadapikey # only needed if set, takes precedence over username/password
``` ```

View File

@@ -9,7 +9,7 @@ You will need to generate an API access token from the [keys page](https://login
To find your device ID, go to the [machine overview page](https://login.tailscale.com/admin/machines) and select your machine. In the "Machine Details" section, copy your `ID`. It will end with `CNTRL`. To find your device ID, go to the [machine overview page](https://login.tailscale.com/admin/machines) and select your machine. In the "Machine Details" section, copy your `ID`. It will end with `CNTRL`.
Allowed fields: `["address", "last_seen", "expires"]`. Allowed fields: `[ "address", "last_seen", "expires", "user", "hostname", "name", "client_version", "os", "created", "authorized", "is_external", "update_available", "tags" ]`.
```yaml ```yaml
widget: widget:

View File

@@ -14,6 +14,7 @@ widget:
type: technitium type: technitium
url: <url to dns server> url: <url to dns server>
key: biglongapitoken key: biglongapitoken
node: <node dns name or cluster> # optional, defaults to current node
range: LastDay # optional, defaults to LastHour range: LastDay # optional, defaults to LastHour
``` ```
@@ -21,6 +22,10 @@ widget:
This can be generated via the Technitium DNS Dashboard, and should be generated from a special API specific user. This can be generated via the Technitium DNS Dashboard, and should be generated from a special API specific user.
#### Node
`node` value determines which Technitium cluster node the statistics are returned for. Specifying a value of `cluster` returns aggregrate stats for all nodes in the cluster. Specify a node domain name to return specific node stats, no value returns stats for the node against which the API is executed.
#### Range #### Range
`range` value determines how far back of statistics to pull data for. The value comes directly from Technitium API documentation found [here](https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md#dashboard-api-calls), defined as `"type"`. The value can be one of: `LastHour`, `LastDay`, `LastWeek`, `LastMonth`, `LastYear`. `range` value determines how far back of statistics to pull data for. The value comes directly from Technitium API documentation found [here](https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md#dashboard-api-calls), defined as `"type"`. The value can be one of: `LastHour`, `LastDay`, `LastWeek`, `LastMonth`, `LastYear`.

View File

@@ -116,6 +116,7 @@ nav:
- widgets/services/nextcloud.md - widgets/services/nextcloud.md
- widgets/services/nextdns.md - widgets/services/nextdns.md
- widgets/services/nginx-proxy-manager.md - widgets/services/nginx-proxy-manager.md
- widgets/services/ntfy.md
- widgets/services/nzbget.md - widgets/services/nzbget.md
- widgets/services/octoprint.md - widgets/services/octoprint.md
- widgets/services/omada.md - widgets/services/omada.md

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.12.3", "version": "1.13.0",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -14,27 +14,27 @@
"telemetry": "next telemetry disable" "telemetry": "next telemetry disable"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.9", "@headlessui/react": "^2.2.10",
"@kubernetes/client-node": "^1.0.0", "@kubernetes/client-node": "^1.0.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
"dockerode": "^4.0.7", "dockerode": "^4.0.10",
"follow-redirects": "^1.15.11", "follow-redirects": "^1.16.0",
"gamedig": "^5.3.2", "gamedig": "^5.3.2",
"i18next": "^25.8.0", "i18next": "^25.10.9",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"json-rpc-2.0": "^1.7.0", "json-rpc-2.0": "^1.7.1",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2", "minecraftstatuspinger": "^1.2.2",
"next": "^16.1.7", "next": "^16.2.4",
"next-i18next": "^15.4.3", "next-i18next": "^15.4.3",
"ping": "^0.4.4", "ping": "^0.4.4",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"raw-body": "^3.0.2", "raw-body": "^3.0.2",
"react": "^19.2.4", "react": "^19.2.5",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-i18next": "^15.5.3", "react-i18next": "^15.5.3",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
@@ -47,10 +47,10 @@
"xml-js": "^1.6.11" "xml-js": "^1.6.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.2", "@eslint/compat": "^2.0.3",
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.4",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -64,7 +64,7 @@
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.10",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"tailwind-scrollbar": "^4.0.2", "tailwind-scrollbar": "^4.0.2",
@@ -74,12 +74,5 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"osx-temperature-sensor": "^1.0.8" "osx-temperature-sensor": "^1.0.8"
},
"pnpm": {
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"osx-temperature-sensor",
"sharp"
]
} }
} }

734
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

18
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,18 @@
packages:
- .
allowBuilds:
'@tailwindcss/oxide': true
core-js: false
cpu-features: false
esbuild: false
osx-temperature-sensor: true
protobufjs: false
sharp: true
ssh2: false
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- osx-temperature-sensor
- protobufjs
- sharp

View File

@@ -344,6 +344,16 @@
"address": "Address", "address": "Address",
"expires": "Expires", "expires": "Expires",
"never": "Never", "never": "Never",
"user": "User",
"hostname": "Hostname",
"name": "Name",
"client_version": "Client Version",
"os": "OS",
"created": "Created",
"authorized": "Authorized",
"is_external": "Is External",
"update_available": "Update Available",
"tags": "Tags",
"last_seen": "Last Seen", "last_seen": "Last Seen",
"now": "Now", "now": "Now",
"years": "{{number}}y", "years": "{{number}}y",
@@ -352,7 +362,9 @@
"hours": "{{number}}h", "hours": "{{number}}h",
"minutes": "{{number}}m", "minutes": "{{number}}m",
"seconds": "{{number}}s", "seconds": "{{number}}s",
"ago": "{{value}} Ago" "ago": "{{value}} Ago",
"true": "Yes",
"false": "No"
}, },
"technitium": { "technitium": {
"totalQueries": "Queries", "totalQueries": "Queries",
@@ -924,6 +936,19 @@
"warnings": "Warnings", "warnings": "Warnings",
"criticals": "Criticals" "criticals": "Criticals"
}, },
"ntfy": {
"title": "Title",
"priority": "Priority",
"lastReceived": "Last Received",
"message": "Message",
"tags": "Tags",
"none": "None",
"min": "Min",
"low": "Low",
"default": "Default",
"high": "High",
"urgent": "Urgent"
},
"plantit": { "plantit": {
"events": "Events", "events": "Events",
"plants": "Plants", "plants": "Plants",

View File

@@ -11,12 +11,16 @@ import Resource from "../widget/resource";
import Resources from "../widget/resources"; import Resources from "../widget/resources";
import WidgetLabel from "../widget/widget_label"; import WidgetLabel from "../widget/widget_label";
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"]; const defaultCpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
function convertToFahrenheit(t) { function convertToFahrenheit(t) {
return (t * 9) / 5 + 32; return (t * 9) / 5 + 32;
} }
function getCpuSensorLabels(options) {
return [...defaultCpuSensorLabels, options.cpuSensorLabel].filter(Boolean);
}
export default function Widget({ options }) { export default function Widget({ options }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
@@ -38,7 +42,6 @@ export default function Widget({ options }) {
<Resources options={options} additionalClassNames="information-widget-glances"> <Resources options={options} additionalClassNames="information-widget-glances">
{options.cpu !== false && <Resource icon={FiCpu} label={t("glances.wait")} percentage="0" />} {options.cpu !== false && <Resource icon={FiCpu} label={t("glances.wait")} percentage="0" />}
{options.mem !== false && <Resource icon={FaMemory} label={t("glances.wait")} percentage="0" />} {options.mem !== false && <Resource icon={FaMemory} label={t("glances.wait")} percentage="0" />}
{options.cputemp && <Resource icon={FaThermometerHalf} label={t("glances.wait")} percentage="0" />}
{options.disk && !Array.isArray(options.disk) && ( {options.disk && !Array.isArray(options.disk) && (
<Resource key={options.disk} icon={FiHardDrive} label={t("glances.wait")} percentage="0" /> <Resource key={options.disk} icon={FiHardDrive} label={t("glances.wait")} percentage="0" />
)} )}
@@ -47,6 +50,7 @@ export default function Widget({ options }) {
options.disk.map((disk) => ( options.disk.map((disk) => (
<Resource key={`disk_${disk}`} icon={FiHardDrive} label={t("glances.wait")} percentage="0" /> <Resource key={`disk_${disk}`} icon={FiHardDrive} label={t("glances.wait")} percentage="0" />
))} ))}
{options.cputemp && <Resource icon={FaThermometerHalf} label={t("glances.wait")} percentage="0" />}
{options.uptime && <Resource icon={FaRegClock} label={t("glances.wait")} percentage="0" />} {options.uptime && <Resource icon={FaRegClock} label={t("glances.wait")} percentage="0" />}
{options.label && <WidgetLabel label={options.label} />} {options.label && <WidgetLabel label={options.label} />}
</Resources> </Resources>
@@ -56,6 +60,7 @@ export default function Widget({ options }) {
const unit = options.units === "imperial" ? "fahrenheit" : "celsius"; const unit = options.units === "imperial" ? "fahrenheit" : "celsius";
let mainTemp = 0; let mainTemp = 0;
let maxTemp = 80; let maxTemp = 80;
const cpuSensorLabels = getCpuSensorLabels(options);
const cpuSensors = data.sensors?.filter( const cpuSensors = data.sensors?.filter(
(s) => cpuSensorLabels.some((label) => s.label.startsWith(label)) && s.type === "temperature_core", (s) => cpuSensorLabels.some((label) => s.label.startsWith(label)) && s.type === "temperature_core",
); );

View File

@@ -120,6 +120,26 @@ describe("components/widgets/glances", () => {
expect(screen.getByRole("link")).toHaveClass("expanded"); expect(screen.getByRole("link")).toHaveClass("expanded");
}); });
it("renders temperature for custom cpu sensor labels", () => {
useSWR.mockReturnValue({
data: {
cpu: { total: 1 },
load: { min15: 1 },
mem: { available: 1, total: 1, percent: 1 },
fs: [],
sensors: [{ label: "Package id 0", type: "temperature_core", value: 42, warning: 90 }],
},
error: undefined,
});
renderWithProviders(<Glances options={{ cputemp: true, cpuSensorLabel: "Package id", url: "http://glances" }} />, {
settings: { target: "_self" },
});
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("glances.temp")).toBeInTheDocument();
});
it("renders disk resources for an array of mount points and filters missing mounts", () => { it("renders disk resources for an array of mount points and filters missing mounts", () => {
useSWR.mockReturnValue({ useSWR.mockReturnValue({
data: { data: {

View File

@@ -19,7 +19,10 @@ const tailwindSafelist = [
"backdrop-blur-xs", "backdrop-blur-xs",
"backdrop-blur-sm", "backdrop-blur-sm",
"backdrop-blur-md", "backdrop-blur-md",
"backdrop-blur-lg",
"backdrop-blur-xl", "backdrop-blur-xl",
"backdrop-blur-2xl",
"backdrop-blur-3xl",
"backdrop-saturate-0", "backdrop-saturate-0",
"backdrop-saturate-50", "backdrop-saturate-50",
"backdrop-saturate-100", "backdrop-saturate-100",

View File

@@ -665,6 +665,7 @@ export function cleanServiceGroups(groups) {
if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents; if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents;
} }
if (type === "technitium") { if (type === "technitium") {
if (node !== undefined) widget.node = node;
if (range !== undefined) widget.range = range; if (range !== undefined) widget.range = range;
} }
if (type === "lubelogger") { if (type === "lubelogger") {

View File

@@ -7,7 +7,10 @@ export function setCookieHeader(url, params) {
const existingCookie = cookieJar.getCookieStringSync(url.toString()); const existingCookie = cookieJar.getCookieStringSync(url.toString());
if (existingCookie) { if (existingCookie) {
params.headers = params.headers ?? {}; params.headers = params.headers ?? {};
params.headers[params.cookieHeader ?? "Cookie"] = existingCookie; const cookieHeader = params.cookieHeader ?? "Cookie";
if (!params.headers[cookieHeader]) {
params.headers[cookieHeader] = existingCookie;
}
} }
} }

View File

@@ -42,4 +42,16 @@ describe("utils/proxy/cookie-jar", () => {
expect(params.headers.Cookie).toContain("c=d"); expect(params.headers.Cookie).toContain("c=d");
}); });
it("does not overwrite an explicit cookie header", async () => {
const { addCookieToJar, setCookieHeader } = await import("./cookie-jar");
const url = new URL("http://example4.test/path");
addCookieToJar(url, { "set-cookie": ["sid=1; Path=/"] });
const params = { headers: { Cookie: "manual=1" } };
setCookieHeader(url, params);
expect(params.headers.Cookie).toBe("manual=1");
});
}); });

View File

@@ -77,6 +77,12 @@ export default async function credentialedProxyHandler(req, res, map) {
} else { } else {
headers.Authorization = basicAuthHeader(widget); headers.Authorization = basicAuthHeader(widget);
} }
} else if (widget.type === "ntfy") {
if (widget.key) {
headers.Authorization = `Bearer ${widget.key}`;
} else if (widget.username && widget.password) {
headers.Authorization = basicAuthHeader(widget);
}
} else if (widget.type === "proxmox") { } else if (widget.type === "proxmox") {
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`; headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
} else if (widget.type === "proxmoxbackupserver") { } else if (widget.type === "proxmoxbackupserver") {
@@ -152,6 +158,14 @@ export default async function credentialedProxyHandler(req, res, map) {
if (status >= 400) { if (status >= 400) {
logger.error("HTTP Error %d calling %s", status, url.toString()); logger.error("HTTP Error %d calling %s", status, url.toString());
return res.status(status).json({
error: {
message: resultData?.error?.message ?? "HTTP Error",
url: sanitizeErrorURL(url),
...(resultData?.error?.rawError ? { rawError: resultData.error.rawError } : {}),
data: Buffer.isBuffer(resultData) ? Buffer.from(resultData).toString() : resultData,
},
});
} }
if (status === 200) { if (status === 200) {

View File

@@ -34,6 +34,7 @@ vi.mock("widgets/widgets", () => ({
paperlessngx: { api: "{url}/api/{endpoint}" }, paperlessngx: { api: "{url}/api/{endpoint}" },
proxmox: { api: "{url}/api2/json/{endpoint}" }, proxmox: { api: "{url}/api2/json/{endpoint}" },
truenas: { api: "{url}/api/v2.0/{endpoint}" }, truenas: { api: "{url}/api/v2.0/{endpoint}" },
ntfy: { api: "{url}/{endpoint}" },
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" }, proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
checkmk: { api: "{url}/{endpoint}" }, checkmk: { api: "{url}/{endpoint}" },
stocks: { api: "{url}/{endpoint}" }, stocks: { api: "{url}/{endpoint}" },
@@ -185,6 +186,51 @@ describe("utils/proxy/handlers/credentialed", () => {
expect(params.headers.Authorization).toBe("Bearer k"); expect(params.headers.Authorization).toBe("Bearer k");
}); });
it("uses Bearer auth for ntfy when key is provided", async () => {
getServiceWidget.mockResolvedValue({ type: "ntfy", url: "http://ntfy", topic: "alerts", key: "tk_test" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Authorization).toBe("Bearer tk_test");
});
it("uses Basic auth for ntfy when username/password are provided", async () => {
getServiceWidget.mockResolvedValue({
type: "ntfy",
url: "http://ntfy",
topic: "alerts",
username: "u",
password: "p",
});
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Authorization).toMatch(/^Basic /);
});
it("sends no auth header for ntfy when no credentials are configured", async () => {
getServiceWidget.mockResolvedValue({ type: "ntfy", url: "http://ntfy", topic: "alerts" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Authorization).toBeUndefined();
});
it.each([ it.each([
[{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }], [{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }],
[ [
@@ -204,6 +250,25 @@ describe("utils/proxy/handlers/credentialed", () => {
expect(params.headers).toEqual(expect.objectContaining(expected)); expect(params.headers).toEqual(expect.objectContaining(expected));
}); });
it("normalizes non-200 JSON responses into widget error payloads", async () => {
getServiceWidget.mockResolvedValue({ type: "paperlessngx", url: "http://x", key: "k" });
httpProxy.mockResolvedValue([401, "application/json", { detail: "Invalid token." }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "statistics", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
expect(res.statusCode).toBe(401);
expect(res.body).toEqual({
error: {
message: "HTTP Error",
url: "http://x/api/statistics",
data: { detail: "Invalid token." },
},
});
});
it("uses basic auth for esphome when username/password are provided", async () => { it("uses basic auth for esphome when username/password are provided", async () => {
getServiceWidget.mockResolvedValue({ type: "esphome", url: "http://x", username: "u", password: "p" }); getServiceWidget.mockResolvedValue({ type: "esphome", url: "http://x", username: "u", password: "p" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]); httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);

View File

@@ -224,23 +224,43 @@ function homepageDNSLookupFn() {
}; };
} }
const homepageLookup = homepageDNSLookupFn();
const agentCache = new Map();
function getAgent(protocol, disableIpv6) {
const cacheKey = `${protocol}:${disableIpv6 ? "ipv4" : "auto"}`;
const cachedAgent = agentCache.get(cacheKey);
if (cachedAgent) {
return cachedAgent;
}
const agentOptions = {
keepAlive: true,
...(disableIpv6 ? { family: 4, autoSelectFamily: false } : { autoSelectFamilyAttemptTimeout: 500 }),
lookup: homepageLookup,
};
const agent =
protocol === "https:"
? new https.Agent({ ...agentOptions, rejectUnauthorized: false })
: new http.Agent(agentOptions);
agentCache.set(cacheKey, agent);
return agent;
}
export async function httpProxy(url, params = {}) { export async function httpProxy(url, params = {}) {
const constructedUrl = new URL(url); const constructedUrl = new URL(url);
const disableIpv6 = process.env.HOMEPAGE_PROXY_DISABLE_IPV6 === "true"; const disableIpv6 = process.env.HOMEPAGE_PROXY_DISABLE_IPV6 === "true";
const agentOptions = {
...(disableIpv6 ? { family: 4, autoSelectFamily: false } : { autoSelectFamilyAttemptTimeout: 500 }),
lookup: homepageDNSLookupFn(),
};
let request = null; let request = null;
if (constructedUrl.protocol === "https:") { if (constructedUrl.protocol === "https:") {
request = httpsRequest(constructedUrl, { request = httpsRequest(constructedUrl, {
agent: new https.Agent({ ...agentOptions, rejectUnauthorized: false }), agent: getAgent(constructedUrl.protocol, disableIpv6),
...params, ...params,
}); });
} else { } else {
request = httpRequest(constructedUrl, { request = httpRequest(constructedUrl, {
agent: new http.Agent(agentOptions), agent: getAgent(constructedUrl.protocol, disableIpv6),
...params, ...params,
}); });
} }

View File

@@ -8,6 +8,7 @@ const { state, cache, logger, dns, net, cookieJar } = vi.hoisted(() => ({
body: Buffer.from(""), body: Buffer.from(""),
}, },
error: null, error: null,
lastAgent: null,
lastAgentOptions: null, lastAgentOptions: null,
lastRequestParams: null, lastRequestParams: null,
lastWrittenBody: null, lastWrittenBody: null,
@@ -59,6 +60,7 @@ vi.mock("follow-redirects", async () => {
state.lastWrittenBody = chunk; state.lastWrittenBody = chunk;
}); });
req.end = vi.fn(() => { req.end = vi.fn(() => {
state.lastAgent = params?.agent ?? null;
state.lastAgentOptions = params?.agent?.opts ?? null; state.lastAgentOptions = params?.agent?.opts ?? null;
if (state.error) { if (state.error) {
req.emit("error", state.error); req.emit("error", state.error);
@@ -104,6 +106,7 @@ describe("utils/proxy/http cachedRequest", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: Buffer.from(""), body: Buffer.from(""),
}; };
state.lastAgent = null;
state.lastAgentOptions = null; state.lastAgentOptions = null;
state.lastRequestParams = null; state.lastRequestParams = null;
state.lastWrittenBody = null; state.lastWrittenBody = null;
@@ -307,6 +310,7 @@ describe("utils/proxy/http httpProxy", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: Buffer.from("ok"), body: Buffer.from("ok"),
}; };
state.lastAgent = null;
state.lastAgentOptions = null; state.lastAgentOptions = null;
state.lastRequestParams = null; state.lastRequestParams = null;
state.lastWrittenBody = null; state.lastWrittenBody = null;
@@ -397,6 +401,7 @@ describe("utils/proxy/http httpProxy", () => {
await httpMod.httpProxy("http://example.com"); await httpMod.httpProxy("http://example.com");
expect(state.lastAgentOptions.keepAlive).toBe(true);
expect(state.lastAgentOptions.family).toBe(4); expect(state.lastAgentOptions.family).toBe(4);
expect(state.lastAgentOptions.autoSelectFamily).toBe(false); expect(state.lastAgentOptions.autoSelectFamily).toBe(false);
}); });
@@ -409,6 +414,17 @@ describe("utils/proxy/http httpProxy", () => {
expect(state.lastAgentOptions.rejectUnauthorized).toBe(false); expect(state.lastAgentOptions.rejectUnauthorized).toBe(false);
}); });
it("reuses the same keep-alive agent for repeated http requests", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com/first");
const firstAgent = state.lastAgent;
await httpMod.httpProxy("http://example.com/second");
expect(state.lastAgentOptions.keepAlive).toBe(true);
expect(state.lastAgent).toBe(firstAgent);
});
it("returns a sanitized error response when the request fails", async () => { it("returns a sanitized error response when the request fails", async () => {
state.error = Object.assign(new Error("boom"), { code: "EHOSTUNREACH" }); state.error = Object.assign(new Error("boom"), { code: "EHOSTUNREACH" });
const httpMod = await import("./http"); const httpMod = await import("./http");

View File

@@ -39,8 +39,8 @@ export default function Event({ event, colorVariants, showDate = false, showTime
return event.url ? ( return event.url ? (
<a <a
className={classNames(className, "hover:bg-theme-300/50 dark:hover:bg-theme-800/20")} className={classNames(className, "hover:bg-theme-300/50 dark:hover:bg-theme-800/20")}
onMouseEnter={() => setHover(!hover)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(!hover)} onMouseLeave={() => setHover(false)}
key={key} key={key}
href={event.url} href={event.url}
target="_blank" target="_blank"
@@ -49,7 +49,7 @@ export default function Event({ event, colorVariants, showDate = false, showTime
{children} {children}
</a> </a>
) : ( ) : (
<div className={className} onMouseEnter={() => setHover(!hover)} onMouseLeave={() => setHover(!hover)} key={key}> <div className={className} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} key={key}>
{children} {children}
</div> </div>
); );

View File

@@ -48,6 +48,56 @@ describe("widgets/calendar/event", () => {
expect(link.querySelector("svg")).toBeTruthy(); expect(link.querySelector("svg")).toBeTruthy();
}); });
it("keeps additional text visible after repeated mouse enter events", () => {
const date = DateTime.fromISO("2099-01-01T13:00:00.000Z").setZone("utc");
render(
<Event
event={{
title: "Primary",
additional: "More info",
date,
color: "gray",
url: "https://example.com",
}}
colorVariants={{ gray: "bg-gray-500" }}
/>,
);
const link = screen.getByRole("link", { name: /primary/i });
fireEvent.mouseEnter(link);
fireEvent.mouseEnter(link);
expect(screen.getByText("More info")).toBeInTheDocument();
expect(screen.queryByText("Primary")).toBeNull();
});
it("keeps title visible after repeated mouse leave events", () => {
const date = DateTime.fromISO("2099-01-01T13:00:00.000Z").setZone("utc");
render(
<Event
event={{
title: "Primary",
additional: "More info",
date,
color: "gray",
}}
colorVariants={{ gray: "bg-gray-500" }}
/>,
);
const event = screen.getByText("Primary").closest("div.flex");
fireEvent.mouseEnter(event);
expect(screen.getByText("More info")).toBeInTheDocument();
fireEvent.mouseLeave(event);
fireEvent.mouseLeave(event);
expect(screen.getByText("Primary")).toBeInTheDocument();
expect(screen.queryByText("More info")).toBeNull();
});
it("compareDateTimezone matches dates by day", () => { it("compareDateTimezone matches dates by day", () => {
const day = DateTime.fromISO("2099-01-01T00:00:00.000Z").setZone("utc"); const day = DateTime.fromISO("2099-01-01T00:00:00.000Z").setZone("utc");
expect(compareDateTimezone(day, { date: DateTime.fromISO("2099-01-01T23:59:00.000Z").setZone("utc") })).toBe(true); expect(compareDateTimezone(day, { date: DateTime.fromISO("2099-01-01T23:59:00.000Z").setZone("utc") })).toBe(true);

View File

@@ -91,6 +91,7 @@ const components = {
nextcloud: dynamic(() => import("./nextcloud/component")), nextcloud: dynamic(() => import("./nextcloud/component")),
nextdns: dynamic(() => import("./nextdns/component")), nextdns: dynamic(() => import("./nextdns/component")),
npm: dynamic(() => import("./npm/component")), npm: dynamic(() => import("./npm/component")),
ntfy: dynamic(() => import("./ntfy/component")),
nzbget: dynamic(() => import("./nzbget/component")), nzbget: dynamic(() => import("./nzbget/component")),
octoprint: dynamic(() => import("./octoprint/component")), octoprint: dynamic(() => import("./octoprint/component")),
omada: dynamic(() => import("./omada/component")), omada: dynamic(() => import("./omada/component")),

View File

@@ -0,0 +1,63 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
const priorityLabels = {
1: "min",
2: "low",
3: "default",
4: "high",
5: "urgent",
};
function Truncated({ text }) {
return (
<span className="grid w-full" title={text}>
<span className="truncate">{text}</span>
</span>
);
}
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: messagesData, error: messagesError } = useWidgetAPI(widget, "messages");
if (messagesError) {
return <Container service={service} error={messagesError} />;
}
if (!widget.fields || widget.fields.length === 0) {
widget.fields = ["title", "message", "priority", "lastReceived"];
} else if (widget.fields?.length > 4) {
widget.fields = widget.fields.slice(0, 4);
}
if (!messagesData) {
return (
<Container service={service}>
<Block label="ntfy.title" />
<Block label="ntfy.message" />
<Block label="ntfy.priority" />
<Block label="ntfy.lastReceived" />
<Block label="ntfy.tags" />
</Container>
);
}
return (
<Container service={service}>
<Block label="ntfy.title" value={<Truncated text={messagesData.title ?? t("ntfy.none")} />} />
<Block label="ntfy.message" value={<Truncated text={messagesData.message ?? t("ntfy.none")} />} />
<Block label="ntfy.priority" value={t(`ntfy.${priorityLabels[messagesData.priority] ?? "default"}`)} />
<Block
label="ntfy.lastReceived"
value={messagesData.time ? t("common.relativeDate", { value: messagesData.time * 1000 }) : t("ntfy.none")}
/>
<Block label="ntfy.tags" value={messagesData.tags?.length > 0 ? messagesData.tags.join(", ") : t("ntfy.none")} />
</Container>
);
}

View File

@@ -0,0 +1,216 @@
// @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";
describe("widgets/ntfy/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "ntfy" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("ntfy.title")).toBeInTheDocument();
expect(screen.getByText("ntfy.message")).toBeInTheDocument();
expect(screen.getByText("ntfy.priority")).toBeInTheDocument();
expect(screen.getByText("ntfy.lastReceived")).toBeInTheDocument();
});
it("renders message data with default fields", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Disk Alert",
message: "Disk usage at 90%",
priority: 4,
time: 1700000000,
tags: ["warning"],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "ntfy.title", "Disk Alert");
expectBlockValue(container, "ntfy.message", "Disk usage at 90%");
expectBlockValue(container, "ntfy.priority", "ntfy.high");
});
it("shows placeholder for title when message has no title set", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: null,
message: "Simple notification",
priority: 3,
time: 1700000000,
tags: [],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "ntfy.title", "ntfy.none");
expectBlockValue(container, "ntfy.message", "Simple notification");
expectBlockValue(container, "ntfy.priority", "ntfy.default");
});
it("renders no messages state", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: null,
message: null,
priority: 3,
time: null,
tags: [],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "ntfy.title", "ntfy.none");
expectBlockValue(container, "ntfy.message", "ntfy.none");
expectBlockValue(container, "ntfy.lastReceived", "ntfy.none");
});
it("renders error when API fails", () => {
useWidgetAPI.mockImplementation(() => ({
data: undefined,
error: { message: "Request failed" },
}));
renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("Request failed")).toBeInTheDocument();
});
it("renders optional tags field when included", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Alert",
message: "Test",
priority: 5,
time: 1700000000,
tags: ["warning", "skull"],
},
error: undefined,
}));
const service = {
widget: {
type: "ntfy",
url: "https://ntfy.example.com",
topic: "alerts",
fields: ["title", "priority", "lastReceived", "tags"],
},
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expectBlockValue(container, "ntfy.tags", "warning, skull");
expectBlockValue(container, "ntfy.priority", "ntfy.urgent");
});
it("caps visible blocks at 4 when more than 4 fields are configured", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Alert",
message: "Body",
priority: 3,
time: 1700000000,
tags: ["a"],
},
error: undefined,
}));
const service = {
widget: {
type: "ntfy",
url: "https://ntfy.example.com",
topic: "alerts",
fields: ["title", "message", "priority", "lastReceived", "tags"],
},
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
});
it("falls back to default priority label when priority is out of range", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Alert",
message: "Body",
priority: 99,
time: 1700000000,
tags: [],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "ntfy.priority", "ntfy.default");
});
it("renders optional message field when included", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Disk Alert",
message: "Disk usage at 90%",
priority: 4,
time: 1700000000,
tags: [],
},
error: undefined,
}));
const service = {
widget: {
type: "ntfy",
url: "https://ntfy.example.com",
topic: "alerts",
fields: ["title", "priority", "message"],
},
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expectBlockValue(container, "ntfy.title", "Disk Alert");
expectBlockValue(container, "ntfy.message", "Disk usage at 90%");
});
});

View File

@@ -0,0 +1,14 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
messages: {
endpoint: "{topic}/json?poll=1&since=latest",
},
},
};
export default widget;

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("ntfy widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -1,12 +1,40 @@
import cache from "memory-cache";
import getServiceWidget from "utils/config/service-helpers"; import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger"; import createLogger from "utils/logger";
import { httpProxy } from "utils/proxy/http"; import { httpProxy } from "utils/proxy/http";
const proxyName = "omadaProxyHandler"; const proxyName = "omadaProxyHandler";
const sessionCacheKey = `${proxyName}__session`;
const logger = createLogger(proxyName); const logger = createLogger(proxyName);
async function login(loginUrl, username, password, controllerVersionMajor) { function getSessionCacheId(group, service, index) {
return [sessionCacheKey, group, service, index ?? "0"].join(".");
}
function shouldRetryWithFreshSession(status, responseData, attempt, usedCachedSession) {
return attempt === 0 && usedCachedSession && (status === 401 || status === 403 || responseData?.errorCode > 0);
}
function getCookieHeader(responseHeaders) {
const setCookie = responseHeaders?.["set-cookie"];
if (!setCookie) return null;
const cookies = new Map();
(Array.isArray(setCookie) ? setCookie : [setCookie]).forEach((cookie) => {
const cookiePair = cookie.split(";")[0];
if (!cookiePair) return;
const separatorIndex = cookiePair.indexOf("=");
const cookieName = separatorIndex === -1 ? cookiePair : cookiePair.slice(0, separatorIndex);
cookies.set(cookieName, cookiePair);
});
return cookies.size > 0 ? Array.from(cookies.values()).join("; ") : null;
}
async function login(loginUrl, username, password, controllerVersionMajor, sessionCacheId) {
const params = { const params = {
username, username,
password, password,
@@ -20,7 +48,7 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
}; };
} }
const [status, contentType, data] = await httpProxy(loginUrl, { const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
method: "POST", method: "POST",
body: JSON.stringify(params), body: JSON.stringify(params),
headers: { headers: {
@@ -28,7 +56,20 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
}, },
}); });
return [status, JSON.parse(data.toString())]; const loginResponseData = JSON.parse(data.toString());
if (status === 200 && loginResponseData.errorCode === 0) {
cache.put(
sessionCacheId,
{
token: loginResponseData.result.token,
cookieHeader: getCookieHeader(responseHeaders),
},
55 * 60 * 1000, // Cache session for 55 minutes
);
}
return [status, loginResponseData];
} }
export default async function omadaProxyHandler(req, res) { export default async function omadaProxyHandler(req, res) {
@@ -86,182 +127,247 @@ export default async function omadaProxyHandler(req, res) {
break; break;
} }
const [loginStatus, loginResponseData] = await login( const sessionCacheId = getSessionCacheId(group, service, index);
loginUrl, let session = cache.get(sessionCacheId);
widget.username,
widget.password,
controllerVersionMajor,
);
if (loginStatus !== 200 || loginResponseData.errorCode > 0) { for (let attempt = 0; attempt < 2; attempt += 1) {
return res try {
.status(loginStatus) const usedCachedSession = Boolean(session);
.json({ error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData } });
}
const { token } = loginResponseData.result; if (!session) {
const [loginStatus, loginResponseData] = await login(
loginUrl,
widget.username,
widget.password,
controllerVersionMajor,
sessionCacheId,
);
let sitesUrl; if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
let body = {}; return res.status(loginStatus).json({
let params = { token }; error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData },
let headers = { "Csrf-Token": token }; });
let method = "GET"; }
switch (controllerVersionMajor) { session = cache.get(sessionCacheId);
case 3: }
sitesUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
body = {
method: "getUserSites",
params: {
userName: widget.username,
},
};
method = "POST";
break;
case 4:
sitesUrl = `${url}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=1000`;
break;
case 5:
case 6:
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=1000`;
break;
default:
break;
}
[status, contentType, data] = await httpProxy(sitesUrl, { const { token, cookieHeader } = session;
method,
params,
body: JSON.stringify(body),
headers,
});
const sitesResponseData = JSON.parse(data); let sitesUrl;
let body = {};
let params = { token };
let headers = { "Csrf-Token": token };
if (cookieHeader) {
headers.Cookie = cookieHeader;
}
let method = "GET";
if (status !== 200 || sitesResponseData.errorCode > 0) { switch (controllerVersionMajor) {
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`); case 3:
return res sitesUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
.status(status) body = {
.json({ error: { message: "Error getting sites list", url, data: sitesResponseData } }); method: "getUserSites",
} params: {
userName: widget.username,
},
};
headers = { "Content-Type": "application/json" };
if (cookieHeader) {
headers.Cookie = cookieHeader;
}
method = "POST";
break;
case 4:
sitesUrl = `${url}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=1000`;
break;
case 5:
case 6:
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=1000`;
break;
default:
break;
}
const site = [status, contentType, data] = await httpProxy(sitesUrl, {
controllerVersionMajor === 3 method,
? sitesResponseData.result.siteList.find((s) => s.name === widget.site) params,
: sitesResponseData.result.data.find((s) => s.name === widget.site); body: JSON.stringify(body),
headers: { ...headers },
if (!site) {
return res.status(status).json({ error: { message: `Site ${widget.site} is not found`, url: sitesUrl, data } });
}
let siteResponseData;
let connectedAp;
let activeUser;
let connectedSwitches;
let connectedGateways;
let alerts;
if (controllerVersionMajor === 3) {
// Omada v3 controller requires switching site
const switchUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
method = "POST";
body = {
method: "switchSite",
params: {
siteName: site.siteName,
userName: widget.username,
},
};
headers = { "Content-Type": "application/json" };
params = { token };
[status, contentType, data] = await httpProxy(switchUrl, {
method,
params,
body: JSON.stringify(body),
headers,
});
const switchResponseData = JSON.parse(data);
if (status !== 200 || switchResponseData.errorCode > 0) {
logger.error(`HTTP ${status} getting sites list: ${data}`);
return res.status(status).json({ error: { message: "Error switching site", url: switchUrl, data } });
}
const statsUrl = `${url}/web/v1/controller?getGlobalStat=&token=${token}`;
[status, contentType, data] = await httpProxy(statsUrl, {
method,
params,
body: JSON.stringify({
method: "getGlobalStat",
}),
headers,
});
siteResponseData = JSON.parse(data);
if (status !== 200 || siteResponseData.errorCode > 0) {
return res.status(status).json({ error: { message: "Error getting stats", url: statsUrl, data } });
}
connectedAp = siteResponseData.result.connectedAp;
activeUser = siteResponseData.result.activeUser;
alerts = siteResponseData.result.alerts;
} else if ([4, 5, 6].includes(controllerVersionMajor)) {
const siteName = controllerVersionMajor > 4 ? site.id : site.key;
const siteStatsUrl =
controllerVersionMajor === 4
? `${url}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(siteStatsUrl, {
headers: {
"Csrf-Token": token,
},
});
siteResponseData = JSON.parse(data);
if (status !== 200 || siteResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
return res.status(status === 200 ? 500 : status).json({
error: {
message: "Error getting stats",
url: siteStatsUrl,
data: siteResponseData,
},
}); });
const sitesResponseData = JSON.parse(data);
if (status !== 200 || sitesResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
if (shouldRetryWithFreshSession(status, sitesResponseData, attempt, usedCachedSession)) {
cache.del(sessionCacheId);
session = null;
continue;
}
return res
.status(status)
.json({ error: { message: "Error getting sites list", url, data: sitesResponseData } });
}
const site =
controllerVersionMajor === 3
? sitesResponseData.result.siteList.find((s) => s.name === widget.site)
: sitesResponseData.result.data.find((s) => s.name === widget.site);
if (!site) {
return res
.status(status)
.json({ error: { message: `Site ${widget.site} is not found`, url: sitesUrl, data } });
}
let siteResponseData;
let connectedAp;
let activeUser;
let connectedSwitches;
let connectedGateways;
let alerts;
if (controllerVersionMajor === 3) {
// Omada v3 controller requires switching site
const switchUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
method = "POST";
body = {
method: "switchSite",
params: {
siteName: site.siteName,
userName: widget.username,
},
};
headers = { "Content-Type": "application/json" };
if (cookieHeader) {
headers.Cookie = cookieHeader;
}
params = { token };
[status, contentType, data] = await httpProxy(switchUrl, {
method,
params,
body: JSON.stringify(body),
headers: { ...headers },
});
const switchResponseData = JSON.parse(data);
if (status !== 200 || switchResponseData.errorCode > 0) {
logger.error(`HTTP ${status} getting sites list: ${data}`);
if (shouldRetryWithFreshSession(status, switchResponseData, attempt, usedCachedSession)) {
cache.del(sessionCacheId);
session = null;
continue;
}
return res.status(status).json({ error: { message: "Error switching site", url: switchUrl, data } });
}
const statsUrl = `${url}/web/v1/controller?getGlobalStat=&token=${token}`;
[status, contentType, data] = await httpProxy(statsUrl, {
method,
params,
body: JSON.stringify({
method: "getGlobalStat",
}),
headers: { ...headers },
});
siteResponseData = JSON.parse(data);
if (status !== 200 || siteResponseData.errorCode > 0) {
if (shouldRetryWithFreshSession(status, siteResponseData, attempt, usedCachedSession)) {
cache.del(sessionCacheId);
session = null;
continue;
}
return res.status(status).json({ error: { message: "Error getting stats", url: statsUrl, data } });
}
connectedAp = siteResponseData.result.connectedAp;
activeUser = siteResponseData.result.activeUser;
alerts = siteResponseData.result.alerts;
} else if ([4, 5, 6].includes(controllerVersionMajor)) {
const siteName = controllerVersionMajor > 4 ? site.id : site.key;
const siteStatsUrl =
controllerVersionMajor === 4
? `${url}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(siteStatsUrl, {
headers: { ...headers },
});
siteResponseData = JSON.parse(data);
if (status !== 200 || siteResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
if (shouldRetryWithFreshSession(status, siteResponseData, attempt, usedCachedSession)) {
cache.del(sessionCacheId);
session = null;
continue;
}
return res.status(status === 200 ? 500 : status).json({
error: {
message: "Error getting stats",
url: siteStatsUrl,
data: siteResponseData,
},
});
}
const alertUrl =
controllerVersionMajor === 4
? `${url}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(alertUrl, {
headers: { ...headers },
});
const alertResponseData = JSON.parse(data);
if (status !== 200 || alertResponseData.errorCode > 0) {
if (shouldRetryWithFreshSession(status, alertResponseData, attempt, usedCachedSession)) {
cache.del(sessionCacheId);
session = null;
continue;
}
return res.status(status === 200 ? 500 : status).json({
error: {
message: "Error getting alerts",
url: alertUrl,
data: alertResponseData,
},
});
}
activeUser = siteResponseData.result.totalClientNum;
connectedAp = siteResponseData.result.connectedApNum;
connectedGateways = siteResponseData.result.connectedGatewayNum;
connectedSwitches = siteResponseData.result.connectedSwitchNum;
alerts = alertResponseData.result.alertNum;
}
return res.send(
JSON.stringify({
connectedAp,
activeUser,
alerts,
connectedGateways,
connectedSwitches,
}),
);
} catch (error) {
if (error instanceof SyntaxError && attempt === 0) {
cache.del(sessionCacheId);
session = null;
continue;
}
throw error;
} }
const alertUrl =
controllerVersionMajor === 4
? `${url}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(alertUrl, {
headers: {
"Csrf-Token": token,
},
});
const alertResponseData = JSON.parse(data);
activeUser = siteResponseData.result.totalClientNum;
connectedAp = siteResponseData.result.connectedApNum;
connectedGateways = siteResponseData.result.connectedGatewayNum;
connectedSwitches = siteResponseData.result.connectedSwitchNum;
alerts = alertResponseData.result.alertNum;
} }
return res.send(
JSON.stringify({
connectedAp,
activeUser,
alerts,
connectedGateways,
connectedSwitches,
}),
);
} }
} }

View File

@@ -2,14 +2,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res"; import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({ const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
httpProxy: vi.fn(), const store = new Map();
getServiceWidget: vi.fn(),
logger: { return {
debug: vi.fn(), httpProxy: vi.fn(),
error: vi.fn(), getServiceWidget: vi.fn(),
}, cache: {
})); get: vi.fn((k) => store.get(k)),
put: vi.fn((k, v) => store.set(k, v)),
del: vi.fn((k) => store.delete(k)),
_reset: () => store.clear(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("utils/logger", () => ({ vi.mock("utils/logger", () => ({
default: () => logger, default: () => logger,
@@ -20,15 +30,19 @@ vi.mock("utils/config/service-helpers", () => ({
vi.mock("utils/proxy/http", () => ({ vi.mock("utils/proxy/http", () => ({
httpProxy, httpProxy,
})); }));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
import omadaProxyHandler from "./proxy"; import omadaProxyHandler from "./proxy";
describe("widgets/omada/proxy", () => { describe("widgets/omada/proxy", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Clear one-off implementations between tests (some branches return early).
httpProxy.mockReset(); httpProxy.mockReset();
getServiceWidget.mockReset(); getServiceWidget.mockReset();
cache._reset();
}); });
it("fetches controller info, logs in, selects site, and returns overview stats (v4)", async () => { it("fetches controller info, logs in, selects site, and returns overview stats (v4)", async () => {
@@ -51,6 +65,7 @@ describe("widgets/omada/proxy", () => {
200, 200,
"application/json", "application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
]) ])
// sites list // sites list
.mockResolvedValueOnce([ .mockResolvedValueOnce([
@@ -91,6 +106,12 @@ describe("widgets/omada/proxy", () => {
connectedSwitches: 3, connectedSwitches: 3,
}), }),
); );
expect(httpProxy.mock.calls[2][1]).toMatchObject({
headers: {
"Csrf-Token": "t",
Cookie: "TPOMADA_SESSIONID=sid",
},
});
}); });
it("returns an error when controller info cannot be retrieved", async () => { it("returns an error when controller info cannot be retrieved", async () => {
@@ -169,6 +190,7 @@ describe("widgets/omada/proxy", () => {
200, 200,
"application/json", "application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
]) ])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 2, msg: "bad" })]); .mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 2, msg: "bad" })]);
@@ -195,6 +217,7 @@ describe("widgets/omada/proxy", () => {
200, 200,
"application/json", "application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
200, 200,
@@ -222,6 +245,7 @@ describe("widgets/omada/proxy", () => {
200, 200,
"application/json", "application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
]) ])
// getUserSites // getUserSites
.mockResolvedValueOnce([ .mockResolvedValueOnce([
@@ -271,6 +295,7 @@ describe("widgets/omada/proxy", () => {
200, 200,
"application/json", "application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
200, 200,
@@ -301,6 +326,7 @@ describe("widgets/omada/proxy", () => {
200, 200,
"application/json", "application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })), Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
200, 200,
@@ -324,4 +350,414 @@ describe("widgets/omada/proxy", () => {
}, },
}); });
}); });
it("reuses a cached Omada session across polls", async () => {
getServiceWidget.mockResolvedValue({
url: "http://omada",
username: "u",
password: "p",
site: "Default",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
await omadaProxyHandler(req, createMockRes());
await omadaProxyHandler(req, createMockRes());
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
expect(loginCalls).toHaveLength(1);
expect(httpProxy.mock.calls[6][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid");
});
it("does not reuse a cached session across different widget identities", async () => {
getServiceWidget.mockResolvedValue({
url: "http://omada",
username: "u",
password: "p",
site: "Default",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t1" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid1; Path=/; HttpOnly"] },
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t2" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid2; Path=/; HttpOnly"] },
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
await omadaProxyHandler({ query: { group: "g1", service: "svc", index: "0" } }, createMockRes());
await omadaProxyHandler({ query: { group: "g2", service: "svc", index: "0" } }, createMockRes());
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
expect(loginCalls).toHaveLength(2);
expect(httpProxy.mock.calls[2][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid1");
expect(httpProxy.mock.calls[7][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid2");
});
it("keeps the latest value when Omada sets the same cookie more than once during login", async () => {
getServiceWidget.mockResolvedValue({
url: "http://omada",
username: "u",
password: "p",
site: "Default",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{
"set-cookie": [
"TPOMADA_SESSIONID=deleteMe; Path=/; Max-Age=0",
"TPOMADA_SESSIONID=sid; Path=/; HttpOnly",
"rememberMe=deleteMe; Path=/; Max-Age=0",
],
},
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(httpProxy.mock.calls[2][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid; rememberMe=deleteMe");
expect(res.body).toBe(
JSON.stringify({
connectedAp: 2,
activeUser: 10,
alerts: 4,
connectedGateways: 1,
connectedSwitches: 3,
}),
);
});
it("does not reuse a mutated content-length header on later GET requests", async () => {
getServiceWidget.mockResolvedValue({
url: "http://omada",
username: "u",
password: "p",
site: "Default",
});
const responses = [
[200, "application/json", JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } })],
[
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
],
[
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
],
[
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
],
[200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })],
];
httpProxy.mockImplementation(async (_url, params = {}) => {
if (params.body) {
params.headers["content-length"] = Buffer.byteLength(params.body);
}
return responses.shift();
});
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(httpProxy.mock.calls[2][1].headers["content-length"]).toBe(2);
expect(httpProxy.mock.calls[3][1].headers["content-length"]).toBeUndefined();
expect(httpProxy.mock.calls[4][1].headers["content-length"]).toBeUndefined();
expect(res.body).toBe(
JSON.stringify({
connectedAp: 2,
activeUser: 10,
alerts: 4,
connectedGateways: 1,
connectedSwitches: 3,
}),
);
});
it("clears the cached session and re-authenticates when an authenticated response is not JSON", async () => {
cache.put("omadaProxyHandler__session.g.svc.0", {
token: "stale-token",
cookieHeader: "TPOMADA_SESSIONID=stale",
});
getServiceWidget.mockResolvedValue({
url: "http://omada",
username: "u",
password: "p",
site: "Default",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([200, "text/html", Buffer.from("<!DOCTYPE html>login")])
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "fresh-token" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=fresh; Path=/; HttpOnly"] },
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(cache.del).toHaveBeenCalledWith("omadaProxyHandler__session.g.svc.0");
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
expect(loginCalls).toHaveLength(1);
expect(res.body).toBe(
JSON.stringify({
connectedAp: 2,
activeUser: 10,
alerts: 4,
connectedGateways: 1,
connectedSwitches: 3,
}),
);
});
it("clears the cached session and re-authenticates when a cached session returns a JSON auth error", async () => {
cache.put("omadaProxyHandler__session.g.svc.0", {
token: "stale-token",
cookieHeader: "TPOMADA_SESSIONID=stale",
});
getServiceWidget.mockResolvedValue({
url: "http://omada",
username: "u",
password: "p",
site: "Default",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 1, msg: "Login required" })])
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "fresh-token" } })),
{ "set-cookie": ["TPOMADA_SESSIONID=fresh; Path=/; HttpOnly"] },
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(cache.del).toHaveBeenCalledWith("omadaProxyHandler__session.g.svc.0");
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
expect(loginCalls).toHaveLength(1);
expect(res.body).toBe(
JSON.stringify({
connectedAp: 2,
activeUser: 10,
alerts: 4,
connectedGateways: 1,
connectedSwitches: 3,
}),
);
});
}); });

View File

@@ -9,7 +9,7 @@ const widget = {
endpoint: "org/{org}/sites", endpoint: "org/{org}/sites",
}, },
resources: { resources: {
endpoint: "org/{org}/resources", endpoint: "org/{org}/resources?pageSize=200",
}, },
}, },
}; };

View File

@@ -8,9 +8,14 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const taskQueryParams = {
errors: true,
limit: 100,
since: Math.floor(Date.now() / 1000) - 24 * 60 * 60,
};
const { data: datastoreData, error: datastoreError } = useWidgetAPI(widget, "status/datastore-usage"); const { data: datastoreData, error: datastoreError } = useWidgetAPI(widget, "status/datastore-usage");
const { data: tasksData, error: tasksError } = useWidgetAPI(widget, "nodes/localhost/tasks"); const { data: tasksData, error: tasksError } = useWidgetAPI(widget, "nodes/localhost/tasks", taskQueryParams);
const { data: hostData, error: hostError } = useWidgetAPI(widget, "nodes/localhost/status"); const { data: hostData, error: hostError } = useWidgetAPI(widget, "nodes/localhost/status");
if (datastoreError || tasksError || hostError) { if (datastoreError || tasksError || hostError) {

View File

@@ -74,4 +74,23 @@ describe("widgets/proxmoxbackupserver/component", () => {
expect(screen.getByText("25")).toBeInTheDocument(); // memory usage expect(screen.getByText("25")).toBeInTheDocument(); // memory usage
expect(screen.getByText("99+")).toBeInTheDocument(); expect(screen.getByText("99+")).toBeInTheDocument();
}); });
it("requests failed tasks with a 24 hour since filter in epoch seconds", () => {
vi.spyOn(Date, "now").mockReturnValue(1_776_519_498_000);
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: undefined })
.mockReturnValueOnce({ data: undefined, error: undefined })
.mockReturnValueOnce({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { type: "proxmoxbackupserver" } }} />, {
settings: { hideErrors: false },
});
expect(useWidgetAPI).toHaveBeenNthCalledWith(2, { type: "proxmoxbackupserver" }, "nodes/localhost/tasks", {
errors: true,
limit: 100,
since: 1_776_433_098,
});
});
}); });

View File

@@ -1,7 +1,5 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const since = Date.now() - 24 * 60 * 60 * 1000;
const widget = { const widget = {
api: "{url}/api2/json/{endpoint}", api: "{url}/api2/json/{endpoint}",
proxyHandler: credentialedProxyHandler, proxyHandler: credentialedProxyHandler,
@@ -11,7 +9,7 @@ const widget = {
endpoint: "status/datastore-usage", endpoint: "status/datastore-usage",
}, },
"nodes/localhost/tasks": { "nodes/localhost/tasks": {
endpoint: `nodes/localhost/tasks?errors=true&limit=100&since=${since}`, endpoint: "nodes/localhost/tasks",
}, },
"nodes/localhost/status": { "nodes/localhost/status": {
endpoint: "nodes/localhost/status", endpoint: "nodes/localhost/status",

View File

@@ -45,17 +45,20 @@ async function fetchFromPyloadAPI(url, sessionId, params, service) {
return [status, returnData, responseHeaders]; return [status, returnData, responseHeaders];
} }
async function fetchFromPyloadAPIBasic(url, params, username, password) { async function fetchFromPyloadAPIWithCredentials(url, params, username, password, key) {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
const isGetRequest = !params || Object.keys(params).length === 0; const isGetRequest = !params || Object.keys(params).length === 0;
const options = { const options = {
method: isGetRequest ? "GET" : "POST", method: isGetRequest ? "GET" : "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
},
}; };
if (key) {
options.headers = { "X-API-Key": key };
} else {
options.headers = { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` };
}
if (isGetRequest) { if (isGetRequest) {
if (params) { if (params) {
Object.keys(params).forEach((key) => parsedUrl.searchParams.append(key, params[key])); Object.keys(params).forEach((key) => parsedUrl.searchParams.append(key, params[key]));
@@ -106,10 +109,16 @@ export default async function pyloadProxyHandler(req, res, map = {}) {
const url = new URL(formatApiCall(apiTemplate, { endpoint, ...widget })); const url = new URL(formatApiCall(apiTemplate, { endpoint, ...widget }));
const ngUrl = ngEndpoint ? new URL(formatApiCall(apiTemplate, { endpoint: ngEndpoint, ...widget })) : url; const ngUrl = ngEndpoint ? new URL(formatApiCall(apiTemplate, { endpoint: ngEndpoint, ...widget })) : url;
const loginUrl = `${widget.url}/api/login`; const loginUrl = `${widget.url}/api/login`;
const hasCredentials = widget.username && widget.password; const hasCredentials = widget.key || (widget.username && widget.password);
if (hasCredentials) { if (hasCredentials) {
const [status, data] = await fetchFromPyloadAPIBasic(ngUrl, null, widget.username, widget.password); const [status, data] = await fetchFromPyloadAPIWithCredentials(
ngUrl,
null,
widget.username,
widget.password,
widget.key,
);
if (status === 200 && !data?.error) { if (status === 200 && !data?.error) {
cache.put(`${isNgCacheKey}.${service}`, true); cache.put(`${isNgCacheKey}.${service}`, true);
@@ -117,9 +126,7 @@ export default async function pyloadProxyHandler(req, res, map = {}) {
} }
if (status === 401) { if (status === 401) {
return res return res.status(status).send({ error: "Invalid credentials communicating with Pyload API", data });
.status(status)
.send({ error: { message: "Invalid credentials communicating with Pyload API", data } });
} }
} }

View File

@@ -75,6 +75,46 @@ describe("widgets/pyload/proxy", () => {
expect(res.body).toEqual({ ok: true }); expect(res.body).toEqual({ ok: true });
}); });
it("uses api key auth and returns data", async () => {
getServiceWidget.mockResolvedValue({
type: "pyload",
url: "http://pyload",
key: "apikey",
});
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ ok: true })), {}]);
const req = { query: { group: "g", service: "svc", endpoint: "status", index: "0" } };
const res = createMockRes();
await pyloadProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(1);
expect(httpProxy.mock.calls[0][1].headers["X-API-Key"]).toBe("apikey");
expect(cache.put).toHaveBeenCalledWith("pyloadProxyHandler__isNg.svc", true);
expect(res.body).toEqual({ ok: true });
});
it("returns error if login fails", async () => {
getServiceWidget.mockResolvedValue({
type: "pyload",
url: "http://pyload",
username: "u",
password: "p",
});
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from(JSON.stringify({ error: "bad" })), {}]);
const req = { query: { group: "g", service: "svc", endpoint: "status", index: "0" } };
const res = createMockRes();
await pyloadProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBe(401);
expect(res.body).toMatchObject({ error: "Invalid credentials communicating with Pyload API" });
});
it("retries after 403 by clearing session and logging in again", async () => { it("retries after 403 by clearing session and logging in again", async () => {
getServiceWidget.mockResolvedValue({ getServiceWidget.mockResolvedValue({
type: "pyload", type: "pyload",

View File

@@ -9,13 +9,13 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: statsData, error: statsError } = useWidgetAPI(widget, "device"); const { data: tailscaleData, error: tailscaleError } = useWidgetAPI(widget, "device");
if (statsError || statsData?.message) { if (tailscaleError || tailscaleData?.message) {
return <Container service={service} error={statsError ?? statsData} />; return <Container service={service} error={tailscaleError ?? tailscaleData} />;
} }
if (!statsData) { if (!tailscaleData) {
return ( return (
<Container service={service}> <Container service={service}>
<Block label="tailscale.address" /> <Block label="tailscale.address" />
@@ -25,12 +25,29 @@ export default function Component({ service }) {
); );
} }
const MAX_ALLOWED_FIELDS = 4;
if (widget.fields?.length == 0 || !widget.fields) {
widget.fields = ["address", "last_seen", "expires"];
} else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
}
const { const {
addresses: [address], addresses: [address],
keyExpiryDisabled, keyExpiryDisabled,
lastSeen, lastSeen,
expires, expires,
} = statsData; user,
hostname,
name,
clientVersion,
os,
created,
authorized,
isExternal,
updateAvailable,
tags,
} = tailscaleData;
const now = new Date(); const now = new Date();
const compareDifferenceInTwoDates = (priorDate, futureDate) => { const compareDifferenceInTwoDates = (priorDate, futureDate) => {
@@ -62,11 +79,28 @@ export default function Component({ service }) {
return compareDifferenceInTwoDates(now, date); return compareDifferenceInTwoDates(now, date);
}; };
const getBooleanAsString = (value) => {
return value ? t("tailscale.true") : t("tailscale.false");
};
const clientVersionString = clientVersion ? clientVersion.toString() : "-";
const tagsString = tags && Array.isArray(tags) ? tags.join(", ") : "-";
return ( return (
<Container service={service}> <Container service={service}>
<Block label="tailscale.address" value={address} /> <Block label="tailscale.address" value={address} />
<Block label="tailscale.last_seen" value={getLastSeen()} /> <Block label="tailscale.last_seen" value={getLastSeen()} />
<Block label="tailscale.expires" value={getExpiry()} /> <Block label="tailscale.expires" value={getExpiry()} />
<Block label="tailscale.user" value={user} />
<Block label="tailscale.hostname" value={hostname} />
<Block label="tailscale.name" value={name} />
<Block label="tailscale.client_version" value={clientVersionString} />
<Block label="tailscale.os" value={os} />
<Block label="tailscale.created" value={created} />
<Block label="tailscale.authorized" value={getBooleanAsString(authorized)} />
<Block label="tailscale.is_external" value={getBooleanAsString(isExternal)} />
<Block label="tailscale.update_available" value={getBooleanAsString(updateAvailable)} />
<Block label="tailscale.tags" value={tagsString} />
</Container> </Container>
); );
} }

View File

@@ -22,6 +22,23 @@ describe("widgets/tailscale/component", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
const fullData = {
addresses: ["127.0.0.1"],
keyExpiryDisabled: false,
expires: "2020-06-01T00:00:00Z",
lastSeen: "2019-12-31T23:55:00Z",
user: "fin@example.com",
hostname: "localhost",
name: "localhost.tail1234.ts.net",
clientVersion: "1.1.0",
os: "linux",
created: "2019-06-01T00:00:00Z",
authorized: true,
isExternal: false,
updateAvailable: true,
tags: ["server", "prod"],
};
it("renders placeholders while loading", () => { it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
@@ -35,14 +52,108 @@ describe("widgets/tailscale/component", () => {
expect(screen.getByText("tailscale.expires")).toBeInTheDocument(); expect(screen.getByText("tailscale.expires")).toBeInTheDocument();
}); });
it("renders address and expiry/last-seen strings when loaded", () => { describe("fields group: address, last_seen, expires, user", () => {
it("renders only the specified 4 fields", () => {
useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "tailscale", fields: ["address", "last_seen", "expires", "user"] } }} />,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "tailscale.address", "127.0.0.1");
expectBlockValue(container, "tailscale.last_seen", "tailscale.ago");
expectBlockValue(container, "tailscale.expires", "tailscale.weeks");
expectBlockValue(container, "tailscale.user", "fin@example.com");
});
});
describe("fields group: hostname, name, client_version, os", () => {
it("renders only the specified 4 fields", () => {
useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "tailscale", fields: ["hostname", "name", "client_version", "os"] } }} />,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "tailscale.hostname", "localhost");
expectBlockValue(container, "tailscale.name", "localhost.tail1234.ts.net");
expectBlockValue(container, "tailscale.client_version", "1.1.0");
expectBlockValue(container, "tailscale.os", "linux");
});
});
describe("fields group: created, authorized, is_external, update_available", () => {
it("renders only the specified 4 fields", () => {
useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
const { container } = renderWithProviders(
<Component
service={{
widget: { type: "tailscale", fields: ["created", "authorized", "is_external", "update_available"] },
}}
/>,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "tailscale.created", "2019-06-01T00:00:00Z");
expectBlockValue(container, "tailscale.authorized", "tailscale.true");
expectBlockValue(container, "tailscale.is_external", "tailscale.false");
expectBlockValue(container, "tailscale.update_available", "tailscale.true");
});
});
describe("fields group: tags with defaults", () => {
it("renders tags alongside default fields", () => {
useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "tailscale", fields: ["address", "last_seen", "expires", "tags"] } }} />,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "tailscale.address", "127.0.0.1");
expectBlockValue(container, "tailscale.tags", "server, prod");
});
});
describe("fields truncation", () => {
it("truncates to 4 fields when more than 4 are specified", () => {
useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
const { container } = renderWithProviders(
<Component
service={{ widget: { type: "tailscale", fields: ["address", "last_seen", "expires", "user", "hostname"] } }}
/>,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(findServiceBlockByLabel(container, "tailscale.hostname")).toBeFalsy();
});
it("defaults to address, last_seen, expires when fields is empty", () => {
useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "tailscale", fields: [] } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expectBlockValue(container, "tailscale.address", "127.0.0.1");
expectBlockValue(container, "tailscale.last_seen", "tailscale.ago");
expectBlockValue(container, "tailscale.expires", "tailscale.weeks");
});
});
it("renders never for expires if key expiry is disabled", () => {
useWidgetAPI.mockReturnValue({ useWidgetAPI.mockReturnValue({
data: { data: { ...fullData, keyExpiryDisabled: true },
addresses: ["100.64.0.1"],
keyExpiryDisabled: true,
lastSeen: "2019-12-31T23:00:00Z",
expires: "2021-01-01T00:00:00Z",
},
error: undefined, error: undefined,
}); });
@@ -50,8 +161,17 @@ describe("widgets/tailscale/component", () => {
settings: { hideErrors: false }, settings: { hideErrors: false },
}); });
expectBlockValue(container, "tailscale.address", "100.64.0.1");
expect(findServiceBlockByLabel(container, "tailscale.last_seen")?.textContent).toContain("tailscale.ago");
expectBlockValue(container, "tailscale.expires", "tailscale.never"); expectBlockValue(container, "tailscale.expires", "tailscale.never");
}); });
it("renders error message when API returns an error", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "API error" } });
const { container } = renderWithProviders(<Component service={{ widget: { type: "tailscale" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(0);
expect(container.textContent).toContain("API error");
});
}); });

View File

@@ -14,6 +14,7 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const params = { const params = {
node: widget.node ?? "",
type: widget.range ?? "LastHour", type: widget.range ?? "LastHour",
}; };

View File

@@ -8,7 +8,7 @@ const widget = {
stats: { stats: {
endpoint: "dashboard/stats/get", endpoint: "dashboard/stats/get",
validate: ["response", "status"], validate: ["response", "status"],
params: ["type"], params: ["node", "type"],
map: (data) => asJson(data).response?.stats, map: (data) => asJson(data).response?.stats,
}, },
}, },

View File

@@ -25,9 +25,9 @@ export default function Component({ service }) {
); );
} }
const { data: storage } = storageData; const { pools } = storageData;
if (!storage) { if (!Array.isArray(pools) || pools.length === 0) {
return ( return (
<Container service={service}> <Container service={service}>
<Block value={t("unifi_drive.no_data")} /> <Block value={t("unifi_drive.no_data")} />
@@ -35,12 +35,13 @@ export default function Component({ service }) {
); );
} }
const { totalQuota, usage, status } = storage; const totalBytes = pools.reduce((sum, p) => sum + (p.capacity ?? 0), 0);
const totalBytes = totalQuota ?? 0; const usedBytes = pools.reduce((sum, p) => sum + (p.usage ?? 0), 0);
const usedBytes = (usage?.system || 0) + (usage?.myDrives || 0) + (usage?.sharedDrives || 0);
const availableBytes = Math.max(0, totalBytes - usedBytes); const availableBytes = Math.max(0, totalBytes - usedBytes);
const status = pools.some((p) => p.status === "degraded") ? "degraded" : pools[0]?.status;
let statusValue = status; let statusValue = status;
if (status === "healthy") statusValue = t("unifi_drive.healthy"); if (status === "fullyOperational" || status === "noDataProtectionYet") statusValue = t("unifi_drive.healthy");
else if (status === "degraded") statusValue = t("unifi_drive.degraded"); else if (status === "degraded") statusValue = t("unifi_drive.degraded");
return ( return (

View File

@@ -39,8 +39,8 @@ describe("widgets/unifi_drive/component", () => {
expect(screen.getAllByText("widget.api_error", { exact: false }).length).toBeGreaterThan(0); expect(screen.getAllByText("widget.api_error", { exact: false }).length).toBeGreaterThan(0);
}); });
it("renders no_data when storage data is missing", () => { it("renders no_data when pools array is empty", () => {
useWidgetAPI.mockReturnValue({ data: { data: null }, error: undefined }); useWidgetAPI.mockReturnValue({ data: { pools: [] }, error: undefined });
const service = { widget: { type: "unifi_drive" } }; const service = { widget: { type: "unifi_drive" } };
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } }); renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
@@ -48,14 +48,19 @@ describe("widgets/unifi_drive/component", () => {
expect(screen.getByText("unifi_drive.no_data")).toBeInTheDocument(); expect(screen.getByText("unifi_drive.no_data")).toBeInTheDocument();
}); });
it("renders storage statistics when data is loaded", () => { it("renders no_data when pools is missing", () => {
useWidgetAPI.mockReturnValue({ data: {}, error: undefined });
const service = { widget: { type: "unifi_drive" } };
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(screen.getByText("unifi_drive.no_data")).toBeInTheDocument();
});
it("renders storage statistics from single pool", () => {
useWidgetAPI.mockReturnValue({ useWidgetAPI.mockReturnValue({
data: { data: {
data: { pools: [{ capacity: 1000000000000, usage: 350000000000, status: "fullyOperational" }],
totalQuota: 1000000000000,
usage: { system: 100000000000, myDrives: 200000000000, sharedDrives: 50000000000 },
status: "healthy",
},
}, },
error: undefined, error: undefined,
}); });
@@ -70,14 +75,34 @@ describe("widgets/unifi_drive/component", () => {
expectBlockValue(container, "widget.status", "unifi_drive.healthy"); expectBlockValue(container, "widget.status", "unifi_drive.healthy");
}); });
it("renders degraded status", () => { it("aggregates storage across multiple pools", () => {
useWidgetAPI.mockReturnValue({ useWidgetAPI.mockReturnValue({
data: { data: {
data: { pools: [
totalQuota: 100, { capacity: 1000000000000, usage: 300000000000, status: "fullyOperational" },
usage: { system: 10, myDrives: 20, sharedDrives: 5 }, { capacity: 500000000000, usage: 100000000000, status: "noDataProtectionYet" },
status: "degraded", ],
}, },
error: undefined,
});
const service = { widget: { type: "unifi_drive" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "resources.total", 1500000000000);
expectBlockValue(container, "resources.used", 400000000000);
expectBlockValue(container, "resources.free", 1100000000000);
expectBlockValue(container, "widget.status", "unifi_drive.healthy");
});
it("renders degraded status when any pool is degraded", () => {
useWidgetAPI.mockReturnValue({
data: {
pools: [
{ capacity: 1000, usage: 400, status: "fullyOperational" },
{ capacity: 500, usage: 100, status: "degraded" },
],
}, },
error: undefined, error: undefined,
}); });
@@ -87,6 +112,37 @@ describe("widgets/unifi_drive/component", () => {
expect(container.querySelectorAll(".service-block")).toHaveLength(4); expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "widget.status", "unifi_drive.degraded"); expectBlockValue(container, "widget.status", "unifi_drive.degraded");
expectBlockValue(container, "resources.free", 65); expectBlockValue(container, "resources.free", 1000);
});
it("renders noDataProtectionYet as healthy", () => {
useWidgetAPI.mockReturnValue({
data: {
pools: [{ capacity: 1000, usage: 200, status: "noDataProtectionYet" }],
},
error: undefined,
});
const service = { widget: { type: "unifi_drive" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expectBlockValue(container, "widget.status", "unifi_drive.healthy");
});
it("handles pools with missing capacity or usage", () => {
useWidgetAPI.mockReturnValue({
data: {
pools: [{ status: "fullyOperational" }],
},
error: undefined,
});
const service = { widget: { type: "unifi_drive" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "resources.total", 0);
expectBlockValue(container, "resources.used", 0);
expectBlockValue(container, "resources.free", 0);
}); });
}); });

View File

@@ -6,7 +6,7 @@ const widget = {
mappings: { mappings: {
storage: { storage: {
endpoint: "v1/systems/storage?type=detail", endpoint: "v2/storage",
}, },
}, },
}; };

View File

@@ -82,6 +82,7 @@ import netdata from "./netdata/widget";
import nextcloud from "./nextcloud/widget"; import nextcloud from "./nextcloud/widget";
import nextdns from "./nextdns/widget"; import nextdns from "./nextdns/widget";
import npm from "./npm/widget"; import npm from "./npm/widget";
import ntfy from "./ntfy/widget";
import nzbget from "./nzbget/widget"; import nzbget from "./nzbget/widget";
import octoprint from "./octoprint/widget"; import octoprint from "./octoprint/widget";
import omada from "./omada/widget"; import omada from "./omada/widget";
@@ -239,6 +240,7 @@ const widgets = {
nextcloud, nextcloud,
nextdns, nextdns,
npm, npm,
ntfy,
nzbget, nzbget,
octoprint, octoprint,
omada, omada,