Compare commits

...

64 Commits

Author SHA1 Message Date
shamoon 5bfe18b3e5 Merge branch 'dev'
Tests / vitest (4) (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Docs / Test Build Docs (push) Has been cancelled
Docs / Build & Deploy Docs (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
2026-05-08 15:59:26 -07:00
github-actions[bot] 1ac5012c94 New Crowdin translations by GitHub Action (#6496)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 15:56:34 -07:00
shamoon 27b1c3f5f4 Merge branch 'dev' 2026-05-08 15:54:10 -07:00
shamoon bb765a6747 1.13.0 2026-05-08 15:53:50 -07:00
shamoon aab4f5125b Chore: add pnpm workspace allowBuilds config (#6645) 2026-05-08 15:09:17 -07:00
shamoon 6d1e56bca0 Update bug report template for issue closure notice 2026-05-08 13:36:24 -07:00
dependabot[bot] 4603e0ee57 Chore(deps): Bump release-drafter/release-drafter from 7.2.0 to 7.2.1 (#6641)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 16:54:39 +00:00
dependabot[bot] 9a983a5be1 Chore(deps): Bump pnpm/action-setup from 6.0.3 to 6.0.4 (#6642)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 09:50:03 -07:00
Thielquis 23e66a265d Fix: Make calendar hover event toggle explicit (#6639)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-05-07 07:15:25 -07:00
Rob van Oostenrijk ce8f020b7b Enhancement: Add node parameter to Technitium DNS Widget (#6638)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-05-06 23:58:48 -07:00
shamoon 72b0e493d0 Enhancement: Normalize non-200 proxy responses to error JSON (#6630)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-05-05 13:30:12 -07:00
dependabot[bot] ffb264cead Chore(deps): Bump next from 16.2.3 to 16.2.4 (#6622)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 04:20:22 +00:00
dependabot[bot] cd6e708af2 Chore(deps): Bump react from 19.2.4 to 19.2.5 (#6621)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-05-03 21:15:50 -07:00
dependabot[bot] 4e621aee8f Chore(deps-dev): Bump @eslint/js from 9.39.2 to 9.39.4 (#6623)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 04:15:05 +00:00
dependabot[bot] 93364b6a27 Chore(deps): Bump json-rpc-2.0 from 1.7.0 to 1.7.1 (#6624)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 04:08:43 +00:00
dependabot[bot] 3dffc38462 Chore(deps): Bump @headlessui/react from 2.2.9 to 2.2.10 (#6620)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 21:03:46 -07:00
dependabot[bot] b2a52ff2a9 Chore(deps): Bump pnpm/action-setup from 5.0.0 to 6.0.3 (#6614)
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 22:57:08 +00:00
dependabot[bot] 081b9ec8c3 Chore(deps): Bump crowdin/github-action from 2.16.0 to 2.16.2 (#6615)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 22:53:03 +00:00
dependabot[bot] 977be863c7 Chore(deps): Bump actions/setup-node from 6.3.0 to 6.4.0 (#6613)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 22:48:57 +00:00
shamoon 77c9ac4a2c Fix: add missing backdrop blur classes to safelist (#6617) 2026-04-30 14:52:32 -07:00
pigawatts b6b00aab86 Documentation: update watchtower links to active fork (#6608)
Crowdin Action / Crowdin Sync (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Docs / Test Build Docs (push) Has been cancelled
Docs / Build & Deploy Docs (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (push) Has been cancelled
2026-04-28 16:27:42 -07:00
Jim Strang c3b4fc17c4 Feature: ntfy widget (#6601)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Co-authored-by: Jim Strang <jimstrang@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-04-26 20:38:14 -07:00
dependabot[bot] 9b8e771268 Chore(deps-dev): Bump postcss from 8.5.8 to 8.5.10 in the npm_and_yarn group across 1 directory (#6596)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 18:48:21 -07:00
shamoon 44c1e8393e Enhancement: support custom labels for cpu temp sensors (#6595)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-04-24 10:20:15 -07:00
dependabot[bot] 7ed1260f0d Chore(deps): Bump actions/cache from 5.0.4 to 5.0.5 (#6593)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 17:39:26 +00:00
dependabot[bot] 96f4777637 Chore(deps): Bump peakoss/anti-slop from 0.2.1 to 0.3.0 (#6590)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 16:41:27 +00:00
dependabot[bot] f762135eb3 Chore(deps): Bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#6592)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 16:37:02 +00:00
finlay-mcaree d721bb661f Enhancement: add additional fields to Tailscale Widget (#6589)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-04-22 13:01:31 -07:00
dependabot[bot] bf55e8acab Chore(deps): Bump i18next-fs-backend from 2.6.1 to 2.6.4 in the npm_and_yarn group across 1 directory (#6588)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 11:17:35 -07:00
DieselTech f82e0d9519 Documentation: update mylar link (#6587)
Crowdin Action / Crowdin Sync (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Docs / Test Build Docs (push) Has been cancelled
Docs / Build & Deploy Docs (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (push) Has been cancelled
2026-04-22 07:37:16 -07:00
shamoon 8322dd9016 Fix: use seconds for PBS since (#6583)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-04-19 07:53:25 -07:00
Jérémie Astori 10ade1d32f Fix: Swap cputemp and disk resources in glances loading state skeleton (#6577)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-04-17 23:18:46 -07:00
dependabot[bot] a0f9fbf614 Chore(deps): Bump actions/github-script from 8.0.0 to 9.0.0 (#6572)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 06:31:18 +00:00
dependabot[bot] 0c8fd6904e Chore(deps): Bump protobufjs from 7.5.4 to 7.5.5 in the npm_and_yarn group across 1 directory (#6575)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 23:27:05 -07:00
dependabot[bot] b2b299d634 Chore(deps): Bump actions/upload-pages-artifact from 4.0.0 to 5.0.0 (#6571)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 17:33:19 +00:00
dependabot[bot] b8672ddc9b Chore(deps): Bump release-drafter/release-drafter from 7.1.1 to 7.2.0 (#6570)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 17:29:04 +00:00
dependabot[bot] 03dd54ee2e Chore(deps): Bump docker/build-push-action from 7.0.0 to 7.1.0 (#6573)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 10:25:03 -07:00
dependabot[bot] 92ec6ccefc Chore(deps): Bump follow-redirects from 1.15.11 to 1.16.0 in the npm_and_yarn group across 1 directory (#6568)
Docker CI / Docker Build & Push (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 12:56:56 -07:00
Steven Harris 463e94d933 Fix: use v2 storage API for UniFi Drive widget (#6567) 2026-04-15 15:10:45 +00:00
shamoon a7bab17f97 Enhancement: support pyload API key, fix error message (#6558)
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
2026-04-12 11:00:54 -07:00
shamoon 1accddf29a Fix: prevent omada race conditions with auth and cookie caching (#6549)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-04-10 15:48:38 -07:00
dependabot[bot] 636d62106c Chore(deps): Bump next from 16.1.7 to 16.2.3 in the npm_and_yarn group across 1 directory (#6547)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 09:47:20 -07:00
shamoon d048888d99 Trigger Docker publish on fix/** branches 2026-04-10 09:24:05 -07:00
dependabot[bot] 24804f39fc Chore(deps): Bump docker/login-action from 4.0.0 to 4.1.0 (#6540)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 00:23:32 +00:00
dependabot[bot] 70f8c67d3c Chore(deps): Bump astral-sh/setup-uv from 7.6.0 to 8.0.0 (#6541)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 00:19:12 +00:00
dependabot[bot] 038f5c3b9f Chore(deps): Bump actions/deploy-pages from 4.0.5 to 5.0.0 (#6542)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 00:15:15 +00:00
dependabot[bot] ade4c733c7 Chore(deps): Bump codecov/codecov-action from 5.5.4 to 6.0.0 (#6543)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 17:11:26 -07:00
dependabot[bot] bdbd2e6ff0 Chore(deps): Bump vite from 7.3.1 to 7.3.2 in the npm_and_yarn group across 1 directory (#6539)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-08 22:44:10 -07:00
shamoon 5f4b0b4e33 Enhancement: Cache and reuse keep-alive HTTP(S) agents (#6536)
Release Drafter / Auto Label PR (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-04-07 08:04:35 -07:00
Gabe Dunn a56a05b553 Documentation: add reference to required settings to ingressroute docs (#6527)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-04-06 09:45:47 -07:00
shamoon 11d1ceb755 Enhancement: increase resources page size for pangolin widget (#6523)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
2026-04-05 07:21:06 -07:00
dependabot[bot] 6df1cfdeba Chore(deps): Bump peakoss/anti-slop from a5a4b2440c9de6f65b64f0718a0136a1fdb04f6f to 85daca1880e9e1af197fc06ea03349daf08f4202 (#6507)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-05 04:07:54 +00:00
dependabot[bot] d64fbae776 Chore(deps): Update release-drafter/release-drafter requirement to 139054aeaa9adc52ab36ddf67437541f039b88e2 (#6504)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-05 04:04:05 +00:00
dependabot[bot] 5f7e5430c4 Chore(deps): Bump actions/configure-pages from 5.0.0 to 6.0.0 (#6506)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-05 03:59:04 +00:00
dependabot[bot] 3cc3986ec3 Chore(deps): Bump pnpm/action-setup from a8198c4bff370c8506180b035930dea56dbd5288 to fc06bc1257f339d1d5d8b3a19a8cae5388b55320 (#6505)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-05 03:55:12 +00:00
dependabot[bot] 5af0ab1686 Chore(deps): Bump astral-sh/setup-uv from 94527f2e458b27549849d47d273a16bec83a01e9 to 37802adc94f370d6bfd71619e3f0bf239e1f3b78 (#6503)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-04 20:49:43 -07:00
dependabot[bot] f0f5c3c15c Chore(deps-dev): Bump @tailwindcss/forms from 0.5.10 to 0.5.11 (#6500)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:02:55 +00:00
dependabot[bot] 2ed1da4411 Chore(deps): Bump i18next from 25.8.0 to 25.10.9 (#6501)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 21:58:21 +00:00
dependabot[bot] e1023466b1 Chore(deps-dev): Bump @eslint/compat from 2.0.2 to 2.0.3 (#6498)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 21:53:25 +00:00
dependabot[bot] 9fe5ad62f1 Chore(deps-dev): Bump postcss from 8.5.6 to 8.5.8 (#6499)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 21:47:23 +00:00
dependabot[bot] 173883000f Chore(deps): Bump dockerode from 4.0.7 to 4.0.10 (#6497)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 14:42:40 -07:00
shamoon d6e7e7e790 1.12.3
Crowdin Action / Crowdin Sync (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Docs / Test Build Docs (push) Has been cancelled
Docs / Build & Deploy Docs (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (push) Has been cancelled
2026-04-01 08:02:58 -07:00
shamoon 24cb274e03 Fix glances regex 2026-04-01 08:02:03 -07:00
shamoon af852e748a Normalize widget version in URLs 2026-04-01 08:00:20 -07:00
114 changed files with 3497 additions and 917 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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' }}
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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'
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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:
+3 -3
View File
@@ -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) {
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+3
View File
@@ -16,6 +16,7 @@ The Glances widget allows you to monitor the resources (CPU, memory, storage, te
cpu: true # optional, enabled by default, disable by setting to false
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
+1 -1
View File
@@ -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.
+36
View File
@@ -0,0 +1,36 @@
---
title: ntfy
description: ntfy Widget Configuration
---
Learn more about [ntfy](https://github.com/binwiederhier/ntfy).
This widget shows the latest notification for a ntfy topic, including the title or body, priority level, and when it was received. Works with both self-hosted ntfy instances and the public [ntfy.sh](https://ntfy.sh) service.
Allowed fields: `["title", "message", "priority", "lastReceived", "tags"]`.
Default fields: `["title", "message", "priority", "lastReceived"]`.
If more than 4 fields are provided, only the first 4 are displayed.
## Authentication
ntfy supports both public and private topics. For private instances or access-controlled topics, you can authenticate using either a **Bearer token** (ntfy access token) or **Basic auth** (username/password).
| Auth Method | Config Fields | Notes |
| ------------ | ------------------------------ | --------------------------------- |
| None | _(omit key/username/password)_ | For public topics |
| Bearer token | `key` | ntfy access tokens (`tk_` prefix) |
| Basic auth | `username` + `password` | Username/password credentials |
See the [ntfy documentation](https://docs.ntfy.sh/config/#access-control) for details on access control.
```yaml
widget:
type: ntfy
url: http://ntfy.host.or.ip:port # required
topic: mytopic # required
# key: tk_accesstoken # optional — for token auth
# username: user # optional — for basic auth
# password: pass # optional — for basic auth
```
+1
View File
@@ -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
```
+1 -1
View File
@@ -9,7 +9,7 @@ You will need to generate an API access token from the [keys page](https://login
To find your device ID, go to the [machine overview page](https://login.tailscale.com/admin/machines) and select your machine. In the "Machine Details" section, copy your `ID`. It will end with `CNTRL`.
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:
+5
View File
@@ -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`.
+2 -2
View File
@@ -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"]`.
+1
View File
@@ -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
View File
@@ -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"
]
}
}
+384 -350
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
packages:
- .
allowBuilds:
'@tailwindcss/oxide': true
core-js: false
cpu-features: false
esbuild: false
osx-temperature-sensor: true
protobufjs: false
sharp: true
ssh2: false
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- osx-temperature-sensor
- protobufjs
- sharp
+29 -4
View File
@@ -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",
+26 -1
View File
@@ -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": "نباتات",
+26 -1
View File
@@ -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": "Растения",
+26 -1
View File
@@ -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",
+27 -2
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+70 -45
View File
@@ -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"
}
}
+26 -1
View File
@@ -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": "צמחים",
+26 -1
View File
@@ -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",
+37 -12
View File
@@ -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"
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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
View File
@@ -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",
+26 -1
View File
@@ -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": "植物",
+29 -4
View File
@@ -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": "식물",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+51 -26
View File
@@ -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",
+26 -1
View File
@@ -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",
+29 -4
View File
@@ -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",
+26 -1
View File
@@ -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",
+47 -22
View File
@@ -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"
}
}
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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": "Растения",
+31 -6
View File
@@ -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",
+26 -1
View File
@@ -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",
+33 -8
View File
@@ -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": "Кораци"
}
}
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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": "Рослини",
+26 -1
View File
@@ -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",
+26 -1
View File
@@ -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": "植物",
+32 -7
View File
@@ -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": "植物",
+26 -1
View File
@@ -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")]);
+7 -2
View File
@@ -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: {
+3
View File
@@ -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",
+2 -1
View File
@@ -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");
+4 -2
View File
@@ -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") {
+16
View File
@@ -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,
+15
View File
@@ -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" };
+4 -1
View File
@@ -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;
}
}
}
+12
View File
@@ -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");
});
});
+14
View File
@@ -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 -2
View File
@@ -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
View File
@@ -224,23 +224,43 @@ function homepageDNSLookupFn() {
};
}
const homepageLookup = homepageDNSLookupFn();
const agentCache = new Map();
function getAgent(protocol, disableIpv6) {
const cacheKey = `${protocol}:${disableIpv6 ? "ipv4" : "auto"}`;
const cachedAgent = agentCache.get(cacheKey);
if (cachedAgent) {
return cachedAgent;
}
const agentOptions = {
keepAlive: true,
...(disableIpv6 ? { family: 4, autoSelectFamily: false } : { autoSelectFamilyAttemptTimeout: 500 }),
lookup: homepageLookup,
};
const agent =
protocol === "https:"
? new https.Agent({ ...agentOptions, rejectUnauthorized: false })
: new http.Agent(agentOptions);
agentCache.set(cacheKey, agent);
return agent;
}
export async function httpProxy(url, params = {}) {
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,
});
}
+16
View File
@@ -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");
+3 -3
View File
@@ -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>
);
+50
View File
@@ -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);
+1
View File
@@ -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")),
+5 -3
View File
@@ -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),
});
+4 -2
View File
@@ -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) {
+3 -1
View File
@@ -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 -1
View File
@@ -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),
});
+3 -1
View File
@@ -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),
});
+4 -2
View File
@@ -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,
});
+3 -1
View File
@@ -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 -3
View File
@@ -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 -2
View File
@@ -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),
});
+3 -1
View File
@@ -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),
});
+1 -1
View File
@@ -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;
+4
View File
@@ -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);
});
});
+63
View File
@@ -0,0 +1,63 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
const priorityLabels = {
1: "min",
2: "low",
3: "default",
4: "high",
5: "urgent",
};
function Truncated({ text }) {
return (
<span className="grid w-full" title={text}>
<span className="truncate">{text}</span>
</span>
);
}
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: messagesData, error: messagesError } = useWidgetAPI(widget, "messages");
if (messagesError) {
return <Container service={service} error={messagesError} />;
}
if (!widget.fields || widget.fields.length === 0) {
widget.fields = ["title", "message", "priority", "lastReceived"];
} else if (widget.fields?.length > 4) {
widget.fields = widget.fields.slice(0, 4);
}
if (!messagesData) {
return (
<Container service={service}>
<Block label="ntfy.title" />
<Block label="ntfy.message" />
<Block label="ntfy.priority" />
<Block label="ntfy.lastReceived" />
<Block label="ntfy.tags" />
</Container>
);
}
return (
<Container service={service}>
<Block label="ntfy.title" value={<Truncated text={messagesData.title ?? t("ntfy.none")} />} />
<Block label="ntfy.message" value={<Truncated text={messagesData.message ?? t("ntfy.none")} />} />
<Block label="ntfy.priority" value={t(`ntfy.${priorityLabels[messagesData.priority] ?? "default"}`)} />
<Block
label="ntfy.lastReceived"
value={messagesData.time ? t("common.relativeDate", { value: messagesData.time * 1000 }) : t("ntfy.none")}
/>
<Block label="ntfy.tags" value={messagesData.tags?.length > 0 ? messagesData.tags.join(", ") : t("ntfy.none")} />
</Container>
);
}
+216
View File
@@ -0,0 +1,216 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({
default: useWidgetAPI,
}));
import Component from "./component";
describe("widgets/ntfy/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "ntfy" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("ntfy.title")).toBeInTheDocument();
expect(screen.getByText("ntfy.message")).toBeInTheDocument();
expect(screen.getByText("ntfy.priority")).toBeInTheDocument();
expect(screen.getByText("ntfy.lastReceived")).toBeInTheDocument();
});
it("renders message data with default fields", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Disk Alert",
message: "Disk usage at 90%",
priority: 4,
time: 1700000000,
tags: ["warning"],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "ntfy.title", "Disk Alert");
expectBlockValue(container, "ntfy.message", "Disk usage at 90%");
expectBlockValue(container, "ntfy.priority", "ntfy.high");
});
it("shows placeholder for title when message has no title set", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: null,
message: "Simple notification",
priority: 3,
time: 1700000000,
tags: [],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "ntfy.title", "ntfy.none");
expectBlockValue(container, "ntfy.message", "Simple notification");
expectBlockValue(container, "ntfy.priority", "ntfy.default");
});
it("renders no messages state", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: null,
message: null,
priority: 3,
time: null,
tags: [],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "ntfy.title", "ntfy.none");
expectBlockValue(container, "ntfy.message", "ntfy.none");
expectBlockValue(container, "ntfy.lastReceived", "ntfy.none");
});
it("renders error when API fails", () => {
useWidgetAPI.mockImplementation(() => ({
data: undefined,
error: { message: "Request failed" },
}));
renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("Request failed")).toBeInTheDocument();
});
it("renders optional tags field when included", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Alert",
message: "Test",
priority: 5,
time: 1700000000,
tags: ["warning", "skull"],
},
error: undefined,
}));
const service = {
widget: {
type: "ntfy",
url: "https://ntfy.example.com",
topic: "alerts",
fields: ["title", "priority", "lastReceived", "tags"],
},
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expectBlockValue(container, "ntfy.tags", "warning, skull");
expectBlockValue(container, "ntfy.priority", "ntfy.urgent");
});
it("caps visible blocks at 4 when more than 4 fields are configured", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Alert",
message: "Body",
priority: 3,
time: 1700000000,
tags: ["a"],
},
error: undefined,
}));
const service = {
widget: {
type: "ntfy",
url: "https://ntfy.example.com",
topic: "alerts",
fields: ["title", "message", "priority", "lastReceived", "tags"],
},
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
});
it("falls back to default priority label when priority is out of range", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Alert",
message: "Body",
priority: 99,
time: 1700000000,
tags: [],
},
error: undefined,
}));
const { container } = renderWithProviders(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "ntfy.priority", "ntfy.default");
});
it("renders optional message field when included", () => {
useWidgetAPI.mockImplementation(() => ({
data: {
title: "Disk Alert",
message: "Disk usage at 90%",
priority: 4,
time: 1700000000,
tags: [],
},
error: undefined,
}));
const service = {
widget: {
type: "ntfy",
url: "https://ntfy.example.com",
topic: "alerts",
fields: ["title", "priority", "message"],
},
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expectBlockValue(container, "ntfy.title", "Disk Alert");
expectBlockValue(container, "ntfy.message", "Disk usage at 90%");
});
});
+14
View File
@@ -0,0 +1,14 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
messages: {
endpoint: "{topic}/json?poll=1&since=latest",
},
},
};
export default widget;
+11
View File
@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("ntfy widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});
+274 -168
View File
@@ -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}&currentPage=1&currentPageSize=1000`;
break;
case 5:
case 6:
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=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}&currentPage=1&currentPageSize=1000`;
break;
case 5:
case 6:
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=1000`;
break;
default:
break;
}
const site =
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}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(siteStatsUrl, {
headers: {
"Csrf-Token": token,
},
});
siteResponseData = JSON.parse(data);
if (status !== 200 || siteResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
return res.status(status === 200 ? 500 : status).json({
error: {
message: "Error getting stats",
url: siteStatsUrl,
data: siteResponseData,
},
[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}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(siteStatsUrl, {
headers: { ...headers },
});
siteResponseData = JSON.parse(data);
if (status !== 200 || siteResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
if (shouldRetryWithFreshSession(status, siteResponseData, attempt, usedCachedSession)) {
cache.del(sessionCacheId);
session = null;
continue;
}
return res.status(status === 200 ? 500 : status).json({
error: {
message: "Error getting stats",
url: siteStatsUrl,
data: siteResponseData,
},
});
}
const alertUrl =
controllerVersionMajor === 4
? `${url}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(alertUrl, {
headers: { ...headers },
});
const alertResponseData = JSON.parse(data);
if (status !== 200 || alertResponseData.errorCode > 0) {
if (shouldRetryWithFreshSession(status, alertResponseData, attempt, usedCachedSession)) {
cache.del(sessionCacheId);
session = null;
continue;
}
return res.status(status === 200 ? 500 : status).json({
error: {
message: "Error getting alerts",
url: alertUrl,
data: alertResponseData,
},
});
}
activeUser = siteResponseData.result.totalClientNum;
connectedAp = siteResponseData.result.connectedApNum;
connectedGateways = siteResponseData.result.connectedGatewayNum;
connectedSwitches = siteResponseData.result.connectedSwitchNum;
alerts = alertResponseData.result.alertNum;
}
return res.send(
JSON.stringify({
connectedAp,
activeUser,
alerts,
connectedGateways,
connectedSwitches,
}),
);
} catch (error) {
if (error instanceof SyntaxError && attempt === 0) {
cache.del(sessionCacheId);
session = null;
continue;
}
throw error;
}
const alertUrl =
controllerVersionMajor === 4
? `${url}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`;
[status, contentType, data] = await httpProxy(alertUrl, {
headers: {
"Csrf-Token": token,
},
});
const alertResponseData = JSON.parse(data);
activeUser = siteResponseData.result.totalClientNum;
connectedAp = siteResponseData.result.connectedApNum;
connectedGateways = siteResponseData.result.connectedGatewayNum;
connectedSwitches = siteResponseData.result.connectedSwitchNum;
alerts = alertResponseData.result.alertNum;
}
return res.send(
JSON.stringify({
connectedAp,
activeUser,
alerts,
connectedGateways,
connectedSwitches,
}),
);
}
}
+445 -9
View File
@@ -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