mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-10 14:18:41 -07:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bfe18b3e5 | |||
| 1ac5012c94 | |||
| 27b1c3f5f4 | |||
| bb765a6747 | |||
| aab4f5125b | |||
| 6d1e56bca0 | |||
| 4603e0ee57 | |||
| 9a983a5be1 | |||
| 23e66a265d | |||
| ce8f020b7b | |||
| 72b0e493d0 | |||
| ffb264cead | |||
| cd6e708af2 | |||
| 4e621aee8f | |||
| 93364b6a27 | |||
| 3dffc38462 | |||
| b2a52ff2a9 | |||
| 081b9ec8c3 | |||
| 977be863c7 | |||
| 77c9ac4a2c | |||
| b6b00aab86 | |||
| c3b4fc17c4 | |||
| 9b8e771268 | |||
| 44c1e8393e | |||
| 7ed1260f0d | |||
| 96f4777637 | |||
| f762135eb3 | |||
| d721bb661f | |||
| bf55e8acab | |||
| f82e0d9519 | |||
| 8322dd9016 | |||
| 10ade1d32f | |||
| a0f9fbf614 | |||
| 0c8fd6904e | |||
| b2b299d634 | |||
| b8672ddc9b | |||
| 03dd54ee2e | |||
| 92ec6ccefc | |||
| 463e94d933 | |||
| a7bab17f97 | |||
| 1accddf29a | |||
| 636d62106c | |||
| d048888d99 | |||
| 24804f39fc | |||
| 70f8c67d3c | |||
| 038f5c3b9f | |||
| ade4c733c7 | |||
| bdbd2e6ff0 | |||
| 5f4b0b4e33 | |||
| a56a05b553 | |||
| 11d1ceb755 | |||
| 6df1cfdeba | |||
| d64fbae776 | |||
| 5f7e5430c4 | |||
| 3cc3986ec3 | |||
| 5af0ab1686 | |||
| f0f5c3c15c | |||
| 2ed1da4411 | |||
| e1023466b1 | |||
| 9fe5ad62f1 | |||
| 173883000f | |||
| d6e7e7e790 | |||
| 24cb274e03 | |||
| af852e748a |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Mylar3
|
||||
description: Mylar3 Widget Configuration
|
||||
---
|
||||
|
||||
Learn more about [Mylar3](https://github.com/mylar3/mylar3).
|
||||
Learn more about [Mylar3](https://github.com/MylarComics/mylar3).
|
||||
|
||||
API must be enabled in Mylar3 settings.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -3,9 +3,9 @@ title: Watchtower
|
||||
description: Watchtower Widget Configuration
|
||||
---
|
||||
|
||||
Learn more about [Watchtower](https://github.com/containrrr/watchtower).
|
||||
Learn more about [Watchtower](https://github.com/nicholas-fedor/watchtower).
|
||||
|
||||
To use this widget, Watchtower needs to be configured to [enable metrics](https://containrrr.dev/watchtower/metrics/).
|
||||
To use this widget, Watchtower needs to be configured to [enable metrics](https://watchtower.nickfedor.com/dev/advanced-features/metrics-api/).
|
||||
|
||||
Allowed fields: `["containers_scanned", "containers_updated", "containers_failed"]`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.12.2",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+384
-350
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "Substelsel status onbekend"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Gesond",
|
||||
"degraded": "Gedegradeer",
|
||||
"no_data": "Geen bergingsdata beskikbaar nie"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adres",
|
||||
"expires": "Verval",
|
||||
"never": "Nooit",
|
||||
"user": "Gebruiker",
|
||||
"hostname": "Gasheernaam",
|
||||
"name": "Naam",
|
||||
"client_version": "Kliënt Weergawe",
|
||||
"os": "OS",
|
||||
"created": "Geskep",
|
||||
"authorized": "Gemagtig",
|
||||
"is_external": "Is Extern",
|
||||
"update_available": "Opdatering Beskikbaar",
|
||||
"tags": "Merkers",
|
||||
"last_seen": "Laaste Gesien",
|
||||
"now": "Nou",
|
||||
"years": "{{number}}y",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Terug"
|
||||
"ago": "{{value}} Terug",
|
||||
"true": "Ja",
|
||||
"false": "Nee"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Navraë",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Waarskuwings",
|
||||
"criticals": "Kritici"
|
||||
},
|
||||
"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": "Gebeure",
|
||||
"plants": "Plante",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "عنوان",
|
||||
"expires": "تنتهي",
|
||||
"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": "آخر ظهور",
|
||||
"now": "الآن",
|
||||
"years": "{{number}}س",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}س",
|
||||
"minutes": "{{number}}د",
|
||||
"seconds": "{{number}}ث",
|
||||
"ago": "منذ {{value}}"
|
||||
"ago": "منذ {{value}}",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "تحذيرات",
|
||||
"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": "أحداث",
|
||||
"plants": "نباتات",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Адрес",
|
||||
"expires": "Изтича",
|
||||
"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": "Последно видян",
|
||||
"now": "Сега",
|
||||
"years": "{{number}}г",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}ч",
|
||||
"minutes": "{{number}}м",
|
||||
"seconds": "{{number}}сек",
|
||||
"ago": "преди {{value}}"
|
||||
"ago": "преди {{value}}",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Предупреждения",
|
||||
"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": "Събития",
|
||||
"plants": "Растения",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adreça",
|
||||
"expires": "Caduca",
|
||||
"never": "Mai",
|
||||
"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": "Vist per darrer cop",
|
||||
"now": "Ara",
|
||||
"years": "{{number}}a",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "Fa {{value}}"
|
||||
"ago": "Fa {{value}}",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Consultes",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Avisos",
|
||||
"criticals": "Crítics"
|
||||
},
|
||||
"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": "Esdeveniments",
|
||||
"plants": "Plantes",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresa",
|
||||
"expires": "Vyprší",
|
||||
"never": "Nikdy",
|
||||
"user": "Uživatel",
|
||||
"hostname": "Název zařízení",
|
||||
"name": "Název",
|
||||
"client_version": "Verze klienta",
|
||||
"os": "OS",
|
||||
"created": "Vytvořeno",
|
||||
"authorized": "Autorizováno",
|
||||
"is_external": "Je externí",
|
||||
"update_available": "Dostupná aktualizace",
|
||||
"tags": "Štítky",
|
||||
"last_seen": "Naposledy viděno",
|
||||
"now": "Nyní",
|
||||
"years": "{{number}}r",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "Před {{value}}"
|
||||
"ago": "Před {{value}}",
|
||||
"true": "Ano",
|
||||
"false": "Ne"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Dotazy",
|
||||
@@ -690,7 +702,7 @@
|
||||
},
|
||||
"proxmoxbackupserver": {
|
||||
"datastore_usage": "Datové úložiště",
|
||||
"failed_tasks_24h": "Neúspěšné úlohy 24h",
|
||||
"failed_tasks_24h": "Neúspěšné úlohy za 24h",
|
||||
"cpu_usage": "Využití procesoru",
|
||||
"memory_usage": "Využití paměti"
|
||||
},
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Upozornění",
|
||||
"criticals": "Kritické"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "Název",
|
||||
"priority": "Priorita",
|
||||
"lastReceived": "Poslední přijetí",
|
||||
"message": "Zpráva",
|
||||
"tags": "Štítky",
|
||||
"none": "Žádné",
|
||||
"min": "Minimální",
|
||||
"low": "Nízká",
|
||||
"default": "Výchozí",
|
||||
"high": "Vysoká",
|
||||
"urgent": "Urgentní"
|
||||
},
|
||||
"plantit": {
|
||||
"events": "Události",
|
||||
"plants": "Rostliny",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresse",
|
||||
"expires": "Udløber",
|
||||
"never": "Aldrig",
|
||||
"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": "Sidst Set",
|
||||
"now": "Nu",
|
||||
"years": "{{number}}å",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}t",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Siden"
|
||||
"ago": "{{value}} Siden",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Advarsler",
|
||||
"criticals": "Criticals"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "Titel",
|
||||
"priority": "Prioritet",
|
||||
"lastReceived": "Sidst modtaget",
|
||||
"message": "Besked",
|
||||
"tags": "Tags",
|
||||
"none": "Ingen",
|
||||
"min": "Min",
|
||||
"low": "Lav",
|
||||
"default": "Standard",
|
||||
"high": "Høj",
|
||||
"urgent": "Akut"
|
||||
},
|
||||
"plantit": {
|
||||
"events": "Events",
|
||||
"plants": "Plants",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresse",
|
||||
"expires": "Läuft ab",
|
||||
"never": "Nie",
|
||||
"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": "Zuletzt gesehen",
|
||||
"now": "Jetzt",
|
||||
"years": "{{number}}a",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}min",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "Vor {{value}}"
|
||||
"ago": "Vor {{value}}",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Anfragen",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Warnungen",
|
||||
"criticals": "Kritisch"
|
||||
},
|
||||
"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": "Ereignisse",
|
||||
"plants": "Pflanzen",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Διεύθυνση",
|
||||
"expires": "Λήγει",
|
||||
"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": "Τελευταία Σύνδεση",
|
||||
"now": "Τώρα",
|
||||
"years": "{{number}}χρόνια",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}ώρες",
|
||||
"minutes": "{{number}}λεπτά",
|
||||
"seconds": "{{number}}δευτερόλεπτα",
|
||||
"ago": "{{value}} πρίν"
|
||||
"ago": "{{value}} πρίν",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Dirección",
|
||||
"expires": "Caduca en",
|
||||
"never": "Nunca",
|
||||
"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": "Visto por última vez",
|
||||
"now": "Ahora",
|
||||
"years": "{{number}}a",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "Hace {{value}}"
|
||||
"ago": "Hace {{value}}",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Consultas",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Advertencias",
|
||||
"criticals": "Críticos"
|
||||
},
|
||||
"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": "Eventos",
|
||||
"plants": "Plantas",
|
||||
|
||||
@@ -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": "Ekitaldiak",
|
||||
"plants": "Landareak",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "Statut du sous-système inconnu"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Sain",
|
||||
"degraded": "Dégradé",
|
||||
"no_data": "Aucune données de stockage disponible"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "Rx",
|
||||
@@ -115,12 +115,12 @@
|
||||
"jellyfin": {
|
||||
"playing": "En cours",
|
||||
"transcoding": "En cours d'encodage",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"episodes": "Episodes",
|
||||
"songs": "Songs"
|
||||
"bitrate": "Débit",
|
||||
"no_active": "Aucune lecture en cours",
|
||||
"movies": "Films",
|
||||
"series": "Séries",
|
||||
"episodes": "Épisodes",
|
||||
"songs": "Chansons"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Hors ligne",
|
||||
@@ -190,11 +190,11 @@
|
||||
"plex_connection_error": "Vérifier la connexion à Plex"
|
||||
},
|
||||
"tracearr": {
|
||||
"no_active": "No Active Streams",
|
||||
"streams": "Streams",
|
||||
"transcodes": "Transcodes",
|
||||
"directplay": "Direct Play",
|
||||
"bitrate": "Bitrate"
|
||||
"no_active": "Aucune lecture en cours",
|
||||
"streams": "Lectures",
|
||||
"transcodes": "Transcodages",
|
||||
"directplay": "Lectures directes",
|
||||
"bitrate": "Débit"
|
||||
},
|
||||
"omada": {
|
||||
"connectedAp": "APs connectées",
|
||||
@@ -295,12 +295,12 @@
|
||||
"available": "Disponible"
|
||||
},
|
||||
"seerr": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"available": "Available",
|
||||
"completed": "Completed",
|
||||
"processing": "Processing",
|
||||
"issues": "Open Issues"
|
||||
"pending": "En attente",
|
||||
"approved": "Validées",
|
||||
"available": "Disponibles",
|
||||
"completed": "Complétées",
|
||||
"processing": "En traitement",
|
||||
"issues": "Problèmes non résolus"
|
||||
},
|
||||
"netalertx": {
|
||||
"total": "Total",
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresse",
|
||||
"expires": "Expire",
|
||||
"never": "Jamais",
|
||||
"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": "Vu pour la dernière fois",
|
||||
"now": "Maintenant",
|
||||
"years": "{{number}}a",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "Il y a {{value}}"
|
||||
"ago": "Il y a {{value}}",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Requêtes",
|
||||
@@ -619,13 +631,13 @@
|
||||
"total": "Total"
|
||||
},
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"orgs": "Organisations",
|
||||
"sites": "Sites",
|
||||
"resources": "Ressources",
|
||||
"targets": "Cibles",
|
||||
"traffic": "Trafique",
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
"in": "Entrant",
|
||||
"out": "Sortant"
|
||||
},
|
||||
"peanut": {
|
||||
"battery_charge": "Charge de la batterie",
|
||||
@@ -724,8 +736,8 @@
|
||||
"volumeAvailable": "Disponible"
|
||||
},
|
||||
"dispatcharr": {
|
||||
"channels": "Channels",
|
||||
"streams": "Streams"
|
||||
"channels": "Chaînes",
|
||||
"streams": "Lectures"
|
||||
},
|
||||
"mylar": {
|
||||
"series": "Séries",
|
||||
@@ -816,10 +828,10 @@
|
||||
"series": "Séries"
|
||||
},
|
||||
"booklore": {
|
||||
"libraries": "Libraries",
|
||||
"books": "Books",
|
||||
"reading": "Reading",
|
||||
"finished": "Finished"
|
||||
"libraries": "Bibliothèques",
|
||||
"books": "Livres",
|
||||
"reading": "En cours de lecture",
|
||||
"finished": "Terminés"
|
||||
},
|
||||
"jdownloader": {
|
||||
"downloadCount": "File d'attente",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Avertissements",
|
||||
"criticals": "Urgent"
|
||||
},
|
||||
"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": "Événements",
|
||||
"plants": "Plantes",
|
||||
@@ -1160,30 +1185,30 @@
|
||||
"artists": "Artistes"
|
||||
},
|
||||
"arcane": {
|
||||
"containers": "Containers",
|
||||
"containers": "Conteneurs",
|
||||
"images": "Images",
|
||||
"image_updates": "Image Updates",
|
||||
"images_unused": "Unused",
|
||||
"environment_required": "Environment ID Required"
|
||||
"image_updates": "Mises à jour d'images",
|
||||
"images_unused": "Inutilisées",
|
||||
"environment_required": "ID d'environnement requis"
|
||||
},
|
||||
"dockhand": {
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"running": "En cours d'exécution",
|
||||
"stopped": "Arrêtés",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"memory": "Mémoire",
|
||||
"images": "Images",
|
||||
"volumes": "Volumes",
|
||||
"events_today": "Events Today",
|
||||
"pending_updates": "Pending Updates",
|
||||
"events_today": "Événements aujourd'hui",
|
||||
"pending_updates": "Mises à jour en attente",
|
||||
"stacks": "Stacks",
|
||||
"paused": "Paused",
|
||||
"paused": "En pause",
|
||||
"total": "Total",
|
||||
"environment_not_found": "Environment Not Found"
|
||||
"environment_not_found": "Environnement introuvable"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"eaten": "Eaten",
|
||||
"burned": "Burned",
|
||||
"remaining": "Remaining",
|
||||
"steps": "Steps"
|
||||
"eaten": "Mangé",
|
||||
"burned": "Brûlé",
|
||||
"remaining": "Restant",
|
||||
"steps": "Pas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "כתובת",
|
||||
"expires": "תאריך תפוגה",
|
||||
"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": "נראה לאחרונה",
|
||||
"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": "שאילתות",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "התראות",
|
||||
"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": "אירועים",
|
||||
"plants": "צמחים",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "Stanje podsustava nepoznato"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Zdravo",
|
||||
"degraded": "Degradirano",
|
||||
"no_data": "Nema dostupnih podataka spremišta"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -116,7 +116,7 @@
|
||||
"playing": "Reprodukcija u tijeku",
|
||||
"transcoding": "Prekodiranje",
|
||||
"bitrate": "Stopa bitova",
|
||||
"no_active": "No Active Streams",
|
||||
"no_active": "Nema aktivnih prijenosa",
|
||||
"movies": "Filmovi",
|
||||
"series": "Serije",
|
||||
"episodes": "Epizode",
|
||||
@@ -190,8 +190,8 @@
|
||||
"plex_connection_error": "Provjeri Plex vezu"
|
||||
},
|
||||
"tracearr": {
|
||||
"no_active": "No Active Streams",
|
||||
"streams": "Streams",
|
||||
"no_active": "Nema aktivnih prijenosa",
|
||||
"streams": "Prijenosi",
|
||||
"transcodes": "Prekodiranja",
|
||||
"directplay": "Izravna reprodukcija",
|
||||
"bitrate": "Stopa bitova"
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresa",
|
||||
"expires": "Isteče",
|
||||
"never": "Nikada",
|
||||
"user": "Korisnik",
|
||||
"hostname": "Ime računala",
|
||||
"name": "Ime",
|
||||
"client_version": "Verzija klijenta",
|
||||
"os": "OS",
|
||||
"created": "Stvoreno",
|
||||
"authorized": "Autorizirano",
|
||||
"is_external": "Je eksterna",
|
||||
"update_available": "Dostupna je nova verzija",
|
||||
"tags": "Oznake",
|
||||
"last_seen": "Zadnje viđeno",
|
||||
"now": "Sada",
|
||||
"years": "{{number}} god",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}} h",
|
||||
"minutes": "{{number}} min",
|
||||
"seconds": "{{number}} s",
|
||||
"ago": "Prije {{value}}"
|
||||
"ago": "Prije {{value}}",
|
||||
"true": "Da",
|
||||
"false": "Ne"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Upiti",
|
||||
@@ -624,8 +636,8 @@
|
||||
"resources": "Resursi",
|
||||
"targets": "Ciljevi",
|
||||
"traffic": "Promet",
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
"in": "Ulaz",
|
||||
"out": "Izlaz"
|
||||
},
|
||||
"peanut": {
|
||||
"battery_charge": "Napunjenost baterije",
|
||||
@@ -725,7 +737,7 @@
|
||||
},
|
||||
"dispatcharr": {
|
||||
"channels": "Kanali",
|
||||
"streams": "Streams"
|
||||
"streams": "Prijenosi"
|
||||
},
|
||||
"mylar": {
|
||||
"series": "Serije",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Upozorenja",
|
||||
"criticals": "Kritično"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "Naslov",
|
||||
"priority": "Prioritet",
|
||||
"lastReceived": "Zadnji primitak",
|
||||
"message": "Poruka",
|
||||
"tags": "Oznake",
|
||||
"none": "Ništa",
|
||||
"min": "Min.",
|
||||
"low": "Nisko",
|
||||
"default": "Standardno",
|
||||
"high": "Visoko",
|
||||
"urgent": "Hitno"
|
||||
},
|
||||
"plantit": {
|
||||
"events": "Događaji",
|
||||
"plants": "Biljke",
|
||||
@@ -1109,7 +1134,7 @@
|
||||
"unhealthy": "Nezdravo",
|
||||
"unknown": "Nepoznato",
|
||||
"servers": "Serveri",
|
||||
"stacks": "Stacks",
|
||||
"stacks": "Stogovi",
|
||||
"containers": "Kontejneri"
|
||||
},
|
||||
"filebrowser": {
|
||||
@@ -1175,7 +1200,7 @@
|
||||
"volumes": "Jedinice memorije",
|
||||
"events_today": "Događanja danas",
|
||||
"pending_updates": "Aktualiziranja na čekanju",
|
||||
"stacks": "Stacks",
|
||||
"stacks": "Stogovi",
|
||||
"paused": "Pauzirano",
|
||||
"total": "Ukupno",
|
||||
"environment_not_found": "Okruženje nije pronađeno"
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Cím",
|
||||
"expires": "Lejár",
|
||||
"never": "Soha",
|
||||
"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": "Utoljára látott",
|
||||
"now": "Most",
|
||||
"years": "{{number}}év",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}ó",
|
||||
"minutes": "{{number}}p",
|
||||
"seconds": "{{number}}mp",
|
||||
"ago": "{{value}} Ezelőtt"
|
||||
"ago": "{{value}} Ezelőtt",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Lekérdezések",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Figyelmeztetések",
|
||||
"criticals": "Kritikusok"
|
||||
},
|
||||
"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": "Események",
|
||||
"plants": "Növények",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Alamat",
|
||||
"expires": "Kedaluwarsa",
|
||||
"never": "Tidak Pernah",
|
||||
"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": "Terakhir terlihat",
|
||||
"now": "Sekarang",
|
||||
"years": "{{number}}thn",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}j",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}d",
|
||||
"ago": "{{value}} Yang Lalu"
|
||||
"ago": "{{value}} Yang Lalu",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Peringatan",
|
||||
"criticals": "Kritis"
|
||||
},
|
||||
"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": "Acara",
|
||||
"plants": "Tanaman",
|
||||
|
||||
+107
-82
@@ -61,15 +61,15 @@
|
||||
"wlan_devices": "Dispositivi WLAN",
|
||||
"lan_users": "Utenti LAN",
|
||||
"wlan_users": "Utenti WLAN",
|
||||
"up": "UP",
|
||||
"up": "SU",
|
||||
"down": "DOWN",
|
||||
"wait": "Please wait",
|
||||
"wait": "Attendere prego",
|
||||
"empty_data": "Stato del sottosistema sconosciuto"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Sano",
|
||||
"degraded": "Degradato",
|
||||
"no_data": "Nessun dato di archiviazione disponibile"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -88,7 +88,7 @@
|
||||
"partial": "Parziale"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
"error": "Errore",
|
||||
"ping": "Ping",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
@@ -96,11 +96,11 @@
|
||||
},
|
||||
"siteMonitor": {
|
||||
"http_status": "Stato HTTP",
|
||||
"error": "Error",
|
||||
"error": "Errore",
|
||||
"response": "Risposta",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
"not_available": "Not Available"
|
||||
"down": "Giù",
|
||||
"up": "Su",
|
||||
"not_available": "Non disponibile"
|
||||
},
|
||||
"emby": {
|
||||
"playing": "In riproduzione",
|
||||
@@ -113,21 +113,21 @@
|
||||
"songs": "Canzoni"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Playing",
|
||||
"transcoding": "Transcoding",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"movies": "Movies",
|
||||
"playing": "In riproduzione",
|
||||
"transcoding": "Transcodifica",
|
||||
"bitrate": "Velocità in bit",
|
||||
"no_active": "Nessuna Trasmissione Attiva",
|
||||
"movies": "Film",
|
||||
"series": "Serie",
|
||||
"episodes": "Episodes",
|
||||
"songs": "Songs"
|
||||
"episodes": "Episodi",
|
||||
"songs": "Canzoni"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
"offline_alt": "Offline",
|
||||
"offline": "Disconnesso",
|
||||
"offline_alt": "Disconnesso",
|
||||
"online": "Online",
|
||||
"total": "Total",
|
||||
"unknown": "Unknown"
|
||||
"total": "Totale",
|
||||
"unknown": "Sconosciuto"
|
||||
},
|
||||
"evcc": {
|
||||
"pv_power": "Produzione",
|
||||
@@ -148,7 +148,7 @@
|
||||
"unread": "Non letto"
|
||||
},
|
||||
"fritzbox": {
|
||||
"connectionStatus": "Status",
|
||||
"connectionStatus": "Stato",
|
||||
"connectionStatusUnconfigured": "Non configurato",
|
||||
"connectionStatusConnecting": "Connessione in corso",
|
||||
"connectionStatusAuthenticating": "In fase di autenticazione",
|
||||
@@ -156,11 +156,11 @@
|
||||
"connectionStatusDisconnecting": "Disconnessione in corso",
|
||||
"connectionStatusDisconnected": "Disconnesso",
|
||||
"connectionStatusConnected": "Connesso",
|
||||
"uptime": "Uptime",
|
||||
"uptime": "Tempo di funzionamento",
|
||||
"maxDown": "Max. Down",
|
||||
"maxUp": "Max. Up",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
"down": "Giù",
|
||||
"up": "Su",
|
||||
"received": "Ricevuti",
|
||||
"sent": "Inviati",
|
||||
"externalIPAddress": "IP Esterno",
|
||||
@@ -184,17 +184,17 @@
|
||||
},
|
||||
"tautulli": {
|
||||
"playing": "In riproduzione",
|
||||
"transcoding": "Transcoding",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"transcoding": "Transcodifica",
|
||||
"bitrate": "Velocità in bit",
|
||||
"no_active": "Nessuna Trasmissione Attiva",
|
||||
"plex_connection_error": "Controllare la connessione a Plex"
|
||||
},
|
||||
"tracearr": {
|
||||
"no_active": "No Active Streams",
|
||||
"streams": "Streams",
|
||||
"transcodes": "Transcodes",
|
||||
"directplay": "Direct Play",
|
||||
"bitrate": "Bitrate"
|
||||
"no_active": "Nessuna Trasmissione Attiva",
|
||||
"streams": "Flussi",
|
||||
"transcodes": "Transcodifiche",
|
||||
"directplay": "Riproduzione diretta",
|
||||
"bitrate": "Velocità in bit"
|
||||
},
|
||||
"omada": {
|
||||
"connectedAp": "AP Connessi",
|
||||
@@ -211,11 +211,11 @@
|
||||
"plex": {
|
||||
"streams": "Trasmissioni attive",
|
||||
"albums": "Album",
|
||||
"movies": "Movies",
|
||||
"movies": "Film",
|
||||
"tv": "Programmi televisivi"
|
||||
},
|
||||
"sabnzbd": {
|
||||
"rate": "Rate",
|
||||
"rate": "Rapporto",
|
||||
"queue": "Coda",
|
||||
"timeleft": "Tempo Rimanente"
|
||||
},
|
||||
@@ -227,13 +227,13 @@
|
||||
"transmission": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"leech": "In download",
|
||||
"seed": "Seed"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"leech": "In download",
|
||||
"seed": "Seed"
|
||||
},
|
||||
"qnap": {
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Indirizzo",
|
||||
"expires": "Scade",
|
||||
"never": "Mai",
|
||||
"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": "Ultima visualizzazione",
|
||||
"now": "Adesso",
|
||||
"years": "{{number}}a",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}o",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Fa"
|
||||
"ago": "{{value}} Fa",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -430,21 +442,21 @@
|
||||
"medusa": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"series": "Series"
|
||||
"series": "Serie"
|
||||
},
|
||||
"minecraft": {
|
||||
"players": "Giocatori",
|
||||
"version": "Versione",
|
||||
"status": "Status",
|
||||
"status": "Stato",
|
||||
"up": "Online",
|
||||
"down": "Offline"
|
||||
"down": "Disconnesso"
|
||||
},
|
||||
"miniflux": {
|
||||
"read": "Letti",
|
||||
"unread": "Unread"
|
||||
"unread": "Non letto"
|
||||
},
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"users": "Utenti",
|
||||
"loginsLast24H": "Accessi (24h)",
|
||||
"failedLoginsLast24H": "Accessi Falliti (24h)"
|
||||
},
|
||||
@@ -456,19 +468,19 @@
|
||||
},
|
||||
"glances": {
|
||||
"cpu": "CPU",
|
||||
"load": "Load",
|
||||
"wait": "Please wait",
|
||||
"load": "Carico",
|
||||
"wait": "Attendere prego",
|
||||
"temp": "TEMP",
|
||||
"_temp": "Temp.",
|
||||
"warn": "Avviso",
|
||||
"uptime": "UP",
|
||||
"total": "Total",
|
||||
"free": "Free",
|
||||
"used": "Used",
|
||||
"days": "d",
|
||||
"hours": "h",
|
||||
"uptime": "SU",
|
||||
"total": "Totale",
|
||||
"free": "Libero",
|
||||
"used": "Utilizzato",
|
||||
"days": "g",
|
||||
"hours": "o",
|
||||
"crit": "Critico",
|
||||
"read": "Read",
|
||||
"read": "Letti",
|
||||
"write": "Scrittura",
|
||||
"gpu": "GPU",
|
||||
"mem": "Mem.",
|
||||
@@ -489,57 +501,57 @@
|
||||
"1-day": "Prevalentemente Soleggiato",
|
||||
"1-night": "Prevalentemente Sereno",
|
||||
"2-day": "Parzialmente Nuvoloso",
|
||||
"2-night": "Partly Cloudy",
|
||||
"2-night": "Parzialmente Nuvoloso",
|
||||
"3-day": "Nuvoloso",
|
||||
"3-night": "Cloudy",
|
||||
"3-night": "Nuvoloso",
|
||||
"45-day": "Nebbioso",
|
||||
"45-night": "Foggy",
|
||||
"48-day": "Foggy",
|
||||
"48-night": "Foggy",
|
||||
"45-night": "Nebbioso",
|
||||
"48-day": "Nebbioso",
|
||||
"48-night": "Nebbioso",
|
||||
"51-day": "Pioggerella Leggera",
|
||||
"51-night": "Light Drizzle",
|
||||
"51-night": "Pioggerella Leggera",
|
||||
"53-day": "Pioggerella",
|
||||
"53-night": "Drizzle",
|
||||
"53-night": "Pioggerella",
|
||||
"55-day": "Pioggerella Pesante",
|
||||
"55-night": "Heavy Drizzle",
|
||||
"55-night": "Pioggerella Pesante",
|
||||
"56-day": "Leggera Pioggia Gelata",
|
||||
"56-night": "Light Freezing Drizzle",
|
||||
"56-night": "Leggera Pioggia Gelata",
|
||||
"57-day": "Pioggerella Gelata",
|
||||
"57-night": "Freezing Drizzle",
|
||||
"57-night": "Pioggerella Gelata",
|
||||
"61-day": "Pioggia Leggera",
|
||||
"61-night": "Light Rain",
|
||||
"61-night": "Pioggia Leggera",
|
||||
"63-day": "Pioggia",
|
||||
"63-night": "Rain",
|
||||
"63-night": "Pioggia",
|
||||
"65-day": "Pioggia Intensa",
|
||||
"65-night": "Heavy Rain",
|
||||
"65-night": "Pioggia Intensa",
|
||||
"66-day": "Grandine",
|
||||
"66-night": "Freezing Rain",
|
||||
"67-day": "Freezing Rain",
|
||||
"67-night": "Freezing Rain",
|
||||
"66-night": "Grandine",
|
||||
"67-day": "Grandine",
|
||||
"67-night": "Grandine",
|
||||
"71-day": "Leggera Nevicata",
|
||||
"71-night": "Light Snow",
|
||||
"71-night": "Leggera Nevicata",
|
||||
"73-day": "Neve",
|
||||
"73-night": "Snow",
|
||||
"73-night": "Neve",
|
||||
"75-day": "Nevicata Intensa",
|
||||
"75-night": "Heavy Snow",
|
||||
"75-night": "Nevicata Intensa",
|
||||
"77-day": "Fiocchi di Neve",
|
||||
"77-night": "Snow Grains",
|
||||
"77-night": "Fiocchi di Neve",
|
||||
"80-day": "Leggeri Rovesci",
|
||||
"80-night": "Light Showers",
|
||||
"80-night": "Leggeri Rovesci",
|
||||
"81-day": "Rovesci",
|
||||
"81-night": "Showers",
|
||||
"81-night": "Rovesci",
|
||||
"82-day": "Intensi Rovesci",
|
||||
"82-night": "Heavy Showers",
|
||||
"82-night": "Intensi Rovesci",
|
||||
"85-day": "Rovesci di Neve",
|
||||
"85-night": "Snow Showers",
|
||||
"86-day": "Snow Showers",
|
||||
"86-night": "Snow Showers",
|
||||
"85-night": "Rovesci di Neve",
|
||||
"86-day": "Rovesci di Neve",
|
||||
"86-night": "Rovesci di Neve",
|
||||
"95-day": "Temporale",
|
||||
"95-night": "Thunderstorm",
|
||||
"95-night": "Temporale",
|
||||
"96-day": "Temporale con grandine",
|
||||
"96-night": "Thunderstorm With Hail",
|
||||
"99-day": "Thunderstorm With Hail",
|
||||
"99-night": "Thunderstorm With Hail"
|
||||
"96-night": "Temporale con grandine",
|
||||
"99-day": "Temporale con grandine",
|
||||
"99-night": "Temporale con grandine"
|
||||
},
|
||||
"homebridge": {
|
||||
"available_update": "Sistema",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Avvisi",
|
||||
"criticals": "Critici"
|
||||
},
|
||||
"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": "Eventi",
|
||||
"plants": "Piante",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "アドレス",
|
||||
"expires": "失効",
|
||||
"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": "最終日時",
|
||||
"now": "現在",
|
||||
"years": "{{number}}年",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}時間",
|
||||
"minutes": "{{number}}分",
|
||||
"seconds": "{{number}}秒",
|
||||
"ago": "{{value}} 前"
|
||||
"ago": "{{value}} 前",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "警告",
|
||||
"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": "イベント",
|
||||
"plants": "植物",
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "서브시스템 상태 알 수 없음"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "정상",
|
||||
"degraded": "성능 저하",
|
||||
"no_data": "저장소 데이터 없음"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "수신",
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "주소",
|
||||
"expires": "만료",
|
||||
"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": "마지막 접속",
|
||||
"now": "지금",
|
||||
"years": "{{number}}년",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}시간",
|
||||
"minutes": "{{number}}분",
|
||||
"seconds": "{{number}}초",
|
||||
"ago": "{{value}} 전"
|
||||
"ago": "{{value}} 전",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "총 쿼리",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "경고",
|
||||
"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": "이벤트",
|
||||
"plants": "식물",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Sekarang",
|
||||
"years": "{{number}}y",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Lepas"
|
||||
"ago": "{{value}} Lepas",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Amaran",
|
||||
"criticals": "Kritikal"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "Subsysteem status onbekend"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Gezond",
|
||||
"degraded": "Gedegradeerd",
|
||||
"no_data": "Geen opslaggegevens beschikbaar"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -113,14 +113,14 @@
|
||||
"songs": "Nummers"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Playing",
|
||||
"transcoding": "Transcoding",
|
||||
"playing": "Speelt",
|
||||
"transcoding": "Bezig met transcoderen",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"movies": "Movies",
|
||||
"no_active": "Geen Actieve Streams",
|
||||
"movies": "Films",
|
||||
"series": "Series",
|
||||
"episodes": "Episodes",
|
||||
"songs": "Songs"
|
||||
"episodes": "Afleveringen",
|
||||
"songs": "Nummers"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
@@ -190,10 +190,10 @@
|
||||
"plex_connection_error": "Controleer Plex Connectie"
|
||||
},
|
||||
"tracearr": {
|
||||
"no_active": "No Active Streams",
|
||||
"no_active": "Geen Actieve Streams",
|
||||
"streams": "Streams",
|
||||
"transcodes": "Transcodes",
|
||||
"directplay": "Direct Play",
|
||||
"directplay": "Directe Afspelen",
|
||||
"bitrate": "Bitrate"
|
||||
},
|
||||
"omada": {
|
||||
@@ -295,12 +295,12 @@
|
||||
"available": "Beschikbaar"
|
||||
},
|
||||
"seerr": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"available": "Available",
|
||||
"completed": "Completed",
|
||||
"processing": "Processing",
|
||||
"issues": "Open Issues"
|
||||
"pending": "In afwachting",
|
||||
"approved": "Goedgekeurd",
|
||||
"available": "Beschikbaar",
|
||||
"completed": "Voltooid",
|
||||
"processing": "Verwerken",
|
||||
"issues": "Openstaande issues"
|
||||
},
|
||||
"netalertx": {
|
||||
"total": "Totaal",
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adres",
|
||||
"expires": "Verloopt",
|
||||
"never": "Nooit",
|
||||
"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": "Laatst Gezien",
|
||||
"now": "Nu",
|
||||
"years": "{{number}}j",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}u",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Geleden"
|
||||
"ago": "{{value}} Geleden",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Verzoeken",
|
||||
@@ -621,11 +633,11 @@
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"sites": "Sites",
|
||||
"resources": "Resources",
|
||||
"targets": "Targets",
|
||||
"traffic": "Traffic",
|
||||
"resources": "Bronnen",
|
||||
"targets": "Doelen",
|
||||
"traffic": "Netwerkverkeer",
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
"out": "Uit"
|
||||
},
|
||||
"peanut": {
|
||||
"battery_charge": "Batterij opladen",
|
||||
@@ -724,7 +736,7 @@
|
||||
"volumeAvailable": "Beschikbaar"
|
||||
},
|
||||
"dispatcharr": {
|
||||
"channels": "Channels",
|
||||
"channels": "Kanalen",
|
||||
"streams": "Streams"
|
||||
},
|
||||
"mylar": {
|
||||
@@ -792,7 +804,7 @@
|
||||
"gross_percent_today": "Vandaag",
|
||||
"gross_percent_1y": "Een jaar",
|
||||
"gross_percent_max": "Altijd",
|
||||
"net_worth": "Net Worth"
|
||||
"net_worth": "Totaal eigen vermogen"
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"podcasts": "Podcasts",
|
||||
@@ -816,8 +828,8 @@
|
||||
"series": "Series"
|
||||
},
|
||||
"booklore": {
|
||||
"libraries": "Libraries",
|
||||
"books": "Books",
|
||||
"libraries": "Bibliotheken",
|
||||
"books": "Boeken",
|
||||
"reading": "Reading",
|
||||
"finished": "Finished"
|
||||
},
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Waarschuwingen",
|
||||
"criticals": "Kritiek"
|
||||
},
|
||||
"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": "Gebeurtenissen",
|
||||
"plants": "Planten",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresse",
|
||||
"expires": "Utgår",
|
||||
"never": "Aldri",
|
||||
"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": "Sist sett",
|
||||
"now": "Nå",
|
||||
"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": "Advarsler",
|
||||
"criticals": "Kritiske"
|
||||
},
|
||||
"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": "Begivenheter",
|
||||
"plants": "Planter",
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "Status podsystemu nieznany"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Zdrowy",
|
||||
"degraded": "Zdegradowany",
|
||||
"no_data": "Brak danych o miejscu przechowywania"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "Rx",
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adres",
|
||||
"expires": "Wygasa za",
|
||||
"never": "Nigdy",
|
||||
"user": "Użytkownik",
|
||||
"hostname": "Host",
|
||||
"name": "Nazwa",
|
||||
"client_version": "Wersja klienta",
|
||||
"os": "System operacyjny",
|
||||
"created": "Utworzony",
|
||||
"authorized": "Autoryzowany",
|
||||
"is_external": "Jest zewnętrzny",
|
||||
"update_available": "Dostępna aktualizacja",
|
||||
"tags": "Tagi",
|
||||
"last_seen": "Ostatnio dostępny",
|
||||
"now": "Teraz",
|
||||
"years": "{{number}}rok/lat",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}godzin",
|
||||
"minutes": "{{number}}miesięcy",
|
||||
"seconds": "{{number}}sekund",
|
||||
"ago": "{{value}} temu"
|
||||
"ago": "{{value}} temu",
|
||||
"true": "Tak",
|
||||
"false": "Nie"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Zapytania",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Ostrzeżenia",
|
||||
"criticals": "Krytyczny"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "Tytuł",
|
||||
"priority": "Priorytet",
|
||||
"lastReceived": "Ostatnio odebrane",
|
||||
"message": "Wiadomość",
|
||||
"tags": "Tagi",
|
||||
"none": "Brak",
|
||||
"min": "Min",
|
||||
"low": "Niski",
|
||||
"default": "Domyślny",
|
||||
"high": "Wysoki",
|
||||
"urgent": "Pilny"
|
||||
},
|
||||
"plantit": {
|
||||
"events": "Wydarzenia",
|
||||
"plants": "Rośliny",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Endereço",
|
||||
"expires": "Expira",
|
||||
"never": "Nunca",
|
||||
"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": "Última Vez Visto",
|
||||
"now": "Agora",
|
||||
"years": "{{number}}y",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Atrás"
|
||||
"ago": "{{value}} Atrás",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Avisos",
|
||||
"criticals": "Críticos"
|
||||
},
|
||||
"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": "Eventos",
|
||||
"plants": "Plantas",
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "Status do Subsistema desconhecido"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Saudável",
|
||||
"degraded": "Degradado",
|
||||
"no_data": "Sem dados de armazenamento disponíveis"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -114,9 +114,9 @@
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Jogando",
|
||||
"transcoding": "Transcoding",
|
||||
"transcoding": "Transcodificando",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"no_active": "Sem Transmissões Ativas",
|
||||
"movies": "Filmes",
|
||||
"series": "Séries",
|
||||
"episodes": "Episódios",
|
||||
@@ -190,10 +190,10 @@
|
||||
"plex_connection_error": "Verifique a conexão do Plex"
|
||||
},
|
||||
"tracearr": {
|
||||
"no_active": "No Active Streams",
|
||||
"streams": "Streams",
|
||||
"transcodes": "Transcodes",
|
||||
"directplay": "Direct Play",
|
||||
"no_active": "Sem Transmissões Ativas",
|
||||
"streams": "Transmissões",
|
||||
"transcodes": "Transcodificações",
|
||||
"directplay": "Reprodução direta",
|
||||
"bitrate": "Bitrate"
|
||||
},
|
||||
"omada": {
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Endereço",
|
||||
"expires": "Expira em",
|
||||
"never": "Nunca",
|
||||
"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": "Visto por último",
|
||||
"now": "Agora",
|
||||
"years": "{{number}}a",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Atrás"
|
||||
"ago": "{{value}} Atrás",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Consultas",
|
||||
@@ -619,13 +631,13 @@
|
||||
"total": "Total"
|
||||
},
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"orgs": "Organizações",
|
||||
"sites": "Sites",
|
||||
"resources": "Recursos",
|
||||
"targets": "Targets",
|
||||
"traffic": "Traffic",
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
"targets": "Alvos",
|
||||
"traffic": "Tráfego",
|
||||
"in": "Em",
|
||||
"out": "Saída"
|
||||
},
|
||||
"peanut": {
|
||||
"battery_charge": "Carga da bateria",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Alertas",
|
||||
"criticals": "Críticos"
|
||||
},
|
||||
"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": "Eventos",
|
||||
"plants": "Plantas",
|
||||
@@ -1164,11 +1189,11 @@
|
||||
"images": "Imagens",
|
||||
"image_updates": "Atualizações de Imagem",
|
||||
"images_unused": "Não utilizado",
|
||||
"environment_required": "Environment ID Required"
|
||||
"environment_required": "ID do ambiente necessário"
|
||||
},
|
||||
"dockhand": {
|
||||
"running": "Executando",
|
||||
"stopped": "Stopped",
|
||||
"stopped": "Parado",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memória",
|
||||
"images": "Imagens",
|
||||
@@ -1178,12 +1203,12 @@
|
||||
"stacks": "Pilhas",
|
||||
"paused": "Pausado",
|
||||
"total": "Total",
|
||||
"environment_not_found": "Environment Not Found"
|
||||
"environment_not_found": "Ambiente não encontrado"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"eaten": "Eaten",
|
||||
"burned": "Burned",
|
||||
"remaining": "Remaining",
|
||||
"steps": "Steps"
|
||||
"eaten": "Comido",
|
||||
"burned": "Queimado",
|
||||
"remaining": "Restante",
|
||||
"steps": "Passos"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresă",
|
||||
"expires": "Expiră",
|
||||
"never": "Niciodată",
|
||||
"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": "Acum",
|
||||
"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": "Critice"
|
||||
},
|
||||
"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": "Evenimente",
|
||||
"plants": "Plante",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Адрес",
|
||||
"expires": "Истекает",
|
||||
"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": "Последнее посещение",
|
||||
"now": "Только что",
|
||||
"years": "{{number}}г",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}ч",
|
||||
"minutes": "{{number}}м",
|
||||
"seconds": "{{number}}с",
|
||||
"ago": "{{value}} назад"
|
||||
"ago": "{{value}} назад",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Запросы",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Предупреждения",
|
||||
"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": "События",
|
||||
"plants": "Растения",
|
||||
|
||||
@@ -295,12 +295,12 @@
|
||||
"available": "Dostupné"
|
||||
},
|
||||
"seerr": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"available": "Available",
|
||||
"completed": "Completed",
|
||||
"pending": "Čakajúce",
|
||||
"approved": "Schválené",
|
||||
"available": "Dostupné",
|
||||
"completed": "Dokončené",
|
||||
"processing": "Processing",
|
||||
"issues": "Open Issues"
|
||||
"issues": "Neriešené problémy"
|
||||
},
|
||||
"netalertx": {
|
||||
"total": "Celkom",
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adresa",
|
||||
"expires": "Vyprší",
|
||||
"never": "Nikdy",
|
||||
"user": "Používateľ",
|
||||
"hostname": "Názov hostiteľa",
|
||||
"name": "Názov",
|
||||
"client_version": "Verzia klienta",
|
||||
"os": "Operačný systém",
|
||||
"created": "Vytvorené",
|
||||
"authorized": "Autorizované",
|
||||
"is_external": "Je externý",
|
||||
"update_available": "Dostupná aktualizácia",
|
||||
"tags": "Štítky",
|
||||
"last_seen": "Naposledy videné",
|
||||
"now": "Teraz",
|
||||
"years": "{{number}}r",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "Pred {{value}}"
|
||||
"ago": "Pred {{value}}",
|
||||
"true": "Áno",
|
||||
"false": "Nie"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Požiadaviek",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Upozornenia",
|
||||
"criticals": "Kritické"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "Názov",
|
||||
"priority": "Priorita",
|
||||
"lastReceived": "Posledná prijatá",
|
||||
"message": "Správa",
|
||||
"tags": "Štítky",
|
||||
"none": "Žiadna",
|
||||
"min": "Min",
|
||||
"low": "Nízka",
|
||||
"default": "Predvolená",
|
||||
"high": "Vysoká",
|
||||
"urgent": "Naliehavé"
|
||||
},
|
||||
"plantit": {
|
||||
"events": "Udalosti",
|
||||
"plants": "Rastliny",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Naslov",
|
||||
"expires": "Poteče",
|
||||
"never": "Nikoli",
|
||||
"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": "Viden",
|
||||
"now": "Sedaj",
|
||||
"years": "{{number}}l",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}u",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} nazaj"
|
||||
"ago": "{{value}} nazaj",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Opozorila",
|
||||
"criticals": "Kritično"
|
||||
},
|
||||
"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": "Dogodki",
|
||||
"plants": "Rastline",
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
"empty_data": "Статус подсистема непознат"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Здрав",
|
||||
"degraded": "Деградирано",
|
||||
"no_data": "Нема доступних података о складиштењу"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Адреса",
|
||||
"expires": "Истиче",
|
||||
"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": "Последње виђен",
|
||||
"now": "Сада",
|
||||
"years": "{{number}}г",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}ч",
|
||||
"minutes": "{{number}}м",
|
||||
"seconds": "{{number}}с",
|
||||
"ago": "Пре {{value}}"
|
||||
"ago": "Пре {{value}}",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Упити",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Упозорења",
|
||||
"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": "Догађаји",
|
||||
"plants": "Биљке",
|
||||
@@ -1181,9 +1206,9 @@
|
||||
"environment_not_found": "Окружење није пронађено"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"eaten": "Eaten",
|
||||
"burned": "Burned",
|
||||
"remaining": "Remaining",
|
||||
"steps": "Steps"
|
||||
"eaten": "Поједено",
|
||||
"burned": "Спаљено",
|
||||
"remaining": "Преостало",
|
||||
"steps": "Кораци"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Adres",
|
||||
"expires": "Geciken",
|
||||
"never": "Asla",
|
||||
"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": "Son Görülme",
|
||||
"now": "Şimdi",
|
||||
"years": "{{number}} Yıl",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}} Saat",
|
||||
"minutes": "{{number}} Dakika",
|
||||
"seconds": "{{number}} Saniye",
|
||||
"ago": "{{value}} Önce"
|
||||
"ago": "{{value}} Önce",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Sorgular",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Uyarılar",
|
||||
"criticals": "Kritik"
|
||||
},
|
||||
"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": "Etkinlikler",
|
||||
"plants": "Bitkiler",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "Адреса",
|
||||
"expires": "Дійсний до",
|
||||
"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": "Востаннє у мережі",
|
||||
"now": "Зараз",
|
||||
"years": "{{number}}р",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}год",
|
||||
"minutes": "{{number}}хв",
|
||||
"seconds": "{{number}}с",
|
||||
"ago": "{{value}} тому"
|
||||
"ago": "{{value}} тому",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "Попередження",
|
||||
"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": "Події",
|
||||
"plants": "Рослини",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "位址",
|
||||
"expires": "已失效",
|
||||
"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": "上次連線",
|
||||
"now": "現在",
|
||||
"years": "{{number}} 年",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}} 小時",
|
||||
"minutes": "{{number}} 分鐘",
|
||||
"seconds": "{{number}} 秒",
|
||||
"ago": "{{value}} 前"
|
||||
"ago": "{{value}} 前",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "警告",
|
||||
"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": "事件",
|
||||
"plants": "植物",
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
"download": "下载",
|
||||
"upload": "",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"seed": "做种"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"download": "下载速率",
|
||||
@@ -247,8 +247,8 @@
|
||||
"deluge": {
|
||||
"download": "下载",
|
||||
"upload": "上传",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"leech": "下载中",
|
||||
"seed": "做种"
|
||||
},
|
||||
"develancacheui": {
|
||||
"cachehitbytes": "缓存命中字节",
|
||||
@@ -256,7 +256,7 @@
|
||||
},
|
||||
"downloadstation": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"upload": "上传速率",
|
||||
"leech": "",
|
||||
"seed": "做种"
|
||||
},
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "地址",
|
||||
"expires": "失效",
|
||||
"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": "最后上线",
|
||||
"now": "现在",
|
||||
"years": "{{number}}年",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} 以前"
|
||||
"ago": "{{value}} 以前",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -458,7 +470,7 @@
|
||||
"cpu": "CPU",
|
||||
"load": "负载",
|
||||
"wait": "请稍候",
|
||||
"temp": "转速",
|
||||
"temp": "温度",
|
||||
"_temp": "Temp",
|
||||
"warn": "Warn",
|
||||
"uptime": "运行时间",
|
||||
@@ -715,7 +727,7 @@
|
||||
},
|
||||
"komga": {
|
||||
"libraries": "书库",
|
||||
"series": "Series",
|
||||
"series": "系列",
|
||||
"books": "Books"
|
||||
},
|
||||
"diskstation": {
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "警告",
|
||||
"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": "事件",
|
||||
"plants": "植物",
|
||||
|
||||
@@ -344,6 +344,16 @@
|
||||
"address": "位址",
|
||||
"expires": "已過期",
|
||||
"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": "上次連線",
|
||||
"now": "現在",
|
||||
"years": "{{number}} 年",
|
||||
@@ -352,7 +362,9 @@
|
||||
"hours": "{{number}} 小時",
|
||||
"minutes": "{{number}} 分鐘",
|
||||
"seconds": "{{number}} 秒",
|
||||
"ago": "{{value}} 前"
|
||||
"ago": "{{value}} 前",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
@@ -924,6 +936,19 @@
|
||||
"warnings": "警告",
|
||||
"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": "事件",
|
||||
"plants": "植物",
|
||||
|
||||
@@ -92,6 +92,23 @@ describe("pages/api/widgets/glances", () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("falls back to version 3 when version is invalid", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))])
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))])
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]);
|
||||
|
||||
const req = { query: { index: "0", version: "3/../../secret-endpoint" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith("http://glances/api/3/cpu", expect.any(Object));
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 when glances returns 401", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
||||
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
|
||||
import createLogger from "utils/logger";
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import { httpProxy } from "utils/proxy/http";
|
||||
|
||||
const logger = createLogger("glances");
|
||||
@@ -45,7 +46,7 @@ export default async function handler(req, res) {
|
||||
const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query;
|
||||
|
||||
const privateWidgetOptions = await getPrivateWidgetOptions("glances", index);
|
||||
privateWidgetOptions.version = version ?? 3;
|
||||
privateWidgetOptions.version = parseVersionForUrl(version, 3);
|
||||
|
||||
try {
|
||||
const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, "cpu");
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getKubeConfig } from "utils/config/kubernetes";
|
||||
import * as shvl from "utils/config/shvl";
|
||||
import kubernetes from "utils/kubernetes/export";
|
||||
import createLogger from "utils/logger";
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
|
||||
const logger = createLogger("service-helpers");
|
||||
|
||||
@@ -113,7 +114,7 @@ export async function servicesFromDocker() {
|
||||
}
|
||||
let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
|
||||
if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) {
|
||||
substitutedVal = parseInt(substitutedVal, 10);
|
||||
substitutedVal = parseVersionForUrl(substitutedVal);
|
||||
}
|
||||
shvl.set(constructedService, value, substitutedVal);
|
||||
}
|
||||
@@ -590,7 +591,7 @@ export function cleanServiceGroups(groups) {
|
||||
"vikunja",
|
||||
].includes(type)
|
||||
) {
|
||||
if (version) widget.version = parseInt(version, 10);
|
||||
widget.version = parseVersionForUrl(version);
|
||||
}
|
||||
if (type === "glances") {
|
||||
if (metric) widget.metric = metric;
|
||||
@@ -664,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") {
|
||||
|
||||
@@ -12,6 +12,22 @@ export function formatApiCall(url, args) {
|
||||
return url.replace(find, replace).replace(find, replace);
|
||||
}
|
||||
|
||||
export function parseVersionForUrl(version, defaultValue = null) {
|
||||
if (version === undefined || version === null || version === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof version === "number") {
|
||||
return Number.isInteger(version) && version >= 0 ? version : defaultValue;
|
||||
}
|
||||
|
||||
if (typeof version === "string" && /^\d+$/.test(version)) {
|
||||
return Number(version);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function getURLSearchParams(widget, endpoint) {
|
||||
const params = new URLSearchParams({
|
||||
group: widget.service_group,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getURLSearchParams,
|
||||
jsonArrayFilter,
|
||||
jsonArrayTransform,
|
||||
parseVersionForUrl,
|
||||
sanitizeErrorURL,
|
||||
} from "./api-helpers";
|
||||
|
||||
@@ -21,6 +22,20 @@ describe("utils/proxy/api-helpers", () => {
|
||||
expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-");
|
||||
});
|
||||
|
||||
it("parseVersionForUrl accepts canonical non-negative integers", () => {
|
||||
expect(parseVersionForUrl("3")).toBe(3);
|
||||
expect(parseVersionForUrl(4)).toBe(4);
|
||||
expect(parseVersionForUrl(undefined, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it("parseVersionForUrl rejects non-canonical values", () => {
|
||||
expect(parseVersionForUrl("3/../../path", 3)).toBe(3);
|
||||
expect(parseVersionForUrl("1e2", 3)).toBe(3);
|
||||
expect(parseVersionForUrl("0x10", 3)).toBe(3);
|
||||
expect(parseVersionForUrl(-1, 3)).toBe(3);
|
||||
expect(parseVersionForUrl(Number.NaN, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it("getURLSearchParams includes group/service/index and optionally endpoint", () => {
|
||||
const widget = { service_group: "g", service_name: "s", index: "0" };
|
||||
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import cache from "memory-cache";
|
||||
|
||||
import getServiceWidget from "utils/config/service-helpers";
|
||||
import createLogger from "utils/logger";
|
||||
import { asJson, formatApiCall } from "utils/proxy/api-helpers";
|
||||
import { asJson, formatApiCall, parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import { httpProxy } from "utils/proxy/http";
|
||||
import widgets from "widgets/widgets";
|
||||
|
||||
@@ -56,7 +56,7 @@ async function getApiInfo(serviceWidget, apiName, serviceName) {
|
||||
const json = asJson(data);
|
||||
if (json?.data?.[apiName]) {
|
||||
cgiPath = json.data[apiName].path;
|
||||
maxVersion = json.data[apiName].maxVersion;
|
||||
maxVersion = parseVersionForUrl(json.data[apiName].maxVersion);
|
||||
logger.debug(
|
||||
`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`,
|
||||
);
|
||||
|
||||
+27
-7
@@ -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")),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const statusMap = {
|
||||
@@ -19,11 +20,12 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
|
||||
const idKey = version === 3 ? "Id" : "id";
|
||||
const statusKey = version === 3 ? "Status" : "status";
|
||||
const idKey = apiVersion === 3 ? "Id" : "id";
|
||||
const statusKey = apiVersion === 3 ? "Status" : "status";
|
||||
|
||||
const { data, error } = useWidgetAPI(service.widget, `${version}/containers`, {
|
||||
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/containers`, {
|
||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
|
||||
@@ -16,14 +17,15 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
|
||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||
|
||||
const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, {
|
||||
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/cpu`, {
|
||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||
});
|
||||
|
||||
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`);
|
||||
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||
@@ -16,6 +17,7 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
const [, diskName] = widget.metric.split(":");
|
||||
|
||||
const [dataPoints, setDataPoints] = useState(
|
||||
@@ -23,7 +25,7 @@ export default function Component({ service }) {
|
||||
);
|
||||
const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
|
||||
|
||||
const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, {
|
||||
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/diskio`, {
|
||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const defaultInterval = 1000;
|
||||
@@ -11,10 +12,11 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
const [, fsName] = widget.metric.split("fs:");
|
||||
const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
|
||||
|
||||
const { data, error } = useWidgetAPI(widget, `${version}/fs`, {
|
||||
const { data, error } = useWidgetAPI(widget, `${apiVersion}/fs`, {
|
||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||
@@ -16,11 +17,12 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
const [, gpuName] = widget.metric.split(":");
|
||||
|
||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
|
||||
|
||||
const { data, error } = useWidgetAPI(widget, `${version}/gpu`, {
|
||||
const { data, error } = useWidgetAPI(widget, `${apiVersion}/gpu`, {
|
||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
function Swap({ quicklookData, className = "" }) {
|
||||
@@ -75,12 +76,13 @@ const defaultSystemInterval = 30000; // This data (OS, hostname, distribution) i
|
||||
export default function Component({ service }) {
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
|
||||
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, {
|
||||
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`, {
|
||||
refreshInterval,
|
||||
});
|
||||
|
||||
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, {
|
||||
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${apiVersion}/system`, {
|
||||
refreshInterval: defaultSystemInterval,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||
@@ -17,10 +18,11 @@ export default function Component({ service }) {
|
||||
const { widget } = service;
|
||||
const { chart } = widget;
|
||||
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
|
||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||
|
||||
const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, {
|
||||
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/mem`, {
|
||||
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||
@@ -17,15 +18,16 @@ export default function Component({ service }) {
|
||||
const { widget } = service;
|
||||
const { chart, metric } = widget;
|
||||
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
|
||||
const rxKey = version === 3 ? "rx" : "bytes_recv";
|
||||
const txKey = version === 3 ? "tx" : "bytes_sent";
|
||||
const rxKey = apiVersion === 3 ? "rx" : "bytes_recv";
|
||||
const txKey = apiVersion === 3 ? "tx" : "bytes_sent";
|
||||
|
||||
const [, interfaceName] = metric.split(":");
|
||||
|
||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||
|
||||
const { data, error } = useWidgetAPI(widget, `${version}/network`, {
|
||||
const { data, error } = useWidgetAPI(widget, `${apiVersion}/network`, {
|
||||
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const statusMap = {
|
||||
@@ -22,10 +23,11 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
|
||||
const memoryInfoKey = version === 3 ? 0 : "rss";
|
||||
const memoryInfoKey = apiVersion === 3 ? 0 : "rss";
|
||||
|
||||
const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {
|
||||
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/processlist`, {
|
||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Block from "../components/block";
|
||||
import Container from "../components/container";
|
||||
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
|
||||
@@ -16,11 +17,12 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||
const apiVersion = parseVersionForUrl(version, 3);
|
||||
const [, sensorName] = widget.metric.split(":");
|
||||
|
||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||
|
||||
const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, {
|
||||
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/sensors`, {
|
||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||
const widget = {
|
||||
api: "{url}/api/{endpoint}",
|
||||
proxyHandler: credentialedProxyHandler,
|
||||
allowedEndpoints: /\d\/quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers/,
|
||||
allowedEndpoints: /^\d+\/(quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers)$/,
|
||||
};
|
||||
|
||||
export default widget;
|
||||
|
||||
@@ -8,6 +8,10 @@ describe("glances widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
expect(widget.allowedEndpoints?.test("3/quicklook")).toBe(true);
|
||||
expect(widget.allowedEndpoints?.test("12/cpu")).toBe(true);
|
||||
expect(widget.allowedEndpoints?.test("unknown")).toBe(false);
|
||||
expect(widget.allowedEndpoints?.test("xxcpuyy")).toBe(false);
|
||||
expect(widget.allowedEndpoints?.test("3/cpu/extra")).toBe(false);
|
||||
expect(widget.allowedEndpoints?.test("membrane")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+274
-168
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user