mirror of
https://github.com/gethomepage/homepage.git
synced 2026-05-18 11:27:33 +08:00
Merge branch 'dev'
This commit is contained in:
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -15,7 +15,7 @@ body:
|
||||
options:
|
||||
- label: I confirm this was discussed, and the maintainers asked that I open an issue.
|
||||
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
|
||||
- type: input
|
||||
id: discussion
|
||||
|
||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@7ca9c452bfe9197d3bb7fa83a4d7e2b0c9ae835d # v2
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
|
||||
with:
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
13
.github/workflows/docker-publish.yml
vendored
13
.github/workflows/docker-publish.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- feature/**
|
||||
- fix/**
|
||||
- dev
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
@@ -52,7 +53,7 @@ jobs:
|
||||
latest=auto
|
||||
|
||||
- name: Next.js build cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: .next/cache
|
||||
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx') }}
|
||||
@@ -60,13 +61,13 @@ jobs:
|
||||
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
@@ -83,7 +84,7 @@ jobs:
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -91,7 +92,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -104,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
10
.github/workflows/docs-publish.yml
vendored
10
.github/workflows/docs-publish.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- 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
|
||||
- name: Test Docs Build
|
||||
run: uv run --frozen zensical build --clean
|
||||
@@ -37,18 +37,18 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
|
||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- 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
|
||||
- name: Build Docs
|
||||
run: uv run --frozen zensical build --clean
|
||||
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
|
||||
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: site
|
||||
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
id: deployment
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -23,13 +23,13 @@ jobs:
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
|
||||
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
2
.github/workflows/pr-quality.yml
vendored
2
.github/workflows/pr-quality.yml
vendored
@@ -13,6 +13,6 @@ jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@a5a4b2440c9de6f65b64f0718a0136a1fdb04f6f # v0
|
||||
- uses: peakoss/anti-slop@57858eead489d08b255fab2af45a506c2ca6eab2 # v0
|
||||
with:
|
||||
max-failures: 4
|
||||
|
||||
6
.github/workflows/release-drafter.yml
vendored
6
.github/workflows/release-drafter.yml
vendored
@@ -26,14 +26,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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:
|
||||
config-name: release-drafter.yml
|
||||
version: ${{ github.event.inputs.version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- 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:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter/autolabeler@ebb69bb56f1b0ebd19897745035726b19bef973e
|
||||
- uses: release-drafter/release-drafter/autolabeler@563bf132657a13ded0b01fcb723c5a58cdd824e2
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
|
||||
6
.github/workflows/repo-maintenance.yml
vendored
6
.github/workflows/repo-maintenance.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
name: 'Close Answered Discussions'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
function sleep(ms) {
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
name: 'Close Outdated Discussions'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
function sleep(ms) {
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
name: 'Close Unsupported Feature Requests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
function sleep(ms) {
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
|
||||
- uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# Run Vitest directly so `--shard` is parsed as an option
|
||||
- run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/lcov.info
|
||||
|
||||
@@ -122,7 +122,7 @@ Use the `gethomepage.dev/pod-selector` selector to specify the pod used for the
|
||||
|
||||
### 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
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
|
||||
@@ -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
|
||||
mem: true # optional, enabled by default, disable by setting to false
|
||||
cputemp: true # disabled by default
|
||||
cpuSensorLabel: Package id # optional additional cputemp sensor label prefix
|
||||
unit: imperial # optional for temp, default is metric
|
||||
uptime: true # disabled by default
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```yaml
|
||||
|
||||
36
docs/widgets/services/ntfy.md
Normal file
36
docs/widgets/services/ntfy.md
Normal 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
|
||||
```
|
||||
@@ -13,4 +13,5 @@ widget:
|
||||
url: http://pyload.host.or.ip:port
|
||||
username: username
|
||||
password: password # only needed if set
|
||||
key: pyloadapikey # only needed if set, takes precedence over username/password
|
||||
```
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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
|
||||
widget:
|
||||
|
||||
@@ -14,6 +14,7 @@ widget:
|
||||
type: technitium
|
||||
url: <url to dns server>
|
||||
key: biglongapitoken
|
||||
node: <node dns name or cluster> # optional, defaults to current node
|
||||
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.
|
||||
|
||||
#### 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` 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`.
|
||||
|
||||
@@ -116,6 +116,7 @@ nav:
|
||||
- widgets/services/nextcloud.md
|
||||
- widgets/services/nextdns.md
|
||||
- widgets/services/nginx-proxy-manager.md
|
||||
- widgets/services/ntfy.md
|
||||
- widgets/services/nzbget.md
|
||||
- widgets/services/octoprint.md
|
||||
- widgets/services/omada.md
|
||||
|
||||
33
package.json
33
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.12.3",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -14,27 +14,27 @@
|
||||
"telemetry": "next telemetry disable"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@kubernetes/client-node": "^1.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dockerode": "^4.0.7",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"dockerode": "^4.0.10",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"gamedig": "^5.3.2",
|
||||
"i18next": "^25.8.0",
|
||||
"i18next": "^25.10.9",
|
||||
"ical.js": "^2.2.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json-rpc-2.0": "^1.7.0",
|
||||
"json-rpc-2.0": "^1.7.1",
|
||||
"luxon": "^3.6.1",
|
||||
"memory-cache": "^0.2.0",
|
||||
"minecraftstatuspinger": "^1.2.2",
|
||||
"next": "^16.1.7",
|
||||
"next": "^16.2.4",
|
||||
"next-i18next": "^15.4.3",
|
||||
"ping": "^0.4.4",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"raw-body": "^3.0.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "^3.1.2",
|
||||
@@ -47,10 +47,10 @@
|
||||
"xml-js": "^1.6.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/compat": "^2.0.3",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.10",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
@@ -74,12 +74,5 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"osx-temperature-sensor": "^1.0.8"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"osx-temperature-sensor",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
734
pnpm-lock.yaml
generated
734
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
18
pnpm-workspace.yaml
Normal file
18
pnpm-workspace.yaml
Normal 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
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Address",
|
||||
"expires": "Expires",
|
||||
"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",
|
||||
"now": "Now",
|
||||
"years": "{{number}}y",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Ago"
|
||||
"ago": "{{value}} Ago",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Warnings",
|
||||
"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": {
|
||||
"events": "Events",
|
||||
"plants": "Plants",
|
||||
|
||||
@@ -11,12 +11,16 @@ import Resource from "../widget/resource";
|
||||
import Resources from "../widget/resources";
|
||||
import WidgetLabel from "../widget/widget_label";
|
||||
|
||||
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
|
||||
const defaultCpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
|
||||
|
||||
function convertToFahrenheit(t) {
|
||||
return (t * 9) / 5 + 32;
|
||||
}
|
||||
|
||||
function getCpuSensorLabels(options) {
|
||||
return [...defaultCpuSensorLabels, options.cpuSensorLabel].filter(Boolean);
|
||||
}
|
||||
|
||||
export default function Widget({ options }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { settings } = useContext(SettingsContext);
|
||||
@@ -38,7 +42,6 @@ export default function Widget({ options }) {
|
||||
<Resources options={options} additionalClassNames="information-widget-glances">
|
||||
{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.cputemp && <Resource icon={FaThermometerHalf} label={t("glances.wait")} percentage="0" />}
|
||||
{options.disk && !Array.isArray(options.disk) && (
|
||||
<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) => (
|
||||
<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.label && <WidgetLabel label={options.label} />}
|
||||
</Resources>
|
||||
@@ -56,6 +60,7 @@ export default function Widget({ options }) {
|
||||
const unit = options.units === "imperial" ? "fahrenheit" : "celsius";
|
||||
let mainTemp = 0;
|
||||
let maxTemp = 80;
|
||||
const cpuSensorLabels = getCpuSensorLabels(options);
|
||||
const cpuSensors = data.sensors?.filter(
|
||||
(s) => cpuSensorLabels.some((label) => s.label.startsWith(label)) && s.type === "temperature_core",
|
||||
);
|
||||
|
||||
@@ -120,6 +120,26 @@ describe("components/widgets/glances", () => {
|
||||
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", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
|
||||
@@ -19,7 +19,10 @@ const tailwindSafelist = [
|
||||
"backdrop-blur-xs",
|
||||
"backdrop-blur-sm",
|
||||
"backdrop-blur-md",
|
||||
"backdrop-blur-lg",
|
||||
"backdrop-blur-xl",
|
||||
"backdrop-blur-2xl",
|
||||
"backdrop-blur-3xl",
|
||||
"backdrop-saturate-0",
|
||||
"backdrop-saturate-50",
|
||||
"backdrop-saturate-100",
|
||||
|
||||
@@ -665,6 +665,7 @@ export function cleanServiceGroups(groups) {
|
||||
if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents;
|
||||
}
|
||||
if (type === "technitium") {
|
||||
if (node !== undefined) widget.node = node;
|
||||
if (range !== undefined) widget.range = range;
|
||||
}
|
||||
if (type === "lubelogger") {
|
||||
|
||||
@@ -7,7 +7,10 @@ export function setCookieHeader(url, params) {
|
||||
const existingCookie = cookieJar.getCookieStringSync(url.toString());
|
||||
if (existingCookie) {
|
||||
params.headers = params.headers ?? {};
|
||||
params.headers[params.cookieHeader ?? "Cookie"] = existingCookie;
|
||||
const cookieHeader = params.cookieHeader ?? "Cookie";
|
||||
if (!params.headers[cookieHeader]) {
|
||||
params.headers[cookieHeader] = existingCookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,4 +42,16 @@ describe("utils/proxy/cookie-jar", () => {
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,12 @@ export default async function credentialedProxyHandler(req, res, map) {
|
||||
} else {
|
||||
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") {
|
||||
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
|
||||
} else if (widget.type === "proxmoxbackupserver") {
|
||||
@@ -152,6 +158,14 @@ export default async function credentialedProxyHandler(req, res, map) {
|
||||
|
||||
if (status >= 400) {
|
||||
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) {
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("widgets/widgets", () => ({
|
||||
paperlessngx: { api: "{url}/api/{endpoint}" },
|
||||
proxmox: { api: "{url}/api2/json/{endpoint}" },
|
||||
truenas: { api: "{url}/api/v2.0/{endpoint}" },
|
||||
ntfy: { api: "{url}/{endpoint}" },
|
||||
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
|
||||
checkmk: { api: "{url}/{endpoint}" },
|
||||
stocks: { api: "{url}/{endpoint}" },
|
||||
@@ -185,6 +186,51 @@ describe("utils/proxy/handlers/credentialed", () => {
|
||||
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([
|
||||
[{ 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));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "esphome", url: "http://x", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
@@ -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 = {}) {
|
||||
const constructedUrl = new URL(url);
|
||||
const disableIpv6 = process.env.HOMEPAGE_PROXY_DISABLE_IPV6 === "true";
|
||||
const agentOptions = {
|
||||
...(disableIpv6 ? { family: 4, autoSelectFamily: false } : { autoSelectFamilyAttemptTimeout: 500 }),
|
||||
lookup: homepageDNSLookupFn(),
|
||||
};
|
||||
|
||||
let request = null;
|
||||
if (constructedUrl.protocol === "https:") {
|
||||
request = httpsRequest(constructedUrl, {
|
||||
agent: new https.Agent({ ...agentOptions, rejectUnauthorized: false }),
|
||||
agent: getAgent(constructedUrl.protocol, disableIpv6),
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
request = httpRequest(constructedUrl, {
|
||||
agent: new http.Agent(agentOptions),
|
||||
agent: getAgent(constructedUrl.protocol, disableIpv6),
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const { state, cache, logger, dns, net, cookieJar } = vi.hoisted(() => ({
|
||||
body: Buffer.from(""),
|
||||
},
|
||||
error: null,
|
||||
lastAgent: null,
|
||||
lastAgentOptions: null,
|
||||
lastRequestParams: null,
|
||||
lastWrittenBody: null,
|
||||
@@ -59,6 +60,7 @@ vi.mock("follow-redirects", async () => {
|
||||
state.lastWrittenBody = chunk;
|
||||
});
|
||||
req.end = vi.fn(() => {
|
||||
state.lastAgent = params?.agent ?? null;
|
||||
state.lastAgentOptions = params?.agent?.opts ?? null;
|
||||
if (state.error) {
|
||||
req.emit("error", state.error);
|
||||
@@ -104,6 +106,7 @@ describe("utils/proxy/http cachedRequest", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Buffer.from(""),
|
||||
};
|
||||
state.lastAgent = null;
|
||||
state.lastAgentOptions = null;
|
||||
state.lastRequestParams = null;
|
||||
state.lastWrittenBody = null;
|
||||
@@ -307,6 +310,7 @@ describe("utils/proxy/http httpProxy", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Buffer.from("ok"),
|
||||
};
|
||||
state.lastAgent = null;
|
||||
state.lastAgentOptions = null;
|
||||
state.lastRequestParams = null;
|
||||
state.lastWrittenBody = null;
|
||||
@@ -397,6 +401,7 @@ describe("utils/proxy/http httpProxy", () => {
|
||||
|
||||
await httpMod.httpProxy("http://example.com");
|
||||
|
||||
expect(state.lastAgentOptions.keepAlive).toBe(true);
|
||||
expect(state.lastAgentOptions.family).toBe(4);
|
||||
expect(state.lastAgentOptions.autoSelectFamily).toBe(false);
|
||||
});
|
||||
@@ -409,6 +414,17 @@ describe("utils/proxy/http httpProxy", () => {
|
||||
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 () => {
|
||||
state.error = Object.assign(new Error("boom"), { code: "EHOSTUNREACH" });
|
||||
const httpMod = await import("./http");
|
||||
|
||||
@@ -39,8 +39,8 @@ export default function Event({ event, colorVariants, showDate = false, showTime
|
||||
return event.url ? (
|
||||
<a
|
||||
className={classNames(className, "hover:bg-theme-300/50 dark:hover:bg-theme-800/20")}
|
||||
onMouseEnter={() => setHover(!hover)}
|
||||
onMouseLeave={() => setHover(!hover)}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
key={key}
|
||||
href={event.url}
|
||||
target="_blank"
|
||||
@@ -49,7 +49,7 @@ export default function Event({ event, colorVariants, showDate = false, showTime
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<div className={className} onMouseEnter={() => setHover(!hover)} onMouseLeave={() => setHover(!hover)} key={key}>
|
||||
<div className={className} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} key={key}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,56 @@ describe("widgets/calendar/event", () => {
|
||||
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", () => {
|
||||
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);
|
||||
|
||||
@@ -91,6 +91,7 @@ const components = {
|
||||
nextcloud: dynamic(() => import("./nextcloud/component")),
|
||||
nextdns: dynamic(() => import("./nextdns/component")),
|
||||
npm: dynamic(() => import("./npm/component")),
|
||||
ntfy: dynamic(() => import("./ntfy/component")),
|
||||
nzbget: dynamic(() => import("./nzbget/component")),
|
||||
octoprint: dynamic(() => import("./octoprint/component")),
|
||||
omada: dynamic(() => import("./omada/component")),
|
||||
|
||||
63
src/widgets/ntfy/component.jsx
Normal file
63
src/widgets/ntfy/component.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/widgets/ntfy/component.test.jsx
Normal file
216
src/widgets/ntfy/component.test.jsx
Normal 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%");
|
||||
});
|
||||
});
|
||||
14
src/widgets/ntfy/widget.js
Normal file
14
src/widgets/ntfy/widget.js
Normal 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;
|
||||
11
src/widgets/ntfy/widget.test.js
Normal file
11
src/widgets/ntfy/widget.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,40 @@
|
||||
import cache from "memory-cache";
|
||||
|
||||
import getServiceWidget from "utils/config/service-helpers";
|
||||
import createLogger from "utils/logger";
|
||||
import { httpProxy } from "utils/proxy/http";
|
||||
|
||||
const proxyName = "omadaProxyHandler";
|
||||
const sessionCacheKey = `${proxyName}__session`;
|
||||
|
||||
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 = {
|
||||
username,
|
||||
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",
|
||||
body: JSON.stringify(params),
|
||||
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) {
|
||||
@@ -86,182 +127,247 @@ export default async function omadaProxyHandler(req, res) {
|
||||
break;
|
||||
}
|
||||
|
||||
const [loginStatus, loginResponseData] = await login(
|
||||
loginUrl,
|
||||
widget.username,
|
||||
widget.password,
|
||||
controllerVersionMajor,
|
||||
);
|
||||
const sessionCacheId = getSessionCacheId(group, service, index);
|
||||
let session = cache.get(sessionCacheId);
|
||||
|
||||
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
|
||||
return res
|
||||
.status(loginStatus)
|
||||
.json({ error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData } });
|
||||
}
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const usedCachedSession = Boolean(session);
|
||||
|
||||
const { token } = loginResponseData.result;
|
||||
if (!session) {
|
||||
const [loginStatus, loginResponseData] = await login(
|
||||
loginUrl,
|
||||
widget.username,
|
||||
widget.password,
|
||||
controllerVersionMajor,
|
||||
sessionCacheId,
|
||||
);
|
||||
|
||||
let sitesUrl;
|
||||
let body = {};
|
||||
let params = { token };
|
||||
let headers = { "Csrf-Token": token };
|
||||
let method = "GET";
|
||||
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
|
||||
return res.status(loginStatus).json({
|
||||
error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData },
|
||||
});
|
||||
}
|
||||
|
||||
switch (controllerVersionMajor) {
|
||||
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}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
session = cache.get(sessionCacheId);
|
||||
}
|
||||
|
||||
[status, contentType, data] = await httpProxy(sitesUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify(body),
|
||||
headers,
|
||||
});
|
||||
const { token, cookieHeader } = session;
|
||||
|
||||
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) {
|
||||
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { message: "Error getting sites list", url, data: sitesResponseData } });
|
||||
}
|
||||
switch (controllerVersionMajor) {
|
||||
case 3:
|
||||
sitesUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
|
||||
body = {
|
||||
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}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
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" };
|
||||
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}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=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,
|
||||
},
|
||||
[status, contentType, data] = await httpProxy(sitesUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify(body),
|
||||
headers: { ...headers },
|
||||
});
|
||||
|
||||
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}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=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}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}¤tPage=1¤tPageSize=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}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}¤tPage=1¤tPageSize=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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
|
||||
return {
|
||||
httpProxy: 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", () => ({
|
||||
default: () => logger,
|
||||
@@ -20,15 +30,19 @@ vi.mock("utils/config/service-helpers", () => ({
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
|
||||
import omadaProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/omada/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear one-off implementations between tests (some branches return early).
|
||||
httpProxy.mockReset();
|
||||
getServiceWidget.mockReset();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("fetches controller info, logs in, selects site, and returns overview stats (v4)", async () => {
|
||||
@@ -51,6 +65,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
// sites list
|
||||
.mockResolvedValueOnce([
|
||||
@@ -91,6 +106,12 @@ describe("widgets/omada/proxy", () => {
|
||||
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 () => {
|
||||
@@ -169,6 +190,7 @@ describe("widgets/omada/proxy", () => {
|
||||
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: 2, msg: "bad" })]);
|
||||
|
||||
@@ -195,6 +217,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
@@ -222,6 +245,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
// getUserSites
|
||||
.mockResolvedValueOnce([
|
||||
@@ -271,6 +295,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
@@ -301,6 +326,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const widget = {
|
||||
endpoint: "org/{org}/sites",
|
||||
},
|
||||
resources: {
|
||||
endpoint: "org/{org}/resources",
|
||||
endpoint: "org/{org}/resources?pageSize=200",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,9 +8,14 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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: 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");
|
||||
|
||||
if (datastoreError || tasksError || hostError) {
|
||||
|
||||
@@ -74,4 +74,23 @@ describe("widgets/proxmoxbackupserver/component", () => {
|
||||
expect(screen.getByText("25")).toBeInTheDocument(); // memory usage
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||
|
||||
const since = Date.now() - 24 * 60 * 60 * 1000;
|
||||
|
||||
const widget = {
|
||||
api: "{url}/api2/json/{endpoint}",
|
||||
proxyHandler: credentialedProxyHandler,
|
||||
@@ -11,7 +9,7 @@ const widget = {
|
||||
endpoint: "status/datastore-usage",
|
||||
},
|
||||
"nodes/localhost/tasks": {
|
||||
endpoint: `nodes/localhost/tasks?errors=true&limit=100&since=${since}`,
|
||||
endpoint: "nodes/localhost/tasks",
|
||||
},
|
||||
"nodes/localhost/status": {
|
||||
endpoint: "nodes/localhost/status",
|
||||
|
||||
@@ -45,17 +45,20 @@ async function fetchFromPyloadAPI(url, sessionId, params, service) {
|
||||
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 isGetRequest = !params || Object.keys(params).length === 0;
|
||||
|
||||
const options = {
|
||||
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 (params) {
|
||||
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 ngUrl = ngEndpoint ? new URL(formatApiCall(apiTemplate, { endpoint: ngEndpoint, ...widget })) : url;
|
||||
const loginUrl = `${widget.url}/api/login`;
|
||||
const hasCredentials = widget.username && widget.password;
|
||||
const hasCredentials = widget.key || (widget.username && widget.password);
|
||||
|
||||
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) {
|
||||
cache.put(`${isNgCacheKey}.${service}`, true);
|
||||
@@ -117,9 +126,7 @@ export default async function pyloadProxyHandler(req, res, map = {}) {
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
return res
|
||||
.status(status)
|
||||
.send({ error: { message: "Invalid credentials communicating with Pyload API", data } });
|
||||
return res.status(status).send({ error: "Invalid credentials communicating with Pyload API", data });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,46 @@ describe("widgets/pyload/proxy", () => {
|
||||
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 () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "pyload",
|
||||
|
||||
@@ -9,13 +9,13 @@ export default function Component({ service }) {
|
||||
|
||||
const { widget } = service;
|
||||
|
||||
const { data: statsData, error: statsError } = useWidgetAPI(widget, "device");
|
||||
const { data: tailscaleData, error: tailscaleError } = useWidgetAPI(widget, "device");
|
||||
|
||||
if (statsError || statsData?.message) {
|
||||
return <Container service={service} error={statsError ?? statsData} />;
|
||||
if (tailscaleError || tailscaleData?.message) {
|
||||
return <Container service={service} error={tailscaleError ?? tailscaleData} />;
|
||||
}
|
||||
|
||||
if (!statsData) {
|
||||
if (!tailscaleData) {
|
||||
return (
|
||||
<Container service={service}>
|
||||
<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 {
|
||||
addresses: [address],
|
||||
keyExpiryDisabled,
|
||||
lastSeen,
|
||||
expires,
|
||||
} = statsData;
|
||||
user,
|
||||
hostname,
|
||||
name,
|
||||
clientVersion,
|
||||
os,
|
||||
created,
|
||||
authorized,
|
||||
isExternal,
|
||||
updateAvailable,
|
||||
tags,
|
||||
} = tailscaleData;
|
||||
|
||||
const now = new Date();
|
||||
const compareDifferenceInTwoDates = (priorDate, futureDate) => {
|
||||
@@ -62,11 +79,28 @@ export default function Component({ service }) {
|
||||
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 (
|
||||
<Container service={service}>
|
||||
<Block label="tailscale.address" value={address} />
|
||||
<Block label="tailscale.last_seen" value={getLastSeen()} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,23 @@ describe("widgets/tailscale/component", () => {
|
||||
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", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
@@ -35,14 +52,108 @@ describe("widgets/tailscale/component", () => {
|
||||
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({
|
||||
data: {
|
||||
addresses: ["100.64.0.1"],
|
||||
keyExpiryDisabled: true,
|
||||
lastSeen: "2019-12-31T23:00:00Z",
|
||||
expires: "2021-01-01T00:00:00Z",
|
||||
},
|
||||
data: { ...fullData, keyExpiryDisabled: true },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
@@ -50,8 +161,17 @@ describe("widgets/tailscale/component", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function Component({ service }) {
|
||||
const { widget } = service;
|
||||
|
||||
const params = {
|
||||
node: widget.node ?? "",
|
||||
type: widget.range ?? "LastHour",
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const widget = {
|
||||
stats: {
|
||||
endpoint: "dashboard/stats/get",
|
||||
validate: ["response", "status"],
|
||||
params: ["type"],
|
||||
params: ["node", "type"],
|
||||
map: (data) => asJson(data).response?.stats,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<Container service={service}>
|
||||
<Block value={t("unifi_drive.no_data")} />
|
||||
@@ -35,12 +35,13 @@ export default function Component({ service }) {
|
||||
);
|
||||
}
|
||||
|
||||
const { totalQuota, usage, status } = storage;
|
||||
const totalBytes = totalQuota ?? 0;
|
||||
const usedBytes = (usage?.system || 0) + (usage?.myDrives || 0) + (usage?.sharedDrives || 0);
|
||||
const totalBytes = pools.reduce((sum, p) => sum + (p.capacity ?? 0), 0);
|
||||
const usedBytes = pools.reduce((sum, p) => sum + (p.usage ?? 0), 0);
|
||||
const availableBytes = Math.max(0, totalBytes - usedBytes);
|
||||
|
||||
const status = pools.some((p) => p.status === "degraded") ? "degraded" : pools[0]?.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");
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,8 +39,8 @@ describe("widgets/unifi_drive/component", () => {
|
||||
expect(screen.getAllByText("widget.api_error", { exact: false }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders no_data when storage data is missing", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: { data: null }, error: undefined });
|
||||
it("renders no_data when pools array is empty", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: { pools: [] }, error: undefined });
|
||||
|
||||
const service = { widget: { type: "unifi_drive" } };
|
||||
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||
@@ -48,14 +48,19 @@ describe("widgets/unifi_drive/component", () => {
|
||||
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({
|
||||
data: {
|
||||
data: {
|
||||
totalQuota: 1000000000000,
|
||||
usage: { system: 100000000000, myDrives: 200000000000, sharedDrives: 50000000000 },
|
||||
status: "healthy",
|
||||
},
|
||||
pools: [{ capacity: 1000000000000, usage: 350000000000, status: "fullyOperational" }],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
@@ -70,14 +75,34 @@ describe("widgets/unifi_drive/component", () => {
|
||||
expectBlockValue(container, "widget.status", "unifi_drive.healthy");
|
||||
});
|
||||
|
||||
it("renders degraded status", () => {
|
||||
it("aggregates storage across multiple pools", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
totalQuota: 100,
|
||||
usage: { system: 10, myDrives: 20, sharedDrives: 5 },
|
||||
status: "degraded",
|
||||
},
|
||||
pools: [
|
||||
{ capacity: 1000000000000, usage: 300000000000, status: "fullyOperational" },
|
||||
{ capacity: 500000000000, usage: 100000000000, status: "noDataProtectionYet" },
|
||||
],
|
||||
},
|
||||
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,
|
||||
});
|
||||
@@ -87,6 +112,37 @@ describe("widgets/unifi_drive/component", () => {
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const widget = {
|
||||
|
||||
mappings: {
|
||||
storage: {
|
||||
endpoint: "v1/systems/storage?type=detail",
|
||||
endpoint: "v2/storage",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -82,6 +82,7 @@ import netdata from "./netdata/widget";
|
||||
import nextcloud from "./nextcloud/widget";
|
||||
import nextdns from "./nextdns/widget";
|
||||
import npm from "./npm/widget";
|
||||
import ntfy from "./ntfy/widget";
|
||||
import nzbget from "./nzbget/widget";
|
||||
import octoprint from "./octoprint/widget";
|
||||
import omada from "./omada/widget";
|
||||
@@ -239,6 +240,7 @@ const widgets = {
|
||||
nextcloud,
|
||||
nextdns,
|
||||
npm,
|
||||
ntfy,
|
||||
nzbget,
|
||||
octoprint,
|
||||
omada,
|
||||
|
||||
Reference in New Issue
Block a user