Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
830660b1ef | ||
|
|
f7451d182a | ||
|
|
bb92f5bb79 | ||
|
|
f7ff1e3911 | ||
|
|
5f80bebef2 | ||
|
|
97fe1f6a3e | ||
|
|
35fee2f380 | ||
|
|
e1e26b27de | ||
|
|
55cdddb274 | ||
|
|
dd85f5a9d5 | ||
|
|
8550c69d51 | ||
|
|
3dcee8cb86 | ||
|
|
d97e74047d | ||
|
|
e36892fc8f | ||
|
|
1d28bf4c93 | ||
|
|
8b076ce761 | ||
|
|
d78b2e1e85 | ||
|
|
f65c0125a9 | ||
|
|
253308357a | ||
|
|
295cc4a3c4 | ||
|
|
767fe946d7 | ||
|
|
aba963c7cf | ||
|
|
4529cabb8a | ||
|
|
7766c3cbbc | ||
|
|
eb95ee285b | ||
|
|
1421c381cc | ||
|
|
573d5f89d6 | ||
|
|
e01b909402 | ||
|
|
bd3535dcf8 | ||
|
|
c4bd513134 | ||
|
|
c64d903f2b | ||
|
|
f98750d0b3 | ||
|
|
c51424717d | ||
|
|
3172e6e0c4 | ||
|
|
7aa379076d | ||
|
|
023ffcf7bf | ||
|
|
fed28bd775 | ||
|
|
09718b8258 | ||
|
|
6040f063ca | ||
|
|
3d508088d5 | ||
|
|
37484ce032 | ||
|
|
ac3c2a6377 | ||
|
|
6e6edc8a0d | ||
|
|
b0d7f9a48c | ||
|
|
fdf700dba5 | ||
|
|
2bf67c3de8 | ||
|
|
593e3ee5a6 | ||
|
|
ad5da4e8ac | ||
|
|
58ef0960b5 | ||
|
|
68d6967d24 | ||
|
|
e5f0bb9792 | ||
|
|
3d68f4d128 | ||
|
|
cfca0ba583 | ||
|
|
9570abb2c3 | ||
|
|
5fcb7a3b9c | ||
|
|
a55acb9eb9 | ||
|
|
d37cdbd080 | ||
|
|
17174b6b96 | ||
|
|
0466a2808a | ||
|
|
1bfe397d68 | ||
|
|
60de6361bb | ||
|
|
47d7abc0d7 | ||
|
|
2d12c1e641 | ||
|
|
00053bda7b | ||
|
|
97390c6030 | ||
|
|
865a52ae7d | ||
|
|
fdca513929 | ||
|
|
ce5784b6c4 | ||
|
|
1a4c136d41 | ||
|
|
089734b293 | ||
|
|
2e59bfcd60 | ||
|
|
02fb1b9dae | ||
|
|
bf4a1cf1e8 | ||
|
|
4a0fb5d0bb | ||
|
|
c57b63b506 | ||
|
|
fa0e694ce0 | ||
|
|
ff1263b217 | ||
|
|
ac78e87042 | ||
|
|
ec7adb03b6 | ||
|
|
f5f39bc094 | ||
|
|
4e6ab037c9 | ||
|
|
a29556ee8e | ||
|
|
71d8f7920d | ||
|
|
835e007334 | ||
|
|
d46b8f63c7 | ||
|
|
a40ebb10c7 | ||
|
|
05dd6227fb | ||
|
|
7a3c836a70 | ||
|
|
4baec7e3ff | ||
|
|
93bd09c85e | ||
|
|
0ed6a79a5b | ||
|
|
0fe4ea9dd9 | ||
|
|
d6a48d132a | ||
|
|
c84dd2fd79 | ||
|
|
94263c445b | ||
|
|
4b0b892250 | ||
|
|
057ff2cfc1 | ||
|
|
45d285a76b | ||
|
|
924a7d076e | ||
|
|
f13be4a708 | ||
|
|
7fb6beceef | ||
|
|
a1fa4297ea | ||
|
|
f5e3138c81 | ||
|
|
d21755426f |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
|
||||
@@ -31,6 +31,10 @@ body:
|
||||
label: Version
|
||||
description: What version of Homarr are you running?
|
||||
options:
|
||||
- 1.18.0
|
||||
- 1.17.0
|
||||
- 1.16.0
|
||||
- 1.15.0
|
||||
- 1.14.0
|
||||
- 1.13.1
|
||||
- 1.13.0
|
||||
|
||||
Vendored
+1
@@ -26,6 +26,7 @@
|
||||
"Sabnzbd",
|
||||
"SeDemal",
|
||||
"Sonarr",
|
||||
"sslverify",
|
||||
"superjson",
|
||||
"tabler",
|
||||
"trpc",
|
||||
|
||||
@@ -1,3 +1,91 @@
|
||||
## [1.18.1](https://github.com/homarr-labs/homarr/compare/v1.18.0...v1.18.1) (2025-04-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* cannot find module http-cookie-agent/http ([#2972](https://github.com/homarr-labs/homarr/issues/2972)) ([bb92f5b](https://github.com/homarr-labs/homarr/commit/bb92f5bb79e8f236dd149e87d14c1a35955140c7))
|
||||
* **deps:** update dependency mysql2 to v3.14.1 ([#2969](https://github.com/homarr-labs/homarr/issues/2969)) ([5f80beb](https://github.com/homarr-labs/homarr/commit/5f80bebef2bd1598ad02dd1e169a1a6c27a4c143))
|
||||
|
||||
## [1.18.0](https://github.com/homarr-labs/homarr/compare/v1.17.0...v1.18.0) (2025-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
* add releases widget ([#2497](https://github.com/homarr-labs/homarr/issues/2497)) ([3dcee8c](https://github.com/homarr-labs/homarr/commit/3dcee8cb869dd11d54631636253ad17e2af97dd7))
|
||||
* **media-server:** add option to only show playing sessions ([#2899](https://github.com/homarr-labs/homarr/issues/2899)) ([f98750d](https://github.com/homarr-labs/homarr/commit/f98750d0b3e96b234d6f57b82bf7aa8729a091f2))
|
||||
* migrate to unifi package ([#2894](https://github.com/homarr-labs/homarr/issues/2894)) ([8550c69](https://github.com/homarr-labs/homarr/commit/8550c69d51497676b654e204a85cdc84e8a38e30))
|
||||
* **widgets:** add title to advanced options ([#2909](https://github.com/homarr-labs/homarr/issues/2909)) ([c64d903](https://github.com/homarr-labs/homarr/commit/c64d903f2b6375970325fb5031afd468dbf0ae11))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **certificates:** improve validation and prevent crash ([#2910](https://github.com/homarr-labs/homarr/issues/2910)) ([c514247](https://github.com/homarr-labs/homarr/commit/c51424717ddce5a2f4110658a1fbfab375185f66))
|
||||
* **deps:** update dependency @auth/core to ^0.39.0 ([#2904](https://github.com/homarr-labs/homarr/issues/2904)) ([b0d7f9a](https://github.com/homarr-labs/homarr/commit/b0d7f9a48c05d380ff84ed7f48c9a9fd8dc33602))
|
||||
* **deps:** update dependency @auth/drizzle-adapter to ^1.9.0 ([#2905](https://github.com/homarr-labs/homarr/issues/2905)) ([fdf700d](https://github.com/homarr-labs/homarr/commit/fdf700dba51d0bc4e72cdd63a63acb99c2196329))
|
||||
* **deps:** update dependency @ctrl/qbittorrent to ^9.6.0 ([#2914](https://github.com/homarr-labs/homarr/issues/2914)) ([3d50808](https://github.com/homarr-labs/homarr/commit/3d508088d5a7d7fda53f49c196c2c6dd84d598d1))
|
||||
* **deps:** update dependency @t3-oss/env-nextjs to ^0.13.0 ([#2935](https://github.com/homarr-labs/homarr/issues/2935)) ([eb95ee2](https://github.com/homarr-labs/homarr/commit/eb95ee285b727a8bda7ac9df3f75c25ced475358))
|
||||
* **deps:** update dependency dockerode to ^4.0.6 ([#2901](https://github.com/homarr-labs/homarr/issues/2901)) ([593e3ee](https://github.com/homarr-labs/homarr/commit/593e3ee5a6f9cb8d50a51262c85c3c067fce0691))
|
||||
* **deps:** update dependency drizzle-orm to ^0.43.0 ([#2939](https://github.com/homarr-labs/homarr/issues/2939)) ([295cc4a](https://github.com/homarr-labs/homarr/commit/295cc4a3c41ba9ad20e93df9d6ea5627d56d310d))
|
||||
* **deps:** update dependency drizzle-orm to ^0.43.1 ([#2947](https://github.com/homarr-labs/homarr/issues/2947)) ([1d28bf4](https://github.com/homarr-labs/homarr/commit/1d28bf4c93c43c1956c5b386ca8a02ca6ae59eb5))
|
||||
* **deps:** update dependency glob to ^11.0.2 ([#2931](https://github.com/homarr-labs/homarr/issues/2931)) ([573d5f8](https://github.com/homarr-labs/homarr/commit/573d5f89d6576020c3a4393372a56f8f75628e58))
|
||||
* **deps:** update dependency next-auth to v5.0.0-beta.26 ([#2906](https://github.com/homarr-labs/homarr/issues/2906)) ([6e6edc8](https://github.com/homarr-labs/homarr/commit/6e6edc8a0d809614588967b51751257929a6ccd3))
|
||||
* **deps:** update dependency next-auth to v5.0.0-beta.27 ([#2940](https://github.com/homarr-labs/homarr/issues/2940)) ([2533083](https://github.com/homarr-labs/homarr/commit/253308357a55ab2fac7ac4144215beefdc517b8d))
|
||||
* **deps:** update dependency next-intl to v4.0.3 ([#2922](https://github.com/homarr-labs/homarr/issues/2922)) ([3172e6e](https://github.com/homarr-labs/homarr/commit/3172e6e0c4e55f62a7ee32ca84fcaa940e774565))
|
||||
* **deps:** update dependency next-intl to v4.1.0 ([#2938](https://github.com/homarr-labs/homarr/issues/2938)) ([767fe94](https://github.com/homarr-labs/homarr/commit/767fe946d78268eddce6e213665176b8c6397e92))
|
||||
* **deps:** update dependency sass to ^1.87.0 ([#2921](https://github.com/homarr-labs/homarr/issues/2921)) ([fed28bd](https://github.com/homarr-labs/homarr/commit/fed28bd77546be83b9965bd1116996b621f6b639))
|
||||
* **deps:** update dependency trpc-to-openapi to ^2.2.0 ([#2903](https://github.com/homarr-labs/homarr/issues/2903)) ([2bf67c3](https://github.com/homarr-labs/homarr/commit/2bf67c3de8609eb504e322d830f507973a55d6fc))
|
||||
* **deps:** update dependency tsdav to ^2.1.4 ([#2944](https://github.com/homarr-labs/homarr/issues/2944)) ([d78b2e1](https://github.com/homarr-labs/homarr/commit/d78b2e1e85a6c72ce1366e50ee4a1e83176b7913))
|
||||
* **deps:** update dependency typescript-eslint to ^8.31.0 ([#2919](https://github.com/homarr-labs/homarr/issues/2919)) ([7aa3790](https://github.com/homarr-labs/homarr/commit/7aa379076da24c90238dd5fe2714dbceb3b84c2a))
|
||||
* **deps:** update mantine monorepo to ^7.17.5 ([#2937](https://github.com/homarr-labs/homarr/issues/2937)) ([aba963c](https://github.com/homarr-labs/homarr/commit/aba963c7cf0d554d51e5fc233e546e4917ecdf67))
|
||||
* **deps:** update trpc monorepo to ^11.1.1 ([#2927](https://github.com/homarr-labs/homarr/issues/2927)) ([bd3535d](https://github.com/homarr-labs/homarr/commit/bd3535dcf84569b87cf647bc5e0b957667b7e199))
|
||||
* **deps:** update turbo monorepo to ^2.5.1 ([#2934](https://github.com/homarr-labs/homarr/issues/2934)) ([7766c3c](https://github.com/homarr-labs/homarr/commit/7766c3cbbc1187f6c38157fe15182c8b7d329acf))
|
||||
* **deps:** update turbo monorepo to ^2.5.2 ([#2949](https://github.com/homarr-labs/homarr/issues/2949)) ([d97e740](https://github.com/homarr-labs/homarr/commit/d97e74047dc107abb05ca65d8a91a41c6071fbd4))
|
||||
|
||||
## [1.17.0](https://github.com/homarr-labs/homarr/compare/v1.16.0...v1.17.0) (2025-04-18)
|
||||
|
||||
### Features
|
||||
|
||||
* add api-key support for tdarr ([#2890](https://github.com/homarr-labs/homarr/issues/2890)) ([3d68f4d](https://github.com/homarr-labs/homarr/commit/3d68f4d1283b6cb27f9532d1adf52cef78a0fb03))
|
||||
* Prefer primary color ([#2665](https://github.com/homarr-labs/homarr/issues/2665)) ([00053bd](https://github.com/homarr-labs/homarr/commit/00053bda7bbcaaf359074c3b35573ee0afc1409d))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* calculate available space correctly and filter unmounted drives ([#2885](https://github.com/homarr-labs/homarr/issues/2885)) ([cfca0ba](https://github.com/homarr-labs/homarr/commit/cfca0ba58323ec09f8f64da2fee251ef14ba914a))
|
||||
* **deps:** update dependency drizzle-kit to ^0.31.0 ([#2881](https://github.com/homarr-labs/homarr/issues/2881)) ([97390c6](https://github.com/homarr-labs/homarr/commit/97390c60300f3d16dabf0710fa435c6b3ae86a6f))
|
||||
* **deps:** update dependency drizzle-orm to ^0.42.0 ([#2882](https://github.com/homarr-labs/homarr/issues/2882)) ([47d7abc](https://github.com/homarr-labs/homarr/commit/47d7abc0d739b91328834e0eaea033637632f22c))
|
||||
* **deps:** update dependency jotai to ^2.12.3 ([#2868](https://github.com/homarr-labs/homarr/issues/2868)) ([c57b63b](https://github.com/homarr-labs/homarr/commit/c57b63b506733459ea0c09776917042fa878829b))
|
||||
* **deps:** update dependency recharts to ^2.15.3 ([#2893](https://github.com/homarr-labs/homarr/issues/2893)) ([5fcb7a3](https://github.com/homarr-labs/homarr/commit/5fcb7a3b9c279c1b402e120a4a6f08265e8d5912))
|
||||
* **deps:** update dependency swagger-ui-react to ^5.21.0 ([#2866](https://github.com/homarr-labs/homarr/issues/2866)) ([ff1263b](https://github.com/homarr-labs/homarr/commit/ff1263b217e96cbb2bc6b8e98ff117c44ca9f00d))
|
||||
* **deps:** update dependency typescript-eslint to ^8.30.0 ([#2873](https://github.com/homarr-labs/homarr/issues/2873)) ([089734b](https://github.com/homarr-labs/homarr/commit/089734b293a21d6eeaa7c5df5ee9930853ed2fcf))
|
||||
* **deps:** update dependency typescript-eslint to ^8.30.1 ([#2876](https://github.com/homarr-labs/homarr/issues/2876)) ([fdca513](https://github.com/homarr-labs/homarr/commit/fdca513929e9e594af2d59ad17c915cbc7ac2e08))
|
||||
* **deps:** update dependency zod to ^3.24.3 ([#2886](https://github.com/homarr-labs/homarr/issues/2886)) ([1bfe397](https://github.com/homarr-labs/homarr/commit/1bfe397d68a88037e35701aab8a7b4c9027ec1e3))
|
||||
* **deps:** update nextjs monorepo to v15.3.1 ([#2889](https://github.com/homarr-labs/homarr/issues/2889)) ([d37cdbd](https://github.com/homarr-labs/homarr/commit/d37cdbd080c2b3a4df536ab7dad4fc4a4b3c915f))
|
||||
* **deps:** update tanstack-query monorepo to ^5.73.3 ([#2860](https://github.com/homarr-labs/homarr/issues/2860)) ([4e6ab03](https://github.com/homarr-labs/homarr/commit/4e6ab037c9245103b05afe977e9c288082a2b539))
|
||||
* **deps:** update tanstack-query monorepo to ^5.74.0 ([#2864](https://github.com/homarr-labs/homarr/issues/2864)) ([ac78e87](https://github.com/homarr-labs/homarr/commit/ac78e8704287f45d6a8ca26cba9b87146e1c6161))
|
||||
* **deps:** update tanstack-query monorepo to ^5.74.2 ([#2869](https://github.com/homarr-labs/homarr/issues/2869)) ([bf4a1cf](https://github.com/homarr-labs/homarr/commit/bf4a1cf1e8c99ada97adf6ee118ddaafc00115e9))
|
||||
* **deps:** update tanstack-query monorepo to ^5.74.3 ([#2871](https://github.com/homarr-labs/homarr/issues/2871)) ([02fb1b9](https://github.com/homarr-labs/homarr/commit/02fb1b9dae501e475353e8e2510e4f8938c4e124))
|
||||
* **deps:** update tanstack-query monorepo to ^5.74.4 ([#2888](https://github.com/homarr-labs/homarr/issues/2888)) ([17174b6](https://github.com/homarr-labs/homarr/commit/17174b6b96ae16b480e2bc5eb9f8ab7b914bc843))
|
||||
* Tdarr widget empty statistics tab ([#2883](https://github.com/homarr-labs/homarr/issues/2883)) ([2d12c1e](https://github.com/homarr-labs/homarr/commit/2d12c1e641398e20a70f8bfb24a48de02498496a))
|
||||
* **widget:** format stock values and changes for better readability ([#2836](https://github.com/homarr-labs/homarr/issues/2836)) ([a29556e](https://github.com/homarr-labs/homarr/commit/a29556ee8ec50aa384a9a46f6f5a5fa5dfa4cdac))
|
||||
|
||||
## [1.16.0](https://github.com/homarr-labs/homarr/compare/v1.15.0...v1.16.0) (2025-04-11)
|
||||
|
||||
### Features
|
||||
|
||||
* **boards:** add quick app add menu item ([#2681](https://github.com/homarr-labs/homarr/issues/2681)) ([7a3c836](https://github.com/homarr-labs/homarr/commit/7a3c836a70a9b9656c6fe74fe6a4bf8e7166a030))
|
||||
* support aria2 integration ([#2226](https://github.com/homarr-labs/homarr/issues/2226)) ([94263c4](https://github.com/homarr-labs/homarr/commit/94263c445b8a0d0cdbea70ebe45ef932b426204d))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **boards:** add missing upload button for background in settings ([#2853](https://github.com/homarr-labs/homarr/issues/2853)) ([4baec7e](https://github.com/homarr-labs/homarr/commit/4baec7e3ff33ac0973daf0ac1f0171e349787bdb))
|
||||
* **deps:** update dependency dotenv to ^16.5.0 ([#2848](https://github.com/homarr-labs/homarr/issues/2848)) ([c84dd2f](https://github.com/homarr-labs/homarr/commit/c84dd2fd797ca674e4f553ddc71926e9b533daa4))
|
||||
* **deps:** update dependency eslint-config-prettier to ^10.1.2 ([#2842](https://github.com/homarr-labs/homarr/issues/2842)) ([924a7d0](https://github.com/homarr-labs/homarr/commit/924a7d076ef8bdd67afcce2e9f850bf334b43c3a))
|
||||
* **deps:** update dependency ioredis to v5.6.1 ([#2851](https://github.com/homarr-labs/homarr/issues/2851)) ([0ed6a79](https://github.com/homarr-labs/homarr/commit/0ed6a79a5bba25fa7a90bec00d3815b3fffae427))
|
||||
* **deps:** update dependency octokit to ^4.1.3 ([#2847](https://github.com/homarr-labs/homarr/issues/2847)) ([4b0b892](https://github.com/homarr-labs/homarr/commit/4b0b89225000137f02f3b89376c46d4d01387128))
|
||||
* **deps:** update dependency swagger-ui-react to ^5.20.8 ([#2844](https://github.com/homarr-labs/homarr/issues/2844)) ([45d285a](https://github.com/homarr-labs/homarr/commit/45d285a76ba46d4d4c594f1f2e822d784a281b72))
|
||||
* **deps:** update dependency undici to v7.8.0 ([#2841](https://github.com/homarr-labs/homarr/issues/2841)) ([f13be4a](https://github.com/homarr-labs/homarr/commit/f13be4a70870aca99a7f49f5068dadcd7fe01c22))
|
||||
* **deps:** update nextjs monorepo to v15.3.0 ([#2838](https://github.com/homarr-labs/homarr/issues/2838)) ([a1fa429](https://github.com/homarr-labs/homarr/commit/a1fa4297ead54a9f8c842b2d23001f725359e000))
|
||||
* **deps:** update tanstack-query monorepo to ^5.72.2 ([#2834](https://github.com/homarr-labs/homarr/issues/2834)) ([f5e3138](https://github.com/homarr-labs/homarr/commit/f5e3138c8118295d8230c3655af1c3f11ee5d4ff))
|
||||
* **deps:** update testcontainers-node monorepo to ^10.24.2 ([#2846](https://github.com/homarr-labs/homarr/issues/2846)) ([057ff2c](https://github.com/homarr-labs/homarr/commit/057ff2cfc1cf062a3c48deb28699df8e1ba89f75))
|
||||
* **deps:** update trpc monorepo to ^11.1.0 ([#2850](https://github.com/homarr-labs/homarr/issues/2850)) ([0fe4ea9](https://github.com/homarr-labs/homarr/commit/0fe4ea9dd9cbfb2def1bede9db8eaf649f79f1b0))
|
||||
|
||||
## [1.15.0](https://github.com/homarr-labs/homarr/compare/v1.14.0...v1.15.0) (2025-04-09)
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:22.14.0-alpine AS base
|
||||
FROM node:22.15.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
+23
-23
@@ -48,57 +48,57 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.17.4",
|
||||
"@mantine/core": "^7.17.4",
|
||||
"@mantine/dropzone": "^7.17.4",
|
||||
"@mantine/hooks": "^7.17.4",
|
||||
"@mantine/modals": "^7.17.4",
|
||||
"@mantine/tiptap": "^7.17.4",
|
||||
"@mantine/colors-generator": "^7.17.5",
|
||||
"@mantine/core": "^7.17.5",
|
||||
"@mantine/dropzone": "^7.17.5",
|
||||
"@mantine/hooks": "^7.17.5",
|
||||
"@mantine/modals": "^7.17.5",
|
||||
"@mantine/tiptap": "^7.17.5",
|
||||
"@million/lint": "1.0.14",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@tanstack/react-query": "^5.72.1",
|
||||
"@tanstack/react-query-devtools": "^5.72.1",
|
||||
"@tanstack/react-query-next-experimental": "^5.72.1",
|
||||
"@trpc/client": "^11.0.4",
|
||||
"@trpc/next": "^11.0.4",
|
||||
"@trpc/react-query": "^11.0.4",
|
||||
"@trpc/server": "^11.0.4",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-query-devtools": "^5.74.4",
|
||||
"@tanstack/react-query-next-experimental": "^5.74.4",
|
||||
"@trpc/client": "^11.1.1",
|
||||
"@trpc/next": "^11.1.1",
|
||||
"@trpc/react-query": "^11.1.1",
|
||||
"@trpc/server": "^11.1.1",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"chroma-js": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"flag-icons": "^7.3.2",
|
||||
"glob": "^11.0.1",
|
||||
"jotai": "^2.12.2",
|
||||
"glob": "^11.0.2",
|
||||
"jotai": "^2.12.3",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "15.2.5",
|
||||
"next": "15.3.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.86.3",
|
||||
"sass": "^1.87.0",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.20.7",
|
||||
"swagger-ui-react": "^5.21.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "3.1.1",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/node": "^22.15.2",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react": "19.1.2",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { z } from "zod";
|
||||
@@ -15,6 +15,8 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { userSignInSchema } from "@homarr/validation/user";
|
||||
|
||||
type Provider = "credentials" | "ldap" | "oidc";
|
||||
|
||||
interface LoginFormProps {
|
||||
providers: string[];
|
||||
oidcClientName: string;
|
||||
@@ -26,6 +28,8 @@ const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credenti
|
||||
|
||||
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
|
||||
const t = useScopedI18n("user");
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const form = useZodForm(extendedValidation, {
|
||||
@@ -39,22 +43,34 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
|
||||
|
||||
const onSuccess = useCallback(
|
||||
async (response: Awaited<ReturnType<typeof signIn>>) => {
|
||||
if (response && (!response.ok || response.error)) {
|
||||
async (provider: Provider, response: Awaited<ReturnType<typeof signIn>>) => {
|
||||
if (!response.ok || response.error) {
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
if (provider === "oidc") {
|
||||
if (!response.url) {
|
||||
showErrorNotification({
|
||||
title: t("action.login.notification.error.title"),
|
||||
message: t("action.login.notification.error.message"),
|
||||
autoClose: 10000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(response.url);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("action.login.notification.success.title"),
|
||||
message: t("action.login.notification.success.message"),
|
||||
});
|
||||
|
||||
// Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
|
||||
if (response) {
|
||||
await revalidatePathActionAsync("/");
|
||||
router.push(callbackUrl);
|
||||
}
|
||||
await revalidatePathActionAsync("/");
|
||||
router.push(callbackUrl);
|
||||
},
|
||||
[t, router, callbackUrl],
|
||||
);
|
||||
@@ -70,14 +86,14 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
}, [t]);
|
||||
|
||||
const signInAsync = useCallback(
|
||||
async (provider: string, options?: Parameters<typeof signIn>[1]) => {
|
||||
async (provider: Provider, options?: Parameters<typeof signIn>[1]) => {
|
||||
setIsPending(true);
|
||||
await signIn(provider, {
|
||||
...options,
|
||||
redirect: false,
|
||||
callbackUrl: new URL(callbackUrl, window.location.href).href,
|
||||
})
|
||||
.then(onSuccess)
|
||||
.then((response) => onSuccess(provider, response))
|
||||
.catch(onError);
|
||||
},
|
||||
[setIsPending, onSuccess, onError, callbackUrl],
|
||||
@@ -86,11 +102,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
const isLoginInProgress = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) return;
|
||||
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
|
||||
isLoginInProgress.current = true;
|
||||
void signInAsync("oidc");
|
||||
}
|
||||
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
|
||||
}, [signInAsync, isOidcAutoLoginEnabled, isPending, isError]);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
|
||||
@@ -25,9 +25,11 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
@@ -62,8 +64,10 @@ export const BoardContentHeaderActions = () => {
|
||||
const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
@@ -90,6 +94,17 @@ const AddMenu = () => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
onSelect: (appId) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId },
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
@@ -101,10 +116,14 @@ const AddMenu = () => {
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
|
||||
@@ -123,7 +123,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { startTransition } from "react";
|
||||
import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPhotoOff } from "@tabler/icons-react";
|
||||
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UploadMedia } from "@homarr/forms-collection";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||
@@ -62,58 +64,76 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Autocomplete
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={({ url }) =>
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", url);
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
@@ -139,7 +159,7 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
</Grid>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const BehaviorSettingsContent = ({ board }: Props) => {
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -46,7 +46,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -116,7 +116,7 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
</Stack>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconCertificate, IconCertificateOff } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -31,11 +31,27 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
||||
const t = await getI18n();
|
||||
const certificates = await loadCustomRootCertificatesAsync();
|
||||
const x509Certificates = certificates
|
||||
.map((cert) => ({
|
||||
...cert,
|
||||
x509: new X509Certificate(cert.content),
|
||||
}))
|
||||
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
|
||||
.map((cert) => {
|
||||
try {
|
||||
const x509 = new X509Certificate(cert.content);
|
||||
return {
|
||||
...cert,
|
||||
isError: false,
|
||||
x509,
|
||||
} as const;
|
||||
} catch {
|
||||
return {
|
||||
...cert,
|
||||
isError: true,
|
||||
x509: null,
|
||||
} as const;
|
||||
}
|
||||
})
|
||||
.sort((certA, certB) => {
|
||||
if (certA.isError) return -1;
|
||||
if (certB.isError) return 1;
|
||||
return certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -57,32 +73,47 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
||||
|
||||
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||
{x509Certificates.map((cert) => (
|
||||
<Card key={cert.x509.fingerprint} withBorder>
|
||||
<Card key={cert.fileName} withBorder>
|
||||
<Group wrap="nowrap">
|
||||
<IconCertificate
|
||||
color={getMantineColor(iconColor(cert.x509.validToDate), 6)}
|
||||
style={{ minWidth: 32 }}
|
||||
size={32}
|
||||
stroke={1.5}
|
||||
/>
|
||||
{cert.isError ? (
|
||||
<IconAlertTriangle
|
||||
color={getMantineColor("red", 6)}
|
||||
style={{ minWidth: 32 }}
|
||||
size={32}
|
||||
stroke={1.5}
|
||||
/>
|
||||
) : (
|
||||
<IconCertificate
|
||||
color={getMantineColor(iconColor(cert.x509.validToDate), 6)}
|
||||
style={{ minWidth: 32 }}
|
||||
size={32}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
<Stack flex={1} gap="xs" maw="calc(100% - 48px)">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
{cert.x509.subject}
|
||||
{cert.isError ? t("certificate.page.list.invalid.title") : cert.x509.subject}
|
||||
</Text>
|
||||
<Text c="gray.6" ta="end" size="sm">
|
||||
{cert.fileName}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
|
||||
{t("certificate.page.list.expires", {
|
||||
when: new Intl.RelativeTimeFormat(locale).format(
|
||||
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
|
||||
"days",
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
{cert.isError ? (
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("certificate.page.list.invalid.description")}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
|
||||
{t("certificate.page.list.expires", {
|
||||
when: new Intl.RelativeTimeFormat(locale).format(
|
||||
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
|
||||
"days",
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<RemoveCertificate fileName={cert.fileName} />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
||||
</Radio.Group>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
||||
|
||||
{isProviderCredentials && (
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
|
||||
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -68,7 +68,7 @@ export const RenameGroupForm = ({ group, disabled }: RenameGroupFormProps) => {
|
||||
|
||||
{!disabled && (
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -40,9 +40,5 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
|
||||
);
|
||||
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton color="teal" onClick={handleAddMember}>
|
||||
{tMembersAdd("label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
|
||||
};
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -108,7 +108,7 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
||||
<Button disabled={isPending} onClick={form.reset}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="teal" loading={isPending} onClick={handleSubmit}>
|
||||
<Button loading={isPending} onClick={handleSubmit}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: Gro
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -57,9 +57,5 @@ const AddGroup = () => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
return <MobileAffixButton onClick={handleAddGroup}>{t("group.action.create.label")}</MobileAffixButton>;
|
||||
};
|
||||
@@ -264,7 +264,7 @@ const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) =>
|
||||
<Button disabled={isPending} onClick={onDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="teal" loading={isPending} onClick={onSave}>
|
||||
<Button loading={isPending} onClick={onSave}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -89,11 +89,7 @@ const RenderTopToolbarCustomActions = () => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<Button color="teal" onClick={handleNewInvite}>
|
||||
{t("action.new.title")}
|
||||
</Button>
|
||||
);
|
||||
return <Button onClick={handleNewInvite}>{t("action.new.title")}</Button>;
|
||||
};
|
||||
|
||||
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
},
|
||||
|
||||
@@ -82,7 +82,7 @@ export const GroupAccessForm = <TPermission extends string>({
|
||||
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("permission.action.saveGroup")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const UsersAccessForm = <TPermission extends string>({
|
||||
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("permission.action.saveUser")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -9,10 +9,11 @@ import { getSectionElements } from "./section-elements";
|
||||
|
||||
export interface CreateItemInput {
|
||||
kind: WidgetKind;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const createItemCallback =
|
||||
({ kind }: CreateItemInput) =>
|
||||
({ kind, options = {} }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
@@ -24,10 +25,11 @@ export const createItemCallback =
|
||||
const widget = {
|
||||
id: createId(),
|
||||
kind,
|
||||
options: {},
|
||||
options,
|
||||
layouts: createItemLayouts(previous, firstSection),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("item actions duplicate-item", () => {
|
||||
kind: itemKind,
|
||||
integrationIds: ["1"],
|
||||
options: { address: "localhost" },
|
||||
advancedOptions: { customCssClasses: ["test"], borderColor: "#ff0000" },
|
||||
advancedOptions: { title: "The best one", customCssClasses: ["test"], borderColor: "#ff0000" },
|
||||
})
|
||||
.addLayout({ layoutId, sectionId: currentSectionId, ...currentItemSize })
|
||||
.build();
|
||||
|
||||
@@ -13,6 +13,7 @@ export class ItemMockBuilder {
|
||||
layouts: [],
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.badge {
|
||||
@mixin dark {
|
||||
--background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity));
|
||||
--border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity));
|
||||
}
|
||||
@mixin light {
|
||||
--background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity));
|
||||
--border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity));
|
||||
}
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card } from "@mantine/core";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
@@ -14,6 +14,7 @@ import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
@@ -25,28 +26,51 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
<>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: item.kind === "iframe" ? "hidden" : undefined,
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} />
|
||||
</Card>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
// It's 4 because of the mantine-react-table that has z-index 3
|
||||
style={{ zIndex: 4 }}
|
||||
top={2}
|
||||
left={16}
|
||||
size="xs"
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
"--opacity": board.opacity / 100,
|
||||
},
|
||||
}}
|
||||
className={itemContentClasses.badge}
|
||||
c="var(--mantine-color-text)"
|
||||
>
|
||||
{item.advancedOptions.title}
|
||||
</Badge>
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: item.kind === "iframe" ? "hidden" : undefined,
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -40,9 +40,7 @@ export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{innerProps.submitLabel}
|
||||
</Button>
|
||||
<Button type="submit">{innerProps.submitLabel}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -48,9 +48,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.saveChanges")}</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --outfile=tasks.cjs",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
@@ -36,17 +36,17 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"superjson": "2.2.2",
|
||||
"undici": "7.7.0"
|
||||
"undici": "7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/node": "^22.15.2",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:deasync --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"tsx": "4.19.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.15.0",
|
||||
"version": "1.18.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -35,27 +35,27 @@
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^11.0.1",
|
||||
"@semantic-release/github": "^11.0.2",
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"@turbo/gen": "^2.5.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"@turbo/gen": "^2.5.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/coverage-v8": "^3.1.2",
|
||||
"@vitest/ui": "^3.1.2",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"semantic-release": "^24.2.3",
|
||||
"testcontainers": "^10.24.1",
|
||||
"turbo": "^2.5.0",
|
||||
"testcontainers": "^10.24.2",
|
||||
"turbo": "^2.5.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.1.1"
|
||||
"vitest": "^3.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.8.0",
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"engines": {
|
||||
"node": ">=22.14.0"
|
||||
"node": ">=22.15.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -70,7 +70,7 @@
|
||||
"tree-sitter-json"
|
||||
],
|
||||
"overrides": {
|
||||
"proxmox-api>undici": "7.7.0"
|
||||
"proxmox-api>undici": "7.8.0"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -41,23 +41,23 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@kubernetes/client-node": "^1.1.2",
|
||||
"@trpc/client": "^11.0.4",
|
||||
"@trpc/react-query": "^11.0.4",
|
||||
"@trpc/server": "^11.0.4",
|
||||
"@trpc/client": "^11.1.1",
|
||||
"@trpc/react-query": "^11.1.1",
|
||||
"@trpc/server": "^11.1.1",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "15.2.5",
|
||||
"next": "15.3.1",
|
||||
"pretty-print-error": "^1.1.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^2.1.5",
|
||||
"zod": "^3.24.2"
|
||||
"trpc-to-openapi": "^2.2.0",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
@@ -16,6 +18,17 @@ export const certificateRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const content = await input.file.text();
|
||||
|
||||
// Validate the certificate
|
||||
try {
|
||||
new X509Certificate(content);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid certificate",
|
||||
});
|
||||
}
|
||||
|
||||
await addCustomRootCertificateAsync(input.file.name, content);
|
||||
}),
|
||||
removeCertificate: permissionRequiredProcedure
|
||||
|
||||
@@ -101,6 +101,18 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
|
||||
);
|
||||
|
||||
if (onlyFormSecretsKindOptions.length >= 1) {
|
||||
// If the first option is no secret it would always be selected even if we want to have a secret
|
||||
if (
|
||||
onlyFormSecretsKindOptions.length >= 2 &&
|
||||
onlyFormSecretsKindOptions.some((secretKinds) => secretKinds.length === 0)
|
||||
) {
|
||||
return (
|
||||
// Will never be undefined because of the check above
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
onlyFormSecretsKindOptions.find((secretKinds) => secretKinds.length >= 1) ?? onlyFormSecretsKindOptions[0]!
|
||||
);
|
||||
}
|
||||
|
||||
// Will never be undefined because of the check above
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return onlyFormSecretsKindOptions[0]!;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { minecraftRouter } from "./minecraft";
|
||||
import { networkControllerRouter } from "./network-controller";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { optionsRouter } from "./options";
|
||||
import { releasesRouter } from "./releases";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
import { stockPriceRouter } from "./stocks";
|
||||
@@ -34,5 +35,6 @@ export const widgetRouter = createTRPCRouter({
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
minecraft: minecraftRouter,
|
||||
options: optionsRouter,
|
||||
releases: releasesRouter,
|
||||
networkController: networkControllerRouter,
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
@@ -14,10 +15,13 @@ const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
export const mediaServerRouter = createTRPCRouter({
|
||||
getCurrentStreams: publicProcedure
|
||||
.unstable_concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {});
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {
|
||||
showOnlyPlaying: input.showOnlyPlaying,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
@@ -29,11 +33,14 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
}),
|
||||
subscribeToCurrentStreams: publicProcedure
|
||||
.unstable_concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.subscription(({ ctx }) => {
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {});
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {
|
||||
showOnlyPlaying: input.showOnlyPlaying,
|
||||
});
|
||||
|
||||
const unsubscribe = innerHandler.subscribe((sessions) => {
|
||||
emit.next({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { escapeForRegEx } from "@tiptap/react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { releasesRequestHandler } from "@homarr/request-handler/releases";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: z.infer<typeof _releaseVersionFilterSchema> | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : "";
|
||||
const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2);
|
||||
const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : "";
|
||||
|
||||
return `^${escapedPrefix}${precision}${escapedSuffix}$`;
|
||||
};
|
||||
|
||||
const _releaseVersionFilterSchema = z.object({
|
||||
prefix: z.string().optional(),
|
||||
precision: z.number(),
|
||||
suffix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const releasesRouter = createTRPCRouter({
|
||||
getLatest: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
repositories: z.array(
|
||||
z.object({
|
||||
providerKey: z.string(),
|
||||
identifier: z.string(),
|
||||
versionFilter: _releaseVersionFilterSchema.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const result = await Promise.all(
|
||||
input.repositories.map(async (repository) => {
|
||||
const innerHandler = releasesRequestHandler.handler({
|
||||
providerKey: repository.providerKey,
|
||||
identifier: repository.identifier,
|
||||
versionRegex: formatVersionFilterRegex(repository.versionFilter),
|
||||
});
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -23,8 +23,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.38.0",
|
||||
"@auth/drizzle-adapter": "^1.8.0",
|
||||
"@auth/core": "^0.39.0",
|
||||
"@auth/drizzle-adapter": "^1.9.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
@@ -35,12 +35,12 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.4.0",
|
||||
"next": "15.2.5",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next": "15.3.1",
|
||||
"next-auth": "5.0.0-beta.27",
|
||||
"pretty-print-error": "^1.1.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,13 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"undici": "7.7.0"
|
||||
"undici": "7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export const loadCustomRootCertificatesAsync = async () => {
|
||||
const dirContent = await fs.readdir(folder);
|
||||
return await Promise.all(
|
||||
dirContent
|
||||
.filter((file) => file.endsWith(".crt"))
|
||||
.filter((file) => file.endsWith(".crt") || file.endsWith(".pem"))
|
||||
.map(async (file) => ({
|
||||
content: await fs.readFile(path.join(folder, file), "utf8"),
|
||||
fileName: file,
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.7"
|
||||
"dotenv": "^16.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -30,17 +30,17 @@
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.2.5",
|
||||
"next": "15.3.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"undici": "7.7.0",
|
||||
"zod": "^3.24.2"
|
||||
"undici": "7.8.0",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).with
|
||||
createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
|
||||
widgetKinds: ["mediaServer"],
|
||||
getInput: {
|
||||
mediaServer: () => ({}),
|
||||
mediaServer: ({ showOnlyPlaying }) => ({ showOnlyPlaying }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -38,21 +38,21 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.38.0",
|
||||
"@auth/core": "^0.39.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.17.4",
|
||||
"@mantine/core": "^7.17.5",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@testcontainers/mysql": "^10.24.1",
|
||||
"@testcontainers/mysql": "^10.24.2",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"mysql2": "3.14.0"
|
||||
"mysql2": "3.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -60,7 +60,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -37,883 +37,6 @@ export type HomarrDocumentationPath =
|
||||
| "/blog/tags/version"
|
||||
| "/blog/translations"
|
||||
| "/search"
|
||||
| "/docs/1.10.0/tags"
|
||||
| "/docs/1.10.0/tags/active-directory"
|
||||
| "/docs/1.10.0/tags/ad-guard"
|
||||
| "/docs/1.10.0/tags/ad-guard-home"
|
||||
| "/docs/1.10.0/tags/administration"
|
||||
| "/docs/1.10.0/tags/advanced"
|
||||
| "/docs/1.10.0/tags/analytics"
|
||||
| "/docs/1.10.0/tags/api"
|
||||
| "/docs/1.10.0/tags/apps"
|
||||
| "/docs/1.10.0/tags/banner"
|
||||
| "/docs/1.10.0/tags/blocking"
|
||||
| "/docs/1.10.0/tags/boards"
|
||||
| "/docs/1.10.0/tags/bookmark"
|
||||
| "/docs/1.10.0/tags/bookmarks"
|
||||
| "/docs/1.10.0/tags/caddy"
|
||||
| "/docs/1.10.0/tags/certificates"
|
||||
| "/docs/1.10.0/tags/checklist"
|
||||
| "/docs/1.10.0/tags/code"
|
||||
| "/docs/1.10.0/tags/community"
|
||||
| "/docs/1.10.0/tags/configuration"
|
||||
| "/docs/1.10.0/tags/connections"
|
||||
| "/docs/1.10.0/tags/customization"
|
||||
| "/docs/1.10.0/tags/data-sources"
|
||||
| "/docs/1.10.0/tags/database"
|
||||
| "/docs/1.10.0/tags/developer"
|
||||
| "/docs/1.10.0/tags/development"
|
||||
| "/docs/1.10.0/tags/dns"
|
||||
| "/docs/1.10.0/tags/docker"
|
||||
| "/docs/1.10.0/tags/donation"
|
||||
| "/docs/1.10.0/tags/edit-mode"
|
||||
| "/docs/1.10.0/tags/env"
|
||||
| "/docs/1.10.0/tags/environment-variables"
|
||||
| "/docs/1.10.0/tags/feeds"
|
||||
| "/docs/1.10.0/tags/getting-started"
|
||||
| "/docs/1.10.0/tags/google"
|
||||
| "/docs/1.10.0/tags/grafana"
|
||||
| "/docs/1.10.0/tags/groups"
|
||||
| "/docs/1.10.0/tags/hardware"
|
||||
| "/docs/1.10.0/tags/health"
|
||||
| "/docs/1.10.0/tags/help"
|
||||
| "/docs/1.10.0/tags/icon-picker"
|
||||
| "/docs/1.10.0/tags/icon-repositories"
|
||||
| "/docs/1.10.0/tags/icons"
|
||||
| "/docs/1.10.0/tags/iframe"
|
||||
| "/docs/1.10.0/tags/images"
|
||||
| "/docs/1.10.0/tags/installation"
|
||||
| "/docs/1.10.0/tags/integrade"
|
||||
| "/docs/1.10.0/tags/integration"
|
||||
| "/docs/1.10.0/tags/integrations"
|
||||
| "/docs/1.10.0/tags/interface"
|
||||
| "/docs/1.10.0/tags/jellyserr"
|
||||
| "/docs/1.10.0/tags/layout"
|
||||
| "/docs/1.10.0/tags/ldap"
|
||||
| "/docs/1.10.0/tags/links"
|
||||
| "/docs/1.10.0/tags/lists"
|
||||
| "/docs/1.10.0/tags/management"
|
||||
| "/docs/1.10.0/tags/media"
|
||||
| "/docs/1.10.0/tags/minecraft"
|
||||
| "/docs/1.10.0/tags/monitoring"
|
||||
| "/docs/1.10.0/tags/news"
|
||||
| "/docs/1.10.0/tags/notebook"
|
||||
| "/docs/1.10.0/tags/notes"
|
||||
| "/docs/1.10.0/tags/oidc"
|
||||
| "/docs/1.10.0/tags/open-collective"
|
||||
| "/docs/1.10.0/tags/open-media-vault"
|
||||
| "/docs/1.10.0/tags/overseerr"
|
||||
| "/docs/1.10.0/tags/permissions"
|
||||
| "/docs/1.10.0/tags/pgid"
|
||||
| "/docs/1.10.0/tags/pi-hole"
|
||||
| "/docs/1.10.0/tags/ping"
|
||||
| "/docs/1.10.0/tags/programming"
|
||||
| "/docs/1.10.0/tags/proxmox"
|
||||
| "/docs/1.10.0/tags/proxy"
|
||||
| "/docs/1.10.0/tags/puid"
|
||||
| "/docs/1.10.0/tags/responsive"
|
||||
| "/docs/1.10.0/tags/roles"
|
||||
| "/docs/1.10.0/tags/rss"
|
||||
| "/docs/1.10.0/tags/search"
|
||||
| "/docs/1.10.0/tags/search-engines"
|
||||
| "/docs/1.10.0/tags/security"
|
||||
| "/docs/1.10.0/tags/self-signed"
|
||||
| "/docs/1.10.0/tags/seo"
|
||||
| "/docs/1.10.0/tags/server"
|
||||
| "/docs/1.10.0/tags/settings"
|
||||
| "/docs/1.10.0/tags/sinkhole"
|
||||
| "/docs/1.10.0/tags/sso"
|
||||
| "/docs/1.10.0/tags/system"
|
||||
| "/docs/1.10.0/tags/table"
|
||||
| "/docs/1.10.0/tags/technical-documentation"
|
||||
| "/docs/1.10.0/tags/text"
|
||||
| "/docs/1.10.0/tags/torrent"
|
||||
| "/docs/1.10.0/tags/traefik"
|
||||
| "/docs/1.10.0/tags/translations"
|
||||
| "/docs/1.10.0/tags/unraid"
|
||||
| "/docs/1.10.0/tags/uploads"
|
||||
| "/docs/1.10.0/tags/usenet"
|
||||
| "/docs/1.10.0/tags/users"
|
||||
| "/docs/1.10.0/tags/variables"
|
||||
| "/docs/1.10.0/tags/widgets"
|
||||
| "/docs/1.10.0/advanced/command-line"
|
||||
| "/docs/1.10.0/advanced/command-line/fix-usernames"
|
||||
| "/docs/1.10.0/advanced/command-line/password-recovery"
|
||||
| "/docs/1.10.0/advanced/development/getting-started"
|
||||
| "/docs/1.10.0/advanced/environment-variables"
|
||||
| "/docs/1.10.0/advanced/icons"
|
||||
| "/docs/1.10.0/advanced/keyboard-shortcuts"
|
||||
| "/docs/1.10.0/advanced/proxy"
|
||||
| "/docs/1.10.0/advanced/running-as-different-user"
|
||||
| "/docs/1.10.0/advanced/single-sign-on"
|
||||
| "/docs/1.10.0/category/advanced"
|
||||
| "/docs/1.10.0/category/community"
|
||||
| "/docs/1.10.0/category/developer-guides"
|
||||
| "/docs/1.10.0/category/getting-started"
|
||||
| "/docs/1.10.0/category/installation"
|
||||
| "/docs/1.10.0/category/installation-1"
|
||||
| "/docs/1.10.0/category/integrations"
|
||||
| "/docs/1.10.0/category/management"
|
||||
| "/docs/1.10.0/category/widgets"
|
||||
| "/docs/1.10.0/community/donate"
|
||||
| "/docs/1.10.0/community/faq"
|
||||
| "/docs/1.10.0/community/get-in-touch"
|
||||
| "/docs/1.10.0/community/license"
|
||||
| "/docs/1.10.0/community/translations"
|
||||
| "/docs/1.10.0/getting-started"
|
||||
| "/docs/1.10.0/getting-started/after-the-installation"
|
||||
| "/docs/1.10.0/getting-started/glossary"
|
||||
| "/docs/1.10.0/getting-started/installation/docker"
|
||||
| "/docs/1.10.0/getting-started/installation/easy-panel"
|
||||
| "/docs/1.10.0/getting-started/installation/helm"
|
||||
| "/docs/1.10.0/getting-started/installation/home-assistant"
|
||||
| "/docs/1.10.0/getting-started/installation/portainer"
|
||||
| "/docs/1.10.0/getting-started/installation/qnap"
|
||||
| "/docs/1.10.0/getting-started/installation/railway"
|
||||
| "/docs/1.10.0/getting-started/installation/saltbox"
|
||||
| "/docs/1.10.0/getting-started/installation/source"
|
||||
| "/docs/1.10.0/getting-started/installation/synology"
|
||||
| "/docs/1.10.0/getting-started/installation/unraid"
|
||||
| "/docs/1.10.0/integrations/containers"
|
||||
| "/docs/1.10.0/integrations/dns"
|
||||
| "/docs/1.10.0/integrations/hardware"
|
||||
| "/docs/1.10.0/integrations/media-requester"
|
||||
| "/docs/1.10.0/integrations/media-server"
|
||||
| "/docs/1.10.0/integrations/servarr"
|
||||
| "/docs/1.10.0/integrations/torrent"
|
||||
| "/docs/1.10.0/integrations/usenet"
|
||||
| "/docs/1.10.0/management/api"
|
||||
| "/docs/1.10.0/management/apps"
|
||||
| "/docs/1.10.0/management/boards"
|
||||
| "/docs/1.10.0/management/certificates"
|
||||
| "/docs/1.10.0/management/integrations"
|
||||
| "/docs/1.10.0/management/media"
|
||||
| "/docs/1.10.0/management/search-engines"
|
||||
| "/docs/1.10.0/management/settings"
|
||||
| "/docs/1.10.0/management/users"
|
||||
| "/docs/1.10.0/widgets/bookmarks"
|
||||
| "/docs/1.10.0/widgets/calendar"
|
||||
| "/docs/1.10.0/widgets/clock"
|
||||
| "/docs/1.10.0/widgets/dns-hole"
|
||||
| "/docs/1.10.0/widgets/downloads"
|
||||
| "/docs/1.10.0/widgets/health-monitoring"
|
||||
| "/docs/1.10.0/widgets/home-assistant"
|
||||
| "/docs/1.10.0/widgets/iframe"
|
||||
| "/docs/1.10.0/widgets/indexer-manager"
|
||||
| "/docs/1.10.0/widgets/media-requests"
|
||||
| "/docs/1.10.0/widgets/media-server"
|
||||
| "/docs/1.10.0/widgets/minecraft-server-status"
|
||||
| "/docs/1.10.0/widgets/notebook"
|
||||
| "/docs/1.10.0/widgets/rss"
|
||||
| "/docs/1.10.0/widgets/video"
|
||||
| "/docs/1.10.0/widgets/weather"
|
||||
| "/docs/1.11.0/tags"
|
||||
| "/docs/1.11.0/tags/active-directory"
|
||||
| "/docs/1.11.0/tags/ad-guard"
|
||||
| "/docs/1.11.0/tags/ad-guard-home"
|
||||
| "/docs/1.11.0/tags/administration"
|
||||
| "/docs/1.11.0/tags/advanced"
|
||||
| "/docs/1.11.0/tags/analytics"
|
||||
| "/docs/1.11.0/tags/api"
|
||||
| "/docs/1.11.0/tags/apps"
|
||||
| "/docs/1.11.0/tags/banner"
|
||||
| "/docs/1.11.0/tags/blocking"
|
||||
| "/docs/1.11.0/tags/boards"
|
||||
| "/docs/1.11.0/tags/bookmark"
|
||||
| "/docs/1.11.0/tags/bookmarks"
|
||||
| "/docs/1.11.0/tags/caddy"
|
||||
| "/docs/1.11.0/tags/certificates"
|
||||
| "/docs/1.11.0/tags/checklist"
|
||||
| "/docs/1.11.0/tags/code"
|
||||
| "/docs/1.11.0/tags/community"
|
||||
| "/docs/1.11.0/tags/configuration"
|
||||
| "/docs/1.11.0/tags/connections"
|
||||
| "/docs/1.11.0/tags/customization"
|
||||
| "/docs/1.11.0/tags/data-sources"
|
||||
| "/docs/1.11.0/tags/database"
|
||||
| "/docs/1.11.0/tags/developer"
|
||||
| "/docs/1.11.0/tags/development"
|
||||
| "/docs/1.11.0/tags/dns"
|
||||
| "/docs/1.11.0/tags/docker"
|
||||
| "/docs/1.11.0/tags/donation"
|
||||
| "/docs/1.11.0/tags/edit-mode"
|
||||
| "/docs/1.11.0/tags/env"
|
||||
| "/docs/1.11.0/tags/environment-variables"
|
||||
| "/docs/1.11.0/tags/feeds"
|
||||
| "/docs/1.11.0/tags/getting-started"
|
||||
| "/docs/1.11.0/tags/google"
|
||||
| "/docs/1.11.0/tags/grafana"
|
||||
| "/docs/1.11.0/tags/groups"
|
||||
| "/docs/1.11.0/tags/hardware"
|
||||
| "/docs/1.11.0/tags/health"
|
||||
| "/docs/1.11.0/tags/help"
|
||||
| "/docs/1.11.0/tags/icon-picker"
|
||||
| "/docs/1.11.0/tags/icon-repositories"
|
||||
| "/docs/1.11.0/tags/icons"
|
||||
| "/docs/1.11.0/tags/iframe"
|
||||
| "/docs/1.11.0/tags/images"
|
||||
| "/docs/1.11.0/tags/installation"
|
||||
| "/docs/1.11.0/tags/integrade"
|
||||
| "/docs/1.11.0/tags/integration"
|
||||
| "/docs/1.11.0/tags/integrations"
|
||||
| "/docs/1.11.0/tags/interface"
|
||||
| "/docs/1.11.0/tags/jellyserr"
|
||||
| "/docs/1.11.0/tags/layout"
|
||||
| "/docs/1.11.0/tags/ldap"
|
||||
| "/docs/1.11.0/tags/links"
|
||||
| "/docs/1.11.0/tags/lists"
|
||||
| "/docs/1.11.0/tags/management"
|
||||
| "/docs/1.11.0/tags/media"
|
||||
| "/docs/1.11.0/tags/minecraft"
|
||||
| "/docs/1.11.0/tags/monitoring"
|
||||
| "/docs/1.11.0/tags/news"
|
||||
| "/docs/1.11.0/tags/notebook"
|
||||
| "/docs/1.11.0/tags/notes"
|
||||
| "/docs/1.11.0/tags/oidc"
|
||||
| "/docs/1.11.0/tags/open-collective"
|
||||
| "/docs/1.11.0/tags/open-media-vault"
|
||||
| "/docs/1.11.0/tags/overseerr"
|
||||
| "/docs/1.11.0/tags/permissions"
|
||||
| "/docs/1.11.0/tags/pgid"
|
||||
| "/docs/1.11.0/tags/pi-hole"
|
||||
| "/docs/1.11.0/tags/ping"
|
||||
| "/docs/1.11.0/tags/programming"
|
||||
| "/docs/1.11.0/tags/proxmox"
|
||||
| "/docs/1.11.0/tags/proxy"
|
||||
| "/docs/1.11.0/tags/puid"
|
||||
| "/docs/1.11.0/tags/responsive"
|
||||
| "/docs/1.11.0/tags/roles"
|
||||
| "/docs/1.11.0/tags/rss"
|
||||
| "/docs/1.11.0/tags/search"
|
||||
| "/docs/1.11.0/tags/search-engines"
|
||||
| "/docs/1.11.0/tags/security"
|
||||
| "/docs/1.11.0/tags/self-signed"
|
||||
| "/docs/1.11.0/tags/seo"
|
||||
| "/docs/1.11.0/tags/server"
|
||||
| "/docs/1.11.0/tags/settings"
|
||||
| "/docs/1.11.0/tags/sinkhole"
|
||||
| "/docs/1.11.0/tags/sso"
|
||||
| "/docs/1.11.0/tags/system"
|
||||
| "/docs/1.11.0/tags/table"
|
||||
| "/docs/1.11.0/tags/technical-documentation"
|
||||
| "/docs/1.11.0/tags/text"
|
||||
| "/docs/1.11.0/tags/torrent"
|
||||
| "/docs/1.11.0/tags/traefik"
|
||||
| "/docs/1.11.0/tags/translations"
|
||||
| "/docs/1.11.0/tags/unraid"
|
||||
| "/docs/1.11.0/tags/uploads"
|
||||
| "/docs/1.11.0/tags/usenet"
|
||||
| "/docs/1.11.0/tags/users"
|
||||
| "/docs/1.11.0/tags/variables"
|
||||
| "/docs/1.11.0/tags/widgets"
|
||||
| "/docs/1.11.0/advanced/command-line"
|
||||
| "/docs/1.11.0/advanced/command-line/fix-usernames"
|
||||
| "/docs/1.11.0/advanced/command-line/password-recovery"
|
||||
| "/docs/1.11.0/advanced/development/getting-started"
|
||||
| "/docs/1.11.0/advanced/development/kubernetes"
|
||||
| "/docs/1.11.0/advanced/environment-variables"
|
||||
| "/docs/1.11.0/advanced/icons"
|
||||
| "/docs/1.11.0/advanced/keyboard-shortcuts"
|
||||
| "/docs/1.11.0/advanced/proxy"
|
||||
| "/docs/1.11.0/advanced/running-as-different-user"
|
||||
| "/docs/1.11.0/advanced/single-sign-on"
|
||||
| "/docs/1.11.0/category/advanced"
|
||||
| "/docs/1.11.0/category/community"
|
||||
| "/docs/1.11.0/category/developer-guides"
|
||||
| "/docs/1.11.0/category/getting-started"
|
||||
| "/docs/1.11.0/category/installation"
|
||||
| "/docs/1.11.0/category/installation-1"
|
||||
| "/docs/1.11.0/category/integrations"
|
||||
| "/docs/1.11.0/category/management"
|
||||
| "/docs/1.11.0/category/widgets"
|
||||
| "/docs/1.11.0/community/donate"
|
||||
| "/docs/1.11.0/community/faq"
|
||||
| "/docs/1.11.0/community/get-in-touch"
|
||||
| "/docs/1.11.0/community/license"
|
||||
| "/docs/1.11.0/community/translations"
|
||||
| "/docs/1.11.0/getting-started"
|
||||
| "/docs/1.11.0/getting-started/after-the-installation"
|
||||
| "/docs/1.11.0/getting-started/glossary"
|
||||
| "/docs/1.11.0/getting-started/installation/docker"
|
||||
| "/docs/1.11.0/getting-started/installation/easy-panel"
|
||||
| "/docs/1.11.0/getting-started/installation/helm"
|
||||
| "/docs/1.11.0/getting-started/installation/home-assistant"
|
||||
| "/docs/1.11.0/getting-started/installation/portainer"
|
||||
| "/docs/1.11.0/getting-started/installation/qnap"
|
||||
| "/docs/1.11.0/getting-started/installation/railway"
|
||||
| "/docs/1.11.0/getting-started/installation/saltbox"
|
||||
| "/docs/1.11.0/getting-started/installation/source"
|
||||
| "/docs/1.11.0/getting-started/installation/synology"
|
||||
| "/docs/1.11.0/getting-started/installation/unraid"
|
||||
| "/docs/1.11.0/integrations/containers"
|
||||
| "/docs/1.11.0/integrations/dns"
|
||||
| "/docs/1.11.0/integrations/hardware"
|
||||
| "/docs/1.11.0/integrations/kubernetes"
|
||||
| "/docs/1.11.0/integrations/media-requester"
|
||||
| "/docs/1.11.0/integrations/media-server"
|
||||
| "/docs/1.11.0/integrations/servarr"
|
||||
| "/docs/1.11.0/integrations/torrent"
|
||||
| "/docs/1.11.0/integrations/usenet"
|
||||
| "/docs/1.11.0/management/api"
|
||||
| "/docs/1.11.0/management/apps"
|
||||
| "/docs/1.11.0/management/boards"
|
||||
| "/docs/1.11.0/management/certificates"
|
||||
| "/docs/1.11.0/management/integrations"
|
||||
| "/docs/1.11.0/management/media"
|
||||
| "/docs/1.11.0/management/search-engines"
|
||||
| "/docs/1.11.0/management/settings"
|
||||
| "/docs/1.11.0/management/users"
|
||||
| "/docs/1.11.0/widgets/bookmarks"
|
||||
| "/docs/1.11.0/widgets/calendar"
|
||||
| "/docs/1.11.0/widgets/clock"
|
||||
| "/docs/1.11.0/widgets/dns-hole"
|
||||
| "/docs/1.11.0/widgets/downloads"
|
||||
| "/docs/1.11.0/widgets/health-monitoring"
|
||||
| "/docs/1.11.0/widgets/home-assistant"
|
||||
| "/docs/1.11.0/widgets/iframe"
|
||||
| "/docs/1.11.0/widgets/indexer-manager"
|
||||
| "/docs/1.11.0/widgets/media-requests"
|
||||
| "/docs/1.11.0/widgets/media-server"
|
||||
| "/docs/1.11.0/widgets/minecraft-server-status"
|
||||
| "/docs/1.11.0/widgets/notebook"
|
||||
| "/docs/1.11.0/widgets/rss"
|
||||
| "/docs/1.11.0/widgets/video"
|
||||
| "/docs/1.11.0/widgets/weather"
|
||||
| "/docs/1.12.0/tags"
|
||||
| "/docs/1.12.0/tags/active-directory"
|
||||
| "/docs/1.12.0/tags/ad-guard"
|
||||
| "/docs/1.12.0/tags/ad-guard-home"
|
||||
| "/docs/1.12.0/tags/administration"
|
||||
| "/docs/1.12.0/tags/advanced"
|
||||
| "/docs/1.12.0/tags/analytics"
|
||||
| "/docs/1.12.0/tags/api"
|
||||
| "/docs/1.12.0/tags/apps"
|
||||
| "/docs/1.12.0/tags/banner"
|
||||
| "/docs/1.12.0/tags/blocking"
|
||||
| "/docs/1.12.0/tags/boards"
|
||||
| "/docs/1.12.0/tags/bookmark"
|
||||
| "/docs/1.12.0/tags/bookmarks"
|
||||
| "/docs/1.12.0/tags/caddy"
|
||||
| "/docs/1.12.0/tags/certificates"
|
||||
| "/docs/1.12.0/tags/checklist"
|
||||
| "/docs/1.12.0/tags/code"
|
||||
| "/docs/1.12.0/tags/community"
|
||||
| "/docs/1.12.0/tags/configuration"
|
||||
| "/docs/1.12.0/tags/connections"
|
||||
| "/docs/1.12.0/tags/customization"
|
||||
| "/docs/1.12.0/tags/data-sources"
|
||||
| "/docs/1.12.0/tags/database"
|
||||
| "/docs/1.12.0/tags/developer"
|
||||
| "/docs/1.12.0/tags/development"
|
||||
| "/docs/1.12.0/tags/dns"
|
||||
| "/docs/1.12.0/tags/docker"
|
||||
| "/docs/1.12.0/tags/donation"
|
||||
| "/docs/1.12.0/tags/edit-mode"
|
||||
| "/docs/1.12.0/tags/env"
|
||||
| "/docs/1.12.0/tags/environment-variables"
|
||||
| "/docs/1.12.0/tags/feeds"
|
||||
| "/docs/1.12.0/tags/finance"
|
||||
| "/docs/1.12.0/tags/getting-started"
|
||||
| "/docs/1.12.0/tags/google"
|
||||
| "/docs/1.12.0/tags/grafana"
|
||||
| "/docs/1.12.0/tags/groups"
|
||||
| "/docs/1.12.0/tags/hardware"
|
||||
| "/docs/1.12.0/tags/health"
|
||||
| "/docs/1.12.0/tags/help"
|
||||
| "/docs/1.12.0/tags/icon-picker"
|
||||
| "/docs/1.12.0/tags/icon-repositories"
|
||||
| "/docs/1.12.0/tags/icons"
|
||||
| "/docs/1.12.0/tags/iframe"
|
||||
| "/docs/1.12.0/tags/images"
|
||||
| "/docs/1.12.0/tags/installation"
|
||||
| "/docs/1.12.0/tags/integrade"
|
||||
| "/docs/1.12.0/tags/integration"
|
||||
| "/docs/1.12.0/tags/integrations"
|
||||
| "/docs/1.12.0/tags/interface"
|
||||
| "/docs/1.12.0/tags/jellyserr"
|
||||
| "/docs/1.12.0/tags/layout"
|
||||
| "/docs/1.12.0/tags/ldap"
|
||||
| "/docs/1.12.0/tags/links"
|
||||
| "/docs/1.12.0/tags/lists"
|
||||
| "/docs/1.12.0/tags/management"
|
||||
| "/docs/1.12.0/tags/market"
|
||||
| "/docs/1.12.0/tags/media"
|
||||
| "/docs/1.12.0/tags/minecraft"
|
||||
| "/docs/1.12.0/tags/monitoring"
|
||||
| "/docs/1.12.0/tags/news"
|
||||
| "/docs/1.12.0/tags/notebook"
|
||||
| "/docs/1.12.0/tags/notes"
|
||||
| "/docs/1.12.0/tags/oidc"
|
||||
| "/docs/1.12.0/tags/open-collective"
|
||||
| "/docs/1.12.0/tags/open-media-vault"
|
||||
| "/docs/1.12.0/tags/overseerr"
|
||||
| "/docs/1.12.0/tags/permissions"
|
||||
| "/docs/1.12.0/tags/pgid"
|
||||
| "/docs/1.12.0/tags/pi-hole"
|
||||
| "/docs/1.12.0/tags/ping"
|
||||
| "/docs/1.12.0/tags/programming"
|
||||
| "/docs/1.12.0/tags/proxmox"
|
||||
| "/docs/1.12.0/tags/proxy"
|
||||
| "/docs/1.12.0/tags/puid"
|
||||
| "/docs/1.12.0/tags/responsive"
|
||||
| "/docs/1.12.0/tags/roles"
|
||||
| "/docs/1.12.0/tags/rss"
|
||||
| "/docs/1.12.0/tags/search"
|
||||
| "/docs/1.12.0/tags/search-engines"
|
||||
| "/docs/1.12.0/tags/security"
|
||||
| "/docs/1.12.0/tags/self-signed"
|
||||
| "/docs/1.12.0/tags/seo"
|
||||
| "/docs/1.12.0/tags/server"
|
||||
| "/docs/1.12.0/tags/settings"
|
||||
| "/docs/1.12.0/tags/sinkhole"
|
||||
| "/docs/1.12.0/tags/sso"
|
||||
| "/docs/1.12.0/tags/stocks"
|
||||
| "/docs/1.12.0/tags/system"
|
||||
| "/docs/1.12.0/tags/table"
|
||||
| "/docs/1.12.0/tags/technical-documentation"
|
||||
| "/docs/1.12.0/tags/text"
|
||||
| "/docs/1.12.0/tags/torrent"
|
||||
| "/docs/1.12.0/tags/traefik"
|
||||
| "/docs/1.12.0/tags/translations"
|
||||
| "/docs/1.12.0/tags/unraid"
|
||||
| "/docs/1.12.0/tags/uploads"
|
||||
| "/docs/1.12.0/tags/usenet"
|
||||
| "/docs/1.12.0/tags/users"
|
||||
| "/docs/1.12.0/tags/variables"
|
||||
| "/docs/1.12.0/tags/widgets"
|
||||
| "/docs/1.12.0/advanced/command-line"
|
||||
| "/docs/1.12.0/advanced/command-line/fix-usernames"
|
||||
| "/docs/1.12.0/advanced/command-line/password-recovery"
|
||||
| "/docs/1.12.0/advanced/development/getting-started"
|
||||
| "/docs/1.12.0/advanced/development/kubernetes"
|
||||
| "/docs/1.12.0/advanced/environment-variables"
|
||||
| "/docs/1.12.0/advanced/icons"
|
||||
| "/docs/1.12.0/advanced/keyboard-shortcuts"
|
||||
| "/docs/1.12.0/advanced/proxy"
|
||||
| "/docs/1.12.0/advanced/running-as-different-user"
|
||||
| "/docs/1.12.0/advanced/single-sign-on"
|
||||
| "/docs/1.12.0/category/advanced"
|
||||
| "/docs/1.12.0/category/community"
|
||||
| "/docs/1.12.0/category/developer-guides"
|
||||
| "/docs/1.12.0/category/getting-started"
|
||||
| "/docs/1.12.0/category/installation"
|
||||
| "/docs/1.12.0/category/installation-1"
|
||||
| "/docs/1.12.0/category/integrations"
|
||||
| "/docs/1.12.0/category/management"
|
||||
| "/docs/1.12.0/category/widgets"
|
||||
| "/docs/1.12.0/community/donate"
|
||||
| "/docs/1.12.0/community/faq"
|
||||
| "/docs/1.12.0/community/get-in-touch"
|
||||
| "/docs/1.12.0/community/license"
|
||||
| "/docs/1.12.0/community/translations"
|
||||
| "/docs/1.12.0/getting-started"
|
||||
| "/docs/1.12.0/getting-started/after-the-installation"
|
||||
| "/docs/1.12.0/getting-started/glossary"
|
||||
| "/docs/1.12.0/getting-started/installation/docker"
|
||||
| "/docs/1.12.0/getting-started/installation/easy-panel"
|
||||
| "/docs/1.12.0/getting-started/installation/helm"
|
||||
| "/docs/1.12.0/getting-started/installation/home-assistant"
|
||||
| "/docs/1.12.0/getting-started/installation/portainer"
|
||||
| "/docs/1.12.0/getting-started/installation/proxmox"
|
||||
| "/docs/1.12.0/getting-started/installation/qnap"
|
||||
| "/docs/1.12.0/getting-started/installation/railway"
|
||||
| "/docs/1.12.0/getting-started/installation/saltbox"
|
||||
| "/docs/1.12.0/getting-started/installation/source"
|
||||
| "/docs/1.12.0/getting-started/installation/synology"
|
||||
| "/docs/1.12.0/getting-started/installation/unraid"
|
||||
| "/docs/1.12.0/integrations/containers"
|
||||
| "/docs/1.12.0/integrations/dns"
|
||||
| "/docs/1.12.0/integrations/hardware"
|
||||
| "/docs/1.12.0/integrations/kubernetes"
|
||||
| "/docs/1.12.0/integrations/media-requester"
|
||||
| "/docs/1.12.0/integrations/media-server"
|
||||
| "/docs/1.12.0/integrations/servarr"
|
||||
| "/docs/1.12.0/integrations/torrent"
|
||||
| "/docs/1.12.0/integrations/usenet"
|
||||
| "/docs/1.12.0/management/api"
|
||||
| "/docs/1.12.0/management/apps"
|
||||
| "/docs/1.12.0/management/boards"
|
||||
| "/docs/1.12.0/management/certificates"
|
||||
| "/docs/1.12.0/management/integrations"
|
||||
| "/docs/1.12.0/management/media"
|
||||
| "/docs/1.12.0/management/search-engines"
|
||||
| "/docs/1.12.0/management/settings"
|
||||
| "/docs/1.12.0/management/users"
|
||||
| "/docs/1.12.0/widgets/bookmarks"
|
||||
| "/docs/1.12.0/widgets/calendar"
|
||||
| "/docs/1.12.0/widgets/clock"
|
||||
| "/docs/1.12.0/widgets/dns-hole"
|
||||
| "/docs/1.12.0/widgets/downloads"
|
||||
| "/docs/1.12.0/widgets/health-monitoring"
|
||||
| "/docs/1.12.0/widgets/home-assistant"
|
||||
| "/docs/1.12.0/widgets/iframe"
|
||||
| "/docs/1.12.0/widgets/indexer-manager"
|
||||
| "/docs/1.12.0/widgets/media-requests"
|
||||
| "/docs/1.12.0/widgets/media-server"
|
||||
| "/docs/1.12.0/widgets/minecraft-server-status"
|
||||
| "/docs/1.12.0/widgets/notebook"
|
||||
| "/docs/1.12.0/widgets/rss"
|
||||
| "/docs/1.12.0/widgets/stocks"
|
||||
| "/docs/1.12.0/widgets/video"
|
||||
| "/docs/1.12.0/widgets/weather"
|
||||
| "/docs/1.13.1/tags"
|
||||
| "/docs/1.13.1/tags/active-directory"
|
||||
| "/docs/1.13.1/tags/ad-guard"
|
||||
| "/docs/1.13.1/tags/ad-guard-home"
|
||||
| "/docs/1.13.1/tags/administration"
|
||||
| "/docs/1.13.1/tags/advanced"
|
||||
| "/docs/1.13.1/tags/analytics"
|
||||
| "/docs/1.13.1/tags/api"
|
||||
| "/docs/1.13.1/tags/apps"
|
||||
| "/docs/1.13.1/tags/banner"
|
||||
| "/docs/1.13.1/tags/blocking"
|
||||
| "/docs/1.13.1/tags/boards"
|
||||
| "/docs/1.13.1/tags/bookmark"
|
||||
| "/docs/1.13.1/tags/bookmarks"
|
||||
| "/docs/1.13.1/tags/caddy"
|
||||
| "/docs/1.13.1/tags/certificates"
|
||||
| "/docs/1.13.1/tags/checklist"
|
||||
| "/docs/1.13.1/tags/code"
|
||||
| "/docs/1.13.1/tags/community"
|
||||
| "/docs/1.13.1/tags/configuration"
|
||||
| "/docs/1.13.1/tags/connections"
|
||||
| "/docs/1.13.1/tags/customization"
|
||||
| "/docs/1.13.1/tags/data-sources"
|
||||
| "/docs/1.13.1/tags/database"
|
||||
| "/docs/1.13.1/tags/developer"
|
||||
| "/docs/1.13.1/tags/development"
|
||||
| "/docs/1.13.1/tags/dns"
|
||||
| "/docs/1.13.1/tags/docker"
|
||||
| "/docs/1.13.1/tags/donation"
|
||||
| "/docs/1.13.1/tags/edit-mode"
|
||||
| "/docs/1.13.1/tags/env"
|
||||
| "/docs/1.13.1/tags/environment-variables"
|
||||
| "/docs/1.13.1/tags/feeds"
|
||||
| "/docs/1.13.1/tags/finance"
|
||||
| "/docs/1.13.1/tags/getting-started"
|
||||
| "/docs/1.13.1/tags/google"
|
||||
| "/docs/1.13.1/tags/grafana"
|
||||
| "/docs/1.13.1/tags/groups"
|
||||
| "/docs/1.13.1/tags/hardware"
|
||||
| "/docs/1.13.1/tags/health"
|
||||
| "/docs/1.13.1/tags/help"
|
||||
| "/docs/1.13.1/tags/icon-picker"
|
||||
| "/docs/1.13.1/tags/icon-repositories"
|
||||
| "/docs/1.13.1/tags/icons"
|
||||
| "/docs/1.13.1/tags/iframe"
|
||||
| "/docs/1.13.1/tags/images"
|
||||
| "/docs/1.13.1/tags/installation"
|
||||
| "/docs/1.13.1/tags/integrade"
|
||||
| "/docs/1.13.1/tags/integration"
|
||||
| "/docs/1.13.1/tags/integrations"
|
||||
| "/docs/1.13.1/tags/interface"
|
||||
| "/docs/1.13.1/tags/jellyserr"
|
||||
| "/docs/1.13.1/tags/layout"
|
||||
| "/docs/1.13.1/tags/ldap"
|
||||
| "/docs/1.13.1/tags/links"
|
||||
| "/docs/1.13.1/tags/lists"
|
||||
| "/docs/1.13.1/tags/management"
|
||||
| "/docs/1.13.1/tags/market"
|
||||
| "/docs/1.13.1/tags/media"
|
||||
| "/docs/1.13.1/tags/minecraft"
|
||||
| "/docs/1.13.1/tags/monitoring"
|
||||
| "/docs/1.13.1/tags/news"
|
||||
| "/docs/1.13.1/tags/notebook"
|
||||
| "/docs/1.13.1/tags/notes"
|
||||
| "/docs/1.13.1/tags/oidc"
|
||||
| "/docs/1.13.1/tags/open-collective"
|
||||
| "/docs/1.13.1/tags/open-media-vault"
|
||||
| "/docs/1.13.1/tags/overseerr"
|
||||
| "/docs/1.13.1/tags/permissions"
|
||||
| "/docs/1.13.1/tags/pgid"
|
||||
| "/docs/1.13.1/tags/pi-hole"
|
||||
| "/docs/1.13.1/tags/ping"
|
||||
| "/docs/1.13.1/tags/programming"
|
||||
| "/docs/1.13.1/tags/proxmox"
|
||||
| "/docs/1.13.1/tags/proxy"
|
||||
| "/docs/1.13.1/tags/puid"
|
||||
| "/docs/1.13.1/tags/responsive"
|
||||
| "/docs/1.13.1/tags/roles"
|
||||
| "/docs/1.13.1/tags/rss"
|
||||
| "/docs/1.13.1/tags/search"
|
||||
| "/docs/1.13.1/tags/search-engines"
|
||||
| "/docs/1.13.1/tags/security"
|
||||
| "/docs/1.13.1/tags/self-signed"
|
||||
| "/docs/1.13.1/tags/seo"
|
||||
| "/docs/1.13.1/tags/server"
|
||||
| "/docs/1.13.1/tags/settings"
|
||||
| "/docs/1.13.1/tags/sinkhole"
|
||||
| "/docs/1.13.1/tags/sso"
|
||||
| "/docs/1.13.1/tags/stocks"
|
||||
| "/docs/1.13.1/tags/system"
|
||||
| "/docs/1.13.1/tags/table"
|
||||
| "/docs/1.13.1/tags/technical-documentation"
|
||||
| "/docs/1.13.1/tags/text"
|
||||
| "/docs/1.13.1/tags/torrent"
|
||||
| "/docs/1.13.1/tags/traefik"
|
||||
| "/docs/1.13.1/tags/translations"
|
||||
| "/docs/1.13.1/tags/unraid"
|
||||
| "/docs/1.13.1/tags/uploads"
|
||||
| "/docs/1.13.1/tags/usenet"
|
||||
| "/docs/1.13.1/tags/users"
|
||||
| "/docs/1.13.1/tags/variables"
|
||||
| "/docs/1.13.1/tags/widgets"
|
||||
| "/docs/1.13.1/advanced/command-line"
|
||||
| "/docs/1.13.1/advanced/command-line/fix-usernames"
|
||||
| "/docs/1.13.1/advanced/command-line/password-recovery"
|
||||
| "/docs/1.13.1/advanced/development/getting-started"
|
||||
| "/docs/1.13.1/advanced/development/kubernetes"
|
||||
| "/docs/1.13.1/advanced/environment-variables"
|
||||
| "/docs/1.13.1/advanced/icons"
|
||||
| "/docs/1.13.1/advanced/keyboard-shortcuts"
|
||||
| "/docs/1.13.1/advanced/proxy"
|
||||
| "/docs/1.13.1/advanced/running-as-different-user"
|
||||
| "/docs/1.13.1/advanced/single-sign-on"
|
||||
| "/docs/1.13.1/advanced/styling"
|
||||
| "/docs/1.13.1/category/advanced"
|
||||
| "/docs/1.13.1/category/community"
|
||||
| "/docs/1.13.1/category/developer-guides"
|
||||
| "/docs/1.13.1/category/getting-started"
|
||||
| "/docs/1.13.1/category/installation"
|
||||
| "/docs/1.13.1/category/installation-1"
|
||||
| "/docs/1.13.1/category/integrations"
|
||||
| "/docs/1.13.1/category/management"
|
||||
| "/docs/1.13.1/category/widgets"
|
||||
| "/docs/1.13.1/community/donate"
|
||||
| "/docs/1.13.1/community/faq"
|
||||
| "/docs/1.13.1/community/get-in-touch"
|
||||
| "/docs/1.13.1/community/license"
|
||||
| "/docs/1.13.1/community/translations"
|
||||
| "/docs/1.13.1/getting-started"
|
||||
| "/docs/1.13.1/getting-started/after-the-installation"
|
||||
| "/docs/1.13.1/getting-started/glossary"
|
||||
| "/docs/1.13.1/getting-started/installation/docker"
|
||||
| "/docs/1.13.1/getting-started/installation/easy-panel"
|
||||
| "/docs/1.13.1/getting-started/installation/helm"
|
||||
| "/docs/1.13.1/getting-started/installation/home-assistant"
|
||||
| "/docs/1.13.1/getting-started/installation/portainer"
|
||||
| "/docs/1.13.1/getting-started/installation/proxmox"
|
||||
| "/docs/1.13.1/getting-started/installation/qnap"
|
||||
| "/docs/1.13.1/getting-started/installation/railway"
|
||||
| "/docs/1.13.1/getting-started/installation/saltbox"
|
||||
| "/docs/1.13.1/getting-started/installation/source"
|
||||
| "/docs/1.13.1/getting-started/installation/synology"
|
||||
| "/docs/1.13.1/getting-started/installation/unraid"
|
||||
| "/docs/1.13.1/integrations/cloud"
|
||||
| "/docs/1.13.1/integrations/containers"
|
||||
| "/docs/1.13.1/integrations/dns"
|
||||
| "/docs/1.13.1/integrations/hardware"
|
||||
| "/docs/1.13.1/integrations/kubernetes"
|
||||
| "/docs/1.13.1/integrations/media-requester"
|
||||
| "/docs/1.13.1/integrations/media-server"
|
||||
| "/docs/1.13.1/integrations/servarr"
|
||||
| "/docs/1.13.1/integrations/torrent"
|
||||
| "/docs/1.13.1/integrations/usenet"
|
||||
| "/docs/1.13.1/management/api"
|
||||
| "/docs/1.13.1/management/apps"
|
||||
| "/docs/1.13.1/management/boards"
|
||||
| "/docs/1.13.1/management/certificates"
|
||||
| "/docs/1.13.1/management/integrations"
|
||||
| "/docs/1.13.1/management/media"
|
||||
| "/docs/1.13.1/management/search-engines"
|
||||
| "/docs/1.13.1/management/settings"
|
||||
| "/docs/1.13.1/management/users"
|
||||
| "/docs/1.13.1/widgets/bookmarks"
|
||||
| "/docs/1.13.1/widgets/calendar"
|
||||
| "/docs/1.13.1/widgets/clock"
|
||||
| "/docs/1.13.1/widgets/dns-hole"
|
||||
| "/docs/1.13.1/widgets/downloads"
|
||||
| "/docs/1.13.1/widgets/health-monitoring"
|
||||
| "/docs/1.13.1/widgets/home-assistant"
|
||||
| "/docs/1.13.1/widgets/iframe"
|
||||
| "/docs/1.13.1/widgets/indexer-manager"
|
||||
| "/docs/1.13.1/widgets/media-requests"
|
||||
| "/docs/1.13.1/widgets/media-server"
|
||||
| "/docs/1.13.1/widgets/minecraft-server-status"
|
||||
| "/docs/1.13.1/widgets/notebook"
|
||||
| "/docs/1.13.1/widgets/rss"
|
||||
| "/docs/1.13.1/widgets/stocks"
|
||||
| "/docs/1.13.1/widgets/video"
|
||||
| "/docs/1.13.1/widgets/weather"
|
||||
| "/docs/next/tags"
|
||||
| "/docs/next/tags/active-directory"
|
||||
| "/docs/next/tags/ad-guard"
|
||||
| "/docs/next/tags/ad-guard-home"
|
||||
| "/docs/next/tags/administration"
|
||||
| "/docs/next/tags/advanced"
|
||||
| "/docs/next/tags/analytics"
|
||||
| "/docs/next/tags/api"
|
||||
| "/docs/next/tags/apps"
|
||||
| "/docs/next/tags/banner"
|
||||
| "/docs/next/tags/blocking"
|
||||
| "/docs/next/tags/boards"
|
||||
| "/docs/next/tags/bookmark"
|
||||
| "/docs/next/tags/bookmarks"
|
||||
| "/docs/next/tags/caddy"
|
||||
| "/docs/next/tags/certificates"
|
||||
| "/docs/next/tags/checklist"
|
||||
| "/docs/next/tags/code"
|
||||
| "/docs/next/tags/community"
|
||||
| "/docs/next/tags/configuration"
|
||||
| "/docs/next/tags/connections"
|
||||
| "/docs/next/tags/customization"
|
||||
| "/docs/next/tags/data-sources"
|
||||
| "/docs/next/tags/database"
|
||||
| "/docs/next/tags/developer"
|
||||
| "/docs/next/tags/development"
|
||||
| "/docs/next/tags/dns"
|
||||
| "/docs/next/tags/docker"
|
||||
| "/docs/next/tags/donation"
|
||||
| "/docs/next/tags/edit-mode"
|
||||
| "/docs/next/tags/env"
|
||||
| "/docs/next/tags/environment-variables"
|
||||
| "/docs/next/tags/feeds"
|
||||
| "/docs/next/tags/finance"
|
||||
| "/docs/next/tags/getting-started"
|
||||
| "/docs/next/tags/google"
|
||||
| "/docs/next/tags/grafana"
|
||||
| "/docs/next/tags/groups"
|
||||
| "/docs/next/tags/hardware"
|
||||
| "/docs/next/tags/health"
|
||||
| "/docs/next/tags/help"
|
||||
| "/docs/next/tags/icon-picker"
|
||||
| "/docs/next/tags/icon-repositories"
|
||||
| "/docs/next/tags/icons"
|
||||
| "/docs/next/tags/iframe"
|
||||
| "/docs/next/tags/images"
|
||||
| "/docs/next/tags/installation"
|
||||
| "/docs/next/tags/integrade"
|
||||
| "/docs/next/tags/integration"
|
||||
| "/docs/next/tags/integrations"
|
||||
| "/docs/next/tags/interface"
|
||||
| "/docs/next/tags/jellyserr"
|
||||
| "/docs/next/tags/layout"
|
||||
| "/docs/next/tags/ldap"
|
||||
| "/docs/next/tags/links"
|
||||
| "/docs/next/tags/lists"
|
||||
| "/docs/next/tags/management"
|
||||
| "/docs/next/tags/market"
|
||||
| "/docs/next/tags/media"
|
||||
| "/docs/next/tags/minecraft"
|
||||
| "/docs/next/tags/monitoring"
|
||||
| "/docs/next/tags/news"
|
||||
| "/docs/next/tags/notebook"
|
||||
| "/docs/next/tags/notes"
|
||||
| "/docs/next/tags/oidc"
|
||||
| "/docs/next/tags/open-collective"
|
||||
| "/docs/next/tags/open-media-vault"
|
||||
| "/docs/next/tags/overseerr"
|
||||
| "/docs/next/tags/permissions"
|
||||
| "/docs/next/tags/pgid"
|
||||
| "/docs/next/tags/pi-hole"
|
||||
| "/docs/next/tags/ping"
|
||||
| "/docs/next/tags/programming"
|
||||
| "/docs/next/tags/proxmox"
|
||||
| "/docs/next/tags/proxy"
|
||||
| "/docs/next/tags/puid"
|
||||
| "/docs/next/tags/responsive"
|
||||
| "/docs/next/tags/roles"
|
||||
| "/docs/next/tags/rss"
|
||||
| "/docs/next/tags/search"
|
||||
| "/docs/next/tags/search-engines"
|
||||
| "/docs/next/tags/security"
|
||||
| "/docs/next/tags/self-signed"
|
||||
| "/docs/next/tags/seo"
|
||||
| "/docs/next/tags/server"
|
||||
| "/docs/next/tags/settings"
|
||||
| "/docs/next/tags/sinkhole"
|
||||
| "/docs/next/tags/sso"
|
||||
| "/docs/next/tags/stocks"
|
||||
| "/docs/next/tags/system"
|
||||
| "/docs/next/tags/table"
|
||||
| "/docs/next/tags/technical-documentation"
|
||||
| "/docs/next/tags/text"
|
||||
| "/docs/next/tags/torrent"
|
||||
| "/docs/next/tags/traefik"
|
||||
| "/docs/next/tags/translations"
|
||||
| "/docs/next/tags/unraid"
|
||||
| "/docs/next/tags/uploads"
|
||||
| "/docs/next/tags/usenet"
|
||||
| "/docs/next/tags/users"
|
||||
| "/docs/next/tags/variables"
|
||||
| "/docs/next/tags/widgets"
|
||||
| "/docs/next/advanced/command-line"
|
||||
| "/docs/next/advanced/command-line/fix-usernames"
|
||||
| "/docs/next/advanced/command-line/password-recovery"
|
||||
| "/docs/next/advanced/development/getting-started"
|
||||
| "/docs/next/advanced/development/kubernetes"
|
||||
| "/docs/next/advanced/environment-variables"
|
||||
| "/docs/next/advanced/icons"
|
||||
| "/docs/next/advanced/keyboard-shortcuts"
|
||||
| "/docs/next/advanced/proxy"
|
||||
| "/docs/next/advanced/running-as-different-user"
|
||||
| "/docs/next/advanced/single-sign-on"
|
||||
| "/docs/next/advanced/styling"
|
||||
| "/docs/next/category/advanced"
|
||||
| "/docs/next/category/community"
|
||||
| "/docs/next/category/developer-guides"
|
||||
| "/docs/next/category/getting-started"
|
||||
| "/docs/next/category/installation"
|
||||
| "/docs/next/category/installation-1"
|
||||
| "/docs/next/category/integrations"
|
||||
| "/docs/next/category/management"
|
||||
| "/docs/next/category/widgets"
|
||||
| "/docs/next/community/donate"
|
||||
| "/docs/next/community/faq"
|
||||
| "/docs/next/community/get-in-touch"
|
||||
| "/docs/next/community/license"
|
||||
| "/docs/next/community/translations"
|
||||
| "/docs/next/getting-started"
|
||||
| "/docs/next/getting-started/after-the-installation"
|
||||
| "/docs/next/getting-started/glossary"
|
||||
| "/docs/next/getting-started/installation/docker"
|
||||
| "/docs/next/getting-started/installation/easy-panel"
|
||||
| "/docs/next/getting-started/installation/helm"
|
||||
| "/docs/next/getting-started/installation/home-assistant"
|
||||
| "/docs/next/getting-started/installation/portainer"
|
||||
| "/docs/next/getting-started/installation/proxmox"
|
||||
| "/docs/next/getting-started/installation/qnap"
|
||||
| "/docs/next/getting-started/installation/railway"
|
||||
| "/docs/next/getting-started/installation/saltbox"
|
||||
| "/docs/next/getting-started/installation/source"
|
||||
| "/docs/next/getting-started/installation/synology"
|
||||
| "/docs/next/getting-started/installation/unraid"
|
||||
| "/docs/next/integrations/cloud"
|
||||
| "/docs/next/integrations/containers"
|
||||
| "/docs/next/integrations/dns"
|
||||
| "/docs/next/integrations/hardware"
|
||||
| "/docs/next/integrations/kubernetes"
|
||||
| "/docs/next/integrations/media-requester"
|
||||
| "/docs/next/integrations/media-server"
|
||||
| "/docs/next/integrations/servarr"
|
||||
| "/docs/next/integrations/torrent"
|
||||
| "/docs/next/integrations/usenet"
|
||||
| "/docs/next/management/api"
|
||||
| "/docs/next/management/apps"
|
||||
| "/docs/next/management/boards"
|
||||
| "/docs/next/management/certificates"
|
||||
| "/docs/next/management/integrations"
|
||||
| "/docs/next/management/media"
|
||||
| "/docs/next/management/search-engines"
|
||||
| "/docs/next/management/settings"
|
||||
| "/docs/next/management/users"
|
||||
| "/docs/next/widgets/bookmarks"
|
||||
| "/docs/next/widgets/calendar"
|
||||
| "/docs/next/widgets/clock"
|
||||
| "/docs/next/widgets/dns-hole"
|
||||
| "/docs/next/widgets/downloads"
|
||||
| "/docs/next/widgets/health-monitoring"
|
||||
| "/docs/next/widgets/home-assistant"
|
||||
| "/docs/next/widgets/iframe"
|
||||
| "/docs/next/widgets/indexer-manager"
|
||||
| "/docs/next/widgets/media-requests"
|
||||
| "/docs/next/widgets/media-server"
|
||||
| "/docs/next/widgets/minecraft-server-status"
|
||||
| "/docs/next/widgets/notebook"
|
||||
| "/docs/next/widgets/rss"
|
||||
| "/docs/next/widgets/stocks"
|
||||
| "/docs/next/widgets/video"
|
||||
| "/docs/next/widgets/weather"
|
||||
| "/docs/tags"
|
||||
| "/docs/tags/active-directory"
|
||||
| "/docs/tags/ad-guard"
|
||||
|
||||
@@ -49,6 +49,12 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
},
|
||||
aria2: {
|
||||
name: "Aria2",
|
||||
secretKinds: [[], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
|
||||
category: ["downloadClient", "torrent", "miscellaneous"],
|
||||
},
|
||||
sonarr: {
|
||||
name: "Sonarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
@@ -136,26 +142,26 @@ export const integrationDefs = {
|
||||
dashDot: {
|
||||
name: "Dash.",
|
||||
secretKinds: [[]],
|
||||
category: ["healthMonitoring"],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
|
||||
category: ["healthMonitoring"],
|
||||
},
|
||||
tdarr: {
|
||||
name: "Tdarr",
|
||||
secretKinds: [[]],
|
||||
category: ["mediaTranscoding"],
|
||||
secretKinds: [[], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
|
||||
category: ["mediaTranscoding"],
|
||||
},
|
||||
proxmox: {
|
||||
name: "Proxmox",
|
||||
secretKinds: [["username", "tokenId", "apiKey", "realm"]],
|
||||
category: ["healthMonitoring"],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
|
||||
category: ["healthMonitoring"],
|
||||
},
|
||||
nextcloud: {
|
||||
name: "Nextcloud",
|
||||
secretKinds: [["username", "password"]],
|
||||
category: ["calendar"],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
|
||||
category: ["calendar"],
|
||||
},
|
||||
unifiController: {
|
||||
name: "Unifi Controller",
|
||||
@@ -211,6 +217,7 @@ export type IntegrationCategory =
|
||||
| "downloadClient"
|
||||
| "usenet"
|
||||
| "torrent"
|
||||
| "miscellaneous"
|
||||
| "smartHomeServer"
|
||||
| "indexerManager"
|
||||
| "healthMonitoring"
|
||||
|
||||
@@ -23,5 +23,6 @@ export const widgetKinds = [
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
"healthMonitoring",
|
||||
"releases",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
@@ -25,14 +25,14 @@
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"dockerode": "^4.0.5"
|
||||
"dockerode": "^4.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.37",
|
||||
"eslint": "^9.24.0",
|
||||
"@types/dockerode": "^3.3.38",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
Vendored
+3
-3
@@ -23,14 +23,14 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"zod": "^3.24.2"
|
||||
"@t3-oss/env-nextjs": "^0.13.0",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,14 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/form": "^7.17.4",
|
||||
"zod": "^3.24.2"
|
||||
"@mantine/form": "^7.17.5",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -29,15 +29,15 @@
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.17.4",
|
||||
"@mantine/core": "^7.17.5",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@ctrl/deluge": "^7.1.0",
|
||||
"@ctrl/qbittorrent": "^9.5.2",
|
||||
"@ctrl/qbittorrent": "^9.6.0",
|
||||
"@ctrl/transmission": "^7.2.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
@@ -36,19 +36,22 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"maria2": "^0.4.0",
|
||||
"node-ical": "^0.20.1",
|
||||
"node-unifi": "^2.5.1",
|
||||
"proxmox-api": "1.1.1",
|
||||
"tsdav": "^2.1.3",
|
||||
"undici": "7.7.0",
|
||||
"tsdav": "^2.1.4",
|
||||
"undici": "7.8.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-unifi": "^2.5.1",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
|
||||
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
|
||||
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
|
||||
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
|
||||
@@ -78,6 +79,7 @@ export const integrationCreators = {
|
||||
qBittorrent: QBitTorrentIntegration,
|
||||
deluge: DelugeIntegration,
|
||||
transmission: TransmissionIntegration,
|
||||
aria2: Aria2Integration,
|
||||
jellyseerr: JellyseerrIntegration,
|
||||
overseerr: OverseerrIntegration,
|
||||
prowlarr: ProwlarrIntegration,
|
||||
|
||||
@@ -30,13 +30,15 @@ export class DashDotIntegration extends Integration {
|
||||
cpuUtilization: cpuLoad.sumLoad,
|
||||
memUsed: `${memoryLoad.loadInBytes}`,
|
||||
memAvailable: `${info.maxAvailableMemoryBytes - memoryLoad.loadInBytes}`,
|
||||
fileSystem: info.storage.map((storage, index) => ({
|
||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
used: humanFileSize(storageLoad[index]!),
|
||||
available: `${storage.size}`,
|
||||
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
||||
})),
|
||||
fileSystem: info.storage
|
||||
.filter((_, index) => storageLoad[index] !== -1) // filter out undermoutned drives, they display as -1 in the load API
|
||||
.map((storage, index) => ({
|
||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
used: humanFileSize(storageLoad[index]!),
|
||||
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
|
||||
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
||||
})),
|
||||
cpuModelName: info.cpuModel === "" ? `Unknown Model (${info.cpuBrand})` : `${info.cpuModel} (${info.cpuBrand})`,
|
||||
cpuTemp: cpuLoad.averageTemperature,
|
||||
availablePkgUpdates: 0,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import path from "path";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
|
||||
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
|
||||
|
||||
export class Aria2Integration extends DownloadClientIntegration {
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
const client = this.getClient();
|
||||
const keys: (keyof Aria2Download)[] = [
|
||||
"bittorrent",
|
||||
"uploadLength",
|
||||
"uploadSpeed",
|
||||
"downloadSpeed",
|
||||
"totalLength",
|
||||
"completedLength",
|
||||
"files",
|
||||
"status",
|
||||
"gid",
|
||||
];
|
||||
const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([
|
||||
client.tellActive(),
|
||||
client.tellWaiting(0, 1000, keys),
|
||||
client.tellStopped(0, 1000, keys),
|
||||
client.getGlobalStat(),
|
||||
]);
|
||||
|
||||
const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads];
|
||||
const allPaused = downloads.every((download) => download.status === "paused");
|
||||
|
||||
return {
|
||||
status: {
|
||||
types: ["torrent", "miscellaneous"],
|
||||
paused: allPaused,
|
||||
rates: {
|
||||
up: Number(globalStats.uploadSpeed),
|
||||
down: Number(globalStats.downloadSpeed),
|
||||
},
|
||||
},
|
||||
items: downloads.map((download, index) => {
|
||||
const totalSize = Number(download.totalLength);
|
||||
const completedSize = Number(download.completedLength);
|
||||
const progress = totalSize > 0 ? completedSize / totalSize : 0;
|
||||
|
||||
const itemName = download.bittorrent?.info?.name ?? path.basename(download.files[0]?.path ?? "Unknown");
|
||||
|
||||
return {
|
||||
index,
|
||||
id: download.gid,
|
||||
name: itemName,
|
||||
type: download.bittorrent ? "torrent" : "miscellaneous",
|
||||
size: totalSize,
|
||||
sent: Number(download.uploadLength),
|
||||
downSpeed: Number(download.downloadSpeed),
|
||||
upSpeed: Number(download.uploadSpeed),
|
||||
time: this.calculateEta(completedSize, totalSize, Number(download.downloadSpeed)),
|
||||
state: this.getState(download.status, Boolean(download.bittorrent)),
|
||||
category: [],
|
||||
progress,
|
||||
};
|
||||
}),
|
||||
} as DownloadClientJobsAndStatus;
|
||||
}
|
||||
public async pauseQueueAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.pauseAll();
|
||||
}
|
||||
public async pauseItemAsync(item: DownloadClientItem): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.pause(item.id);
|
||||
}
|
||||
public async resumeQueueAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.unpauseAll();
|
||||
}
|
||||
public async resumeItemAsync(item: DownloadClientItem): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.unpause(item.id);
|
||||
}
|
||||
public async deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
const client = this.getClient();
|
||||
// Note: Remove download file is not support by aria2, replace with forceremove
|
||||
|
||||
if (item.state in ["downloading", "leeching", "paused"]) {
|
||||
await (fromDisk ? client.remove(item.id) : client.forceRemove(item.id));
|
||||
} else {
|
||||
await client.removeDownloadResult(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.getVersion();
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
const url = this.url("/jsonrpc");
|
||||
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (target, method: keyof Aria2GetClient) => {
|
||||
return async (...args: Parameters<Aria2GetClient[typeof method]>) => {
|
||||
let params = [...args];
|
||||
if (this.hasSecretValue("apiKey")) {
|
||||
params = [`token:${this.getSecretValue("apiKey")}`, ...params];
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
|
||||
method: `aria2.${method}`,
|
||||
params,
|
||||
});
|
||||
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
|
||||
.then(async (response) => {
|
||||
const responseBody = (await response.json()) as { result: ReturnType<Aria2GetClient[typeof method]> };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
return responseBody.result;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(error.message);
|
||||
} else {
|
||||
throw new Error("Error communicating with Aria2");
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
) as Aria2GetClient;
|
||||
}
|
||||
|
||||
private getState(aria2Status: Aria2Download["status"], isTorrent: boolean): DownloadClientItem["state"] {
|
||||
return isTorrent ? this.getTorrentState(aria2Status) : this.getNonTorrentState(aria2Status);
|
||||
}
|
||||
private getNonTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||
switch (aria2Status) {
|
||||
case "active":
|
||||
return "downloading";
|
||||
case "waiting":
|
||||
return "queued";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "complete":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "removed":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
private getTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||
switch (aria2Status) {
|
||||
case "active":
|
||||
return "leeching";
|
||||
case "waiting":
|
||||
return "queued";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "complete":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "removed":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
private calculateEta(completed: number, total: number, speed: number): number {
|
||||
if (speed === 0 || completed >= total) return 0;
|
||||
return Math.floor((total - completed) / speed) * 1000; // Convert to milliseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface Aria2GetClient {
|
||||
getVersion: Aria2VoidFunc<Aria2GetVersion>;
|
||||
getGlobalStat: Aria2VoidFunc<Aria2GetGlobalStat>;
|
||||
|
||||
tellActive: Aria2VoidFunc<Aria2Download[]>;
|
||||
tellWaiting: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||
tellStopped: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||
tellStatus: Aria2GidFunc<Aria2Download, Aria2TellStatusListParams>;
|
||||
|
||||
pause: Aria2GidFunc<AriaGID>;
|
||||
pauseAll: Aria2VoidFunc<"OK">;
|
||||
unpause: Aria2GidFunc<AriaGID>;
|
||||
unpauseAll: Aria2VoidFunc<"OK">;
|
||||
remove: Aria2GidFunc<AriaGID>;
|
||||
forceRemove: Aria2GidFunc<AriaGID>;
|
||||
removeDownloadResult: Aria2GidFunc<"OK">;
|
||||
}
|
||||
type AriaGID = string;
|
||||
|
||||
type Aria2GidFunc<R = void, T extends unknown[] = []> = (gid: string, ...args: T) => Promise<R>;
|
||||
type Aria2VoidFunc<R = void, T extends unknown[] = []> = (...args: T) => Promise<R>;
|
||||
|
||||
type Aria2TellStatusListParams = [offset: number, num: number, keys?: [keyof Aria2Download] | (keyof Aria2Download)[]];
|
||||
|
||||
export interface Aria2GetVersion {
|
||||
enabledFeatures: string[];
|
||||
version: string;
|
||||
}
|
||||
export interface Aria2GetGlobalStat {
|
||||
downloadSpeed: string;
|
||||
uploadSpeed: string;
|
||||
numActive: string;
|
||||
numWaiting: string;
|
||||
numStopped: string;
|
||||
numStoppedTotal: string;
|
||||
}
|
||||
export interface Aria2Download {
|
||||
gid: AriaGID;
|
||||
status: "active" | "waiting" | "paused" | "error" | "complete" | "removed";
|
||||
totalLength: string;
|
||||
completedLength: string;
|
||||
uploadLength: string;
|
||||
bitfield: string;
|
||||
downloadSpeed: string;
|
||||
uploadSpeed: string;
|
||||
infoHash?: string;
|
||||
numSeeders?: string;
|
||||
seeder?: "true" | "false";
|
||||
pieceLength: string;
|
||||
numPieces: string;
|
||||
connections: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
followedBy?: AriaGID[];
|
||||
following?: AriaGID;
|
||||
belongsTo?: AriaGID;
|
||||
dir: string;
|
||||
files: {
|
||||
index: number;
|
||||
path: string;
|
||||
length: string;
|
||||
completedLength: string;
|
||||
selected: "true" | "false";
|
||||
uris: {
|
||||
status: "waiting" | "used";
|
||||
uri: string;
|
||||
}[];
|
||||
}[];
|
||||
bittorrent?: {
|
||||
announceList: string[];
|
||||
comment?: string | { "utf-8": string };
|
||||
creationDate?: number;
|
||||
mode?: "single" | "multi";
|
||||
info?: {
|
||||
name: string | { "utf-8": string };
|
||||
};
|
||||
verifiedLength?: number;
|
||||
verifyIntegrityPending?: boolean;
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export class DelugeIntegration extends DownloadClientIntegration {
|
||||
down: Math.floor(download_rate),
|
||||
up: Math.floor(upload_rate),
|
||||
},
|
||||
type,
|
||||
types: [type],
|
||||
};
|
||||
const items = torrents.map((torrent): DownloadClientItem => {
|
||||
const state = DelugeIntegration.getTorrentState(torrent.state);
|
||||
|
||||
@@ -21,7 +21,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
|
||||
const status: DownloadClientStatus = {
|
||||
paused: nzbGetStatus.DownloadPaused,
|
||||
rates: { down: nzbGetStatus.DownloadRate },
|
||||
type,
|
||||
types: [type],
|
||||
};
|
||||
const items = queue
|
||||
.map((file): DownloadClientItem => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
|
||||
);
|
||||
const paused =
|
||||
torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined;
|
||||
const status: DownloadClientStatus = { paused, rates, type };
|
||||
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||
const items = torrents.map((torrent): DownloadClientItem => {
|
||||
const state = QBitTorrentIntegration.getTorrentState(torrent.state);
|
||||
return {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
|
||||
const status: DownloadClientStatus = {
|
||||
paused: queue.paused,
|
||||
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
|
||||
type,
|
||||
types: [type],
|
||||
};
|
||||
const items = queue.slots
|
||||
.map((slot): DownloadClientItem => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class TransmissionIntegration extends DownloadClientIntegration {
|
||||
);
|
||||
const paused =
|
||||
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
|
||||
const status: DownloadClientStatus = { paused, rates, type };
|
||||
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||
const items = torrents.map((torrent): DownloadClientItem => {
|
||||
const state = TransmissionIntegration.getTorrentState(torrent.status);
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import type { StreamSession } from "../interfaces/media-server/session";
|
||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
|
||||
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
|
||||
|
||||
const sessionSchema = z.object({
|
||||
@@ -47,7 +47,7 @@ export class EmbyIntegration extends Integration {
|
||||
});
|
||||
}
|
||||
|
||||
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
|
||||
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), {
|
||||
headers: {
|
||||
@@ -69,6 +69,7 @@ export class EmbyIntegration extends Integration {
|
||||
return result.data
|
||||
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
|
||||
.filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId)
|
||||
.filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined)
|
||||
.map((sessionInfo): StreamSession => {
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
|
||||
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
|
||||
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
|
||||
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
|
||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
|
||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||
|
||||
@@ -20,8 +20,8 @@ export const downloadClientItemSchema = z.object({
|
||||
index: z.number(),
|
||||
/** Filename */
|
||||
name: z.string(),
|
||||
/** Torrent/Usenet identifier */
|
||||
type: z.enum(["torrent", "usenet"]),
|
||||
/** Download Client identifier */
|
||||
type: z.enum(["torrent", "usenet", "miscellaneous"]),
|
||||
/** Item size in Bytes */
|
||||
size: z.number(),
|
||||
/** Total uploaded in Bytes, only required for Torrent items */
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface DownloadClientStatus {
|
||||
down: number;
|
||||
up?: number;
|
||||
};
|
||||
type: "usenet" | "torrent";
|
||||
types: ("usenet" | "torrent" | "miscellaneous")[];
|
||||
}
|
||||
export interface ExtendedClientStatus {
|
||||
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };
|
||||
|
||||
@@ -15,3 +15,7 @@ export interface StreamSession {
|
||||
episodeCount?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CurrentSessionsInput {
|
||||
showOnlyPlaying: boolean;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface TdarrPieSegment {
|
||||
}
|
||||
|
||||
export interface TdarrStatistics {
|
||||
libraryName: string;
|
||||
totalFileCount: number;
|
||||
totalTranscodeCount: number;
|
||||
totalHealthCheckCount: number;
|
||||
@@ -11,19 +12,12 @@ export interface TdarrStatistics {
|
||||
failedHealthCheckCount: number;
|
||||
stagedTranscodeCount: number;
|
||||
stagedHealthCheckCount: number;
|
||||
pies: {
|
||||
libraryName: string;
|
||||
libraryId: string;
|
||||
totalFiles: number;
|
||||
totalTranscodes: number;
|
||||
savedSpace: number;
|
||||
totalHealthChecks: number;
|
||||
transcodeStatus: TdarrPieSegment[];
|
||||
healthCheckStatus: TdarrPieSegment[];
|
||||
videoCodecs: TdarrPieSegment[];
|
||||
videoContainers: TdarrPieSegment[];
|
||||
videoResolutions: TdarrPieSegment[];
|
||||
audioCodecs: TdarrPieSegment[];
|
||||
audioContainers: TdarrPieSegment[];
|
||||
}[];
|
||||
totalSavedSpace: number;
|
||||
transcodeStatus: TdarrPieSegment[];
|
||||
healthCheckStatus: TdarrPieSegment[];
|
||||
videoCodecs: TdarrPieSegment[];
|
||||
videoContainers: TdarrPieSegment[];
|
||||
videoResolutions: TdarrPieSegment[];
|
||||
audioCodecs: TdarrPieSegment[];
|
||||
audioContainers: TdarrPieSegment[];
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
|
||||
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import type { StreamSession } from "../interfaces/media-server/session";
|
||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
|
||||
|
||||
export class JellyfinIntegration extends Integration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
@@ -26,7 +26,7 @@ export class JellyfinIntegration extends Integration {
|
||||
await systemApi.getPingSystem();
|
||||
}
|
||||
|
||||
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
|
||||
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const api = await this.getApiAsync();
|
||||
const sessionApi = getSessionApi(api);
|
||||
const sessions = await sessionApi.getSessions();
|
||||
@@ -38,6 +38,7 @@ export class JellyfinIntegration extends Integration {
|
||||
return sessions.data
|
||||
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
|
||||
.filter((sessionInfo) => sessionInfo.DeviceId !== "homarr")
|
||||
.filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined)
|
||||
.map((sessionInfo): StreamSession => {
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
@@ -10,25 +8,34 @@ import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } fro
|
||||
|
||||
export class TdarrIntegration extends Integration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const url = this.url("/api/v2/status");
|
||||
const response = await fetchWithTrustedCertificatesAsync(url);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Unexpected status code: ${response.status}`);
|
||||
}
|
||||
|
||||
await z.object({ status: z.string() }).parseAsync(await response.json());
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api/v2/is-server-alive"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getStatisticsAsync(): Promise<TdarrStatistics> {
|
||||
const url = this.url("/api/v2/cruddb");
|
||||
const url = this.url("/api/v2/stats/get-pies");
|
||||
|
||||
const headerParams = {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: headerParams,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
collection: "StatisticsJSONDB",
|
||||
mode: "getById",
|
||||
docID: "statistics",
|
||||
libraryId: "", // empty string to get all libraries
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -36,36 +43,41 @@ export class TdarrIntegration extends Integration {
|
||||
const statisticsData = await getStatisticsSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
totalFileCount: statisticsData.totalFileCount,
|
||||
totalTranscodeCount: statisticsData.totalTranscodeCount,
|
||||
totalHealthCheckCount: statisticsData.totalHealthCheckCount,
|
||||
failedTranscodeCount: statisticsData.table3Count,
|
||||
failedHealthCheckCount: statisticsData.table6Count,
|
||||
stagedTranscodeCount: statisticsData.table1Count,
|
||||
stagedHealthCheckCount: statisticsData.table4Count,
|
||||
pies: statisticsData.pies.map((pie) => ({
|
||||
libraryName: pie[0],
|
||||
libraryId: pie[1],
|
||||
totalFiles: pie[2],
|
||||
totalTranscodes: pie[3],
|
||||
savedSpace: pie[4] * 1_000_000_000, // file_size is in GB, convert to bytes,
|
||||
totalHealthChecks: pie[5],
|
||||
transcodeStatus: pie[6],
|
||||
healthCheckStatus: pie[7],
|
||||
videoCodecs: pie[8],
|
||||
videoContainers: pie[9],
|
||||
videoResolutions: pie[10],
|
||||
audioCodecs: pie[11],
|
||||
audioContainers: pie[12],
|
||||
})),
|
||||
libraryName: "All",
|
||||
totalFileCount: statisticsData.pieStats.totalFiles,
|
||||
totalTranscodeCount: statisticsData.pieStats.totalTranscodeCount,
|
||||
totalHealthCheckCount: statisticsData.pieStats.totalHealthCheckCount,
|
||||
// The Tdarr API only returns a category if there is at least one item in it
|
||||
failedTranscodeCount:
|
||||
statisticsData.pieStats.status.transcode.find((transcode) => transcode.name === "Transcode error")?.value ?? 0,
|
||||
failedHealthCheckCount:
|
||||
statisticsData.pieStats.status.healthcheck.find((healthcheck) => healthcheck.name === "Error")?.value ?? 0,
|
||||
stagedTranscodeCount:
|
||||
statisticsData.pieStats.status.transcode.find((transcode) => transcode.name === "Transcode success")?.value ??
|
||||
0,
|
||||
stagedHealthCheckCount:
|
||||
statisticsData.pieStats.status.healthcheck.find((healthcheck) => healthcheck.name === "Queued")?.value ?? 0,
|
||||
|
||||
totalSavedSpace: statisticsData.pieStats.sizeDiff * 1_000_000_000, // sizeDiff is in GB, convert to bytes
|
||||
transcodeStatus: statisticsData.pieStats.status.transcode,
|
||||
healthCheckStatus: statisticsData.pieStats.status.healthcheck,
|
||||
videoCodecs: statisticsData.pieStats.video.codecs,
|
||||
videoContainers: statisticsData.pieStats.video.containers,
|
||||
videoResolutions: statisticsData.pieStats.video.resolutions,
|
||||
audioCodecs: statisticsData.pieStats.audio.codecs,
|
||||
audioContainers: statisticsData.pieStats.audio.containers,
|
||||
};
|
||||
}
|
||||
|
||||
public async getWorkersAsync(): Promise<TdarrWorker[]> {
|
||||
const url = this.url("/api/v2/get-nodes");
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "GET",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: headerParams,
|
||||
});
|
||||
|
||||
const nodesData = await getNodesResponseSchema.parseAsync(await response.json());
|
||||
@@ -103,9 +115,13 @@ export class TdarrIntegration extends Integration {
|
||||
|
||||
private async getTranscodingQueueAsync(firstItemIndex: number, pageSize: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: headerParams,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
start: firstItemIndex,
|
||||
@@ -124,7 +140,7 @@ export class TdarrIntegration extends Integration {
|
||||
healthCheck: item.HealthCheck,
|
||||
transcode: item.TranscodeDecisionMaker,
|
||||
filePath: item.file,
|
||||
fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes
|
||||
fileSize: Math.floor(item.file_size * 1_000_000), // file_size is in MB, convert to bytes, floor because it returns as float
|
||||
container: item.container,
|
||||
codec: item.video_codec_name,
|
||||
resolution: item.video_resolution,
|
||||
@@ -138,9 +154,13 @@ export class TdarrIntegration extends Integration {
|
||||
|
||||
private async getHealthCheckDataAsync(firstItemIndex: number, pageSize: number, totalQueueCount: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: headerParams,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
start: Math.max(firstItemIndex - totalQueueCount, 0),
|
||||
@@ -162,7 +182,7 @@ export class TdarrIntegration extends Integration {
|
||||
healthCheck: item.HealthCheck,
|
||||
transcode: item.TranscodeDecisionMaker,
|
||||
filePath: item.file,
|
||||
fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes
|
||||
fileSize: Math.floor(item.file_size * 1_000_000), // file_size is in MB, convert to bytes, floor because it returns as float
|
||||
container: item.container,
|
||||
codec: item.video_codec_name,
|
||||
resolution: item.video_resolution,
|
||||
|
||||
@@ -1,72 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const getStatisticsSchema = z.object({
|
||||
totalFileCount: z.number(),
|
||||
totalTranscodeCount: z.number(),
|
||||
totalHealthCheckCount: z.number(),
|
||||
table3Count: z.number(),
|
||||
table6Count: z.number(),
|
||||
table1Count: z.number(),
|
||||
table4Count: z.number(),
|
||||
pies: z.array(
|
||||
z.tuple([
|
||||
z.string(), // Library Name
|
||||
z.string(), // Library ID
|
||||
z.number(), // File count
|
||||
z.number(), // Number of transcodes
|
||||
z.number(), // Space saved (in GB)
|
||||
z.number(), // Number of health checks
|
||||
z.array(
|
||||
pieStats: z.object({
|
||||
totalFiles: z.number(),
|
||||
totalTranscodeCount: z.number(),
|
||||
sizeDiff: z.number(),
|
||||
totalHealthCheckCount: z.number(),
|
||||
status: z.object({
|
||||
transcode: z.array(
|
||||
z.object({
|
||||
// Transcode Status (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
healthcheck: z.array(
|
||||
z.object({
|
||||
// Health Status (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
}),
|
||||
video: z.object({
|
||||
codecs: z.array(
|
||||
z.object({
|
||||
// Video files - Codecs (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
containers: z.array(
|
||||
z.object({
|
||||
// Video files - Containers (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
resolutions: z.array(
|
||||
z.object({
|
||||
// Video files - Resolutions (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
}),
|
||||
audio: z.object({
|
||||
codecs: z.array(
|
||||
z.object({
|
||||
// Audio files - Codecs (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
containers: z.array(
|
||||
z.object({
|
||||
// Audio files - Containers (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getNodesResponseSchema = z.record(
|
||||
|
||||
@@ -5,11 +5,11 @@ import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import type { StreamSession } from "../interfaces/media-server/session";
|
||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
|
||||
import type { PlexResponse } from "./interface";
|
||||
|
||||
export class PlexIntegration extends Integration {
|
||||
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
|
||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const token = super.getSecretValue("apiKey");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/status/sessions"), {
|
||||
|
||||
@@ -1,103 +1,78 @@
|
||||
import type z from "zod";
|
||||
import type { SiteStats } from "node-unifi";
|
||||
import { Controller } from "node-unifi";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../base/error";
|
||||
import { Integration, throwErrorByStatusCode } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
|
||||
import type { NetworkControllerSummary } from "../interfaces/network-controller-summary/network-controller-summary-types";
|
||||
import { unifiSummaryResponseSchema } from "./unifi-controller-types";
|
||||
|
||||
const udmpPrefix = "proxy/network";
|
||||
type Subsystem = "www" | "wan" | "wlan" | "lan" | "vpn";
|
||||
import type { HealthSubsystem } from "./unifi-controller-types";
|
||||
|
||||
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
|
||||
private prefix: string | undefined;
|
||||
|
||||
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
|
||||
if (!this.headers) {
|
||||
await this.authenticateAndConstructSessionInHeaderAsync();
|
||||
}
|
||||
|
||||
const requestUrl = this.url(`/${this.prefix}/api/stat/sites`);
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.headers,
|
||||
};
|
||||
if (this.csrfToken) {
|
||||
requestHeaders["X-CSRF-TOKEN"] = this.csrfToken;
|
||||
}
|
||||
|
||||
const statsResponse = await fetchWithTrustedCertificatesAsync(requestUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
}).catch((err: TypeError) => {
|
||||
const detailMessage = String(err.cause);
|
||||
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
|
||||
});
|
||||
|
||||
if (!statsResponse.ok) {
|
||||
throwErrorByStatusCode(statsResponse.status);
|
||||
}
|
||||
|
||||
const result = unifiSummaryResponseSchema.safeParse(await statsResponse.json());
|
||||
|
||||
if (!result.success) {
|
||||
throw new ParseError("Unifi controller", result.error);
|
||||
}
|
||||
const client = await this.createControllerClientAsync();
|
||||
const stats = await client.getSitesStats();
|
||||
|
||||
return {
|
||||
wanStatus: this.getStatusValueOverAllSites(result.data, "wan", (site) => site.status === "ok"),
|
||||
wanStatus: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"),
|
||||
www: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "wan", (site) => site.status === "ok"),
|
||||
latency: this.getNumericValueOverAllSites(result.data, "www", (site) => site.latency, "max"),
|
||||
ping: this.getNumericValueOverAllSites(result.data, "www", (site) => site.speedtest_ping, "max"),
|
||||
uptime: this.getNumericValueOverAllSites(result.data, "www", (site) => site.uptime, "max"),
|
||||
status: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"),
|
||||
latency: this.getNumericValueOverAllSites(stats, "www", (site) => site.latency, "max"),
|
||||
ping: this.getNumericValueOverAllSites(stats, "www", (site) => site.speedtest_ping, "max"),
|
||||
uptime: this.getNumericValueOverAllSites(stats, "www", (site) => site.uptime, "max"),
|
||||
},
|
||||
wifi: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "wlan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(result.data, "wlan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(result.data, "wlan", (site) => site.num_guest, "sum"),
|
||||
status: this.getStatusValueOverAllSites(stats, "wlan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(stats, "wlan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(stats, "wlan", (site) => site.num_guest, "sum"),
|
||||
},
|
||||
lan: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "lan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(result.data, "lan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(result.data, "lan", (site) => site.num_guest, "sum"),
|
||||
status: this.getStatusValueOverAllSites(stats, "lan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(stats, "lan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(stats, "lan", (site) => site.num_guest, "sum"),
|
||||
},
|
||||
vpn: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "vpn", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(result.data, "vpn", (site) => site.remote_user_num_active, "sum"),
|
||||
status: this.getStatusValueOverAllSites(stats, "vpn", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(stats, "vpn", (site) => site.remote_user_num_active, "sum"),
|
||||
},
|
||||
} satisfies NetworkControllerSummary;
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await this.authenticateAndConstructSessionInHeaderAsync();
|
||||
const client = await this.createControllerClientAsync();
|
||||
await client.getSitesStats();
|
||||
}
|
||||
|
||||
private getStatusValueOverAllSites(
|
||||
data: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: Subsystem,
|
||||
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
|
||||
private async createControllerClientAsync() {
|
||||
const portString = new URL(this.integration.url).port;
|
||||
const port = Number.isInteger(portString) ? Number(portString) : undefined;
|
||||
const hostname = new URL(this.integration.url).hostname;
|
||||
|
||||
const client = new Controller({
|
||||
host: hostname,
|
||||
// @ts-expect-error the URL construction is incorrect and does not append the required / at the end: https://github.com/jens-maus/node-unifi/blob/05665e8f82a900a15a9ea8b1071750b29825b3bc/unifi.js#L56, https://github.com/jens-maus/node-unifi/blob/05665e8f82a900a15a9ea8b1071750b29825b3bc/unifi.js#L95
|
||||
port: port === undefined ? "/" : `${port}/`,
|
||||
sslverify: false, // TODO: implement a "ignore certificate toggle", see https://github.com/homarr-labs/homarr/issues/2553
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
});
|
||||
|
||||
// Object.defineProperty(client, '_baseurl', { value: url });
|
||||
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);
|
||||
return client;
|
||||
}
|
||||
|
||||
private getStatusValueOverAllSites<S extends HealthSubsystem>(
|
||||
data: SiteStats[],
|
||||
subsystem: S,
|
||||
selectCallback: (obj: SiteStats["health"][number]) => boolean,
|
||||
) {
|
||||
return this.getBooleanValueOverAllSites(data, subsystem, selectCallback) ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
private getNumericValueOverAllSites<
|
||||
S extends Subsystem,
|
||||
T extends Extract<z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number], { subsystem: S }>,
|
||||
>(
|
||||
data: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: S,
|
||||
selectCallback: (obj: T) => number,
|
||||
strategy: "average" | "sum" | "max",
|
||||
): number {
|
||||
const values = data.data.map((site) => selectCallback(this.getSubsystem(site.health, subsystem) as T));
|
||||
S extends HealthSubsystem,
|
||||
T extends Extract<SiteStats["health"][number], { subsystem: S }>,
|
||||
>(data: SiteStats[], subsystem: S, selectCallback: (obj: T) => number, strategy: "average" | "sum" | "max"): number {
|
||||
const values = data.map((site) => selectCallback(this.getSubsystem(site.health, subsystem) as T));
|
||||
|
||||
if (strategy === "sum") {
|
||||
return values.reduce((first, second) => first + second, 0);
|
||||
@@ -111,118 +86,18 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
|
||||
}
|
||||
|
||||
private getBooleanValueOverAllSites(
|
||||
data: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: Subsystem,
|
||||
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
|
||||
data: SiteStats[],
|
||||
subsystem: HealthSubsystem,
|
||||
selectCallback: (obj: SiteStats["health"][number]) => boolean,
|
||||
): boolean {
|
||||
return data.data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem)));
|
||||
return data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem)));
|
||||
}
|
||||
|
||||
private getSubsystem(
|
||||
health: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"],
|
||||
subsystem: Subsystem,
|
||||
) {
|
||||
private getSubsystem(health: SiteStats["health"], subsystem: HealthSubsystem) {
|
||||
const value = health.find((health) => health.subsystem === subsystem);
|
||||
if (!value) {
|
||||
throw new Error(`Subsystem ${subsystem} not found!`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private headers: Record<string, string> | undefined = undefined;
|
||||
private csrfToken: string | undefined;
|
||||
|
||||
private async authenticateAndConstructSessionInHeaderAsync(): Promise<void> {
|
||||
await this.determineUDMVariantAsync();
|
||||
await this.authenticateAndSetCookieAsync();
|
||||
}
|
||||
|
||||
private async authenticateAndSetCookieAsync(): Promise<void> {
|
||||
if (this.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = this.prefix === udmpPrefix ? "auth/login" : "login";
|
||||
logger.debug("Authenticating at network console: " + endpoint);
|
||||
|
||||
const loginUrl = this.url(`/api/${endpoint}`);
|
||||
|
||||
const loginBody = {
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
remember: true,
|
||||
};
|
||||
|
||||
const requestHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (this.csrfToken) {
|
||||
requestHeaders["X-CSRF-TOKEN"] = this.csrfToken;
|
||||
}
|
||||
|
||||
const loginResponse = await fetchWithTrustedCertificatesAsync(loginUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
body: JSON.stringify(loginBody),
|
||||
}).catch((err: TypeError) => {
|
||||
const detailMessage = String(err.cause);
|
||||
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throwErrorByStatusCode(loginResponse.status);
|
||||
}
|
||||
|
||||
const responseHeaders = loginResponse.headers;
|
||||
const newHeaders: Record<string, string> = {};
|
||||
const loginToken = UnifiControllerIntegration.extractLoginTokenFromCookies(responseHeaders);
|
||||
newHeaders.Cookie = `${loginToken};`;
|
||||
this.headers = newHeaders;
|
||||
}
|
||||
|
||||
private async determineUDMVariantAsync(): Promise<void> {
|
||||
if (this.prefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Prefix for authentication not set; initial connect to determine UDM variant");
|
||||
const url = this.url("/");
|
||||
|
||||
const { status, ok, headers } = await fetchWithTrustedCertificatesAsync(url, { method: "HEAD" })
|
||||
.then((res) => res)
|
||||
.catch((err: TypeError) => {
|
||||
const detailMessage = String(err.cause);
|
||||
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
throw new IntegrationTestConnectionError("invalidUrl", "status code: " + status);
|
||||
}
|
||||
|
||||
let prefix = "";
|
||||
if (headers.get("x-csrf-token") !== null) {
|
||||
// Unifi OS < 3.2.5 passes & requires csrf-token
|
||||
prefix = udmpPrefix;
|
||||
const headersCSRFToken = headers.get("x-csrf-token");
|
||||
if (headersCSRFToken) {
|
||||
this.csrfToken = headersCSRFToken;
|
||||
}
|
||||
} else if (headers.get("access-control-expose-headers") !== null) {
|
||||
// Unifi OS ≥ 3.2.5 doesnt pass csrf token but still uses different endpoint
|
||||
prefix = udmpPrefix;
|
||||
}
|
||||
this.prefix = prefix;
|
||||
logger.debug("Final prefix: " + this.prefix);
|
||||
}
|
||||
|
||||
private static extractLoginTokenFromCookies(headers: Headers): string {
|
||||
const cookies = headers.get("set-cookie") ?? "";
|
||||
const loginToken = cookies.split(";").find((cookie) => cookie.includes("TOKEN"));
|
||||
|
||||
if (loginToken) {
|
||||
return loginToken;
|
||||
}
|
||||
|
||||
throw new Error("Login token not found in cookies");
|
||||
}
|
||||
}
|
||||
@@ -1,130 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import type { SiteStats } from "node-unifi";
|
||||
|
||||
export const healthSchema = z.discriminatedUnion("subsystem", [
|
||||
z.object({
|
||||
subsystem: z.literal("wlan"),
|
||||
num_user: z.number(),
|
||||
num_guest: z.number(),
|
||||
num_iot: z.number(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
status: z.string(),
|
||||
num_ap: z.number(),
|
||||
num_adopted: z.number(),
|
||||
num_disabled: z.number(),
|
||||
num_disconnected: z.number(),
|
||||
num_pending: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("wan"),
|
||||
num_gw: z.number(),
|
||||
num_adopted: z.number(),
|
||||
num_disconnected: z.number(),
|
||||
num_pending: z.number(),
|
||||
status: z.string(),
|
||||
wan_ip: z.string().ip(),
|
||||
gateways: z.array(z.string().ip()),
|
||||
netmask: z.string().ip(),
|
||||
nameservers: z.array(z.string().ip()).optional(),
|
||||
num_sta: z.number(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
gw_mac: z.string(),
|
||||
gw_name: z.string(),
|
||||
"gw_system-stats": z.object({
|
||||
cpu: z.string(),
|
||||
mem: z.string(),
|
||||
uptime: z.string(),
|
||||
}),
|
||||
gw_version: z.string(),
|
||||
isp_name: z.string(),
|
||||
isp_organization: z.string(),
|
||||
uptime_stats: z.object({
|
||||
WAN: z.object({
|
||||
alerting_monitors: z.array(
|
||||
z.object({
|
||||
availability: z.number(),
|
||||
latency_average: z.number(),
|
||||
target: z.string(),
|
||||
type: z.enum(["icmp", "dns"]),
|
||||
}),
|
||||
),
|
||||
availability: z.number(),
|
||||
latency_average: z.number(),
|
||||
monitors: z.array(
|
||||
z.object({
|
||||
availability: z.number(),
|
||||
latency_average: z.number(),
|
||||
target: z.string(),
|
||||
type: z.enum(["icmp", "dns"]),
|
||||
}),
|
||||
),
|
||||
time_period: z.number(),
|
||||
uptime: z.number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("www"),
|
||||
status: z.string(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
latency: z.number(),
|
||||
uptime: z.number(),
|
||||
drops: z.number(),
|
||||
xput_up: z.number(),
|
||||
xput_down: z.number(),
|
||||
speedtest_status: z.string(),
|
||||
speedtest_lastrun: z.number(),
|
||||
speedtest_ping: z.number(),
|
||||
gw_mac: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("lan"),
|
||||
lan_ip: z.string().ip().nullish(),
|
||||
status: z.string(),
|
||||
num_user: z.number(),
|
||||
num_guest: z.number(),
|
||||
num_iot: z.number(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
num_sw: z.number(),
|
||||
num_adopted: z.number(),
|
||||
num_disconnected: z.number(),
|
||||
num_pending: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("vpn"),
|
||||
status: z.string(),
|
||||
remote_user_enabled: z.boolean(),
|
||||
remote_user_num_active: z.number(),
|
||||
remote_user_num_inactive: z.number(),
|
||||
remote_user_rx_bytes: z.number(),
|
||||
remote_user_tx_bytes: z.number(),
|
||||
remote_user_rx_packets: z.number(),
|
||||
remote_user_tx_packets: z.number(),
|
||||
site_to_site_enabled: z.boolean(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Health = z.infer<typeof healthSchema>;
|
||||
|
||||
export const siteSchema = z.object({
|
||||
anonymous_id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
external_id: z.string().uuid(),
|
||||
_id: z.string(),
|
||||
attr_no_delete: z.boolean(),
|
||||
attr_hidden_id: z.string(),
|
||||
desc: z.string(),
|
||||
health: z.array(healthSchema),
|
||||
num_new_alarms: z.number(),
|
||||
});
|
||||
export type Site = z.infer<typeof siteSchema>;
|
||||
|
||||
export const unifiSummaryResponseSchema = z.object({
|
||||
meta: z.object({
|
||||
rc: z.enum(["ok"]),
|
||||
}),
|
||||
data: z.array(siteSchema),
|
||||
});
|
||||
export type HealthSubsystem = SiteStats["health"][number]["subsystem"];
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
import { Aria2Integration } from "../src";
|
||||
|
||||
const API_KEY = "ARIA2_API_KEY";
|
||||
const IMAGE_NAME = "hurlenko/aria2-ariang:latest";
|
||||
|
||||
describe("Aria2 integration", () => {
|
||||
beforeAll(async () => {
|
||||
const containerRuntimeClient = await getContainerRuntimeClient();
|
||||
await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME));
|
||||
}, 100_000);
|
||||
|
||||
test("Test connection should work", async () => {
|
||||
// Arrange
|
||||
const startedContainer = await createAria2Container().start();
|
||||
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await aria2Integration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
}, 30_000);
|
||||
|
||||
test("pauseQueueAsync should work", async () => {
|
||||
// Arrange
|
||||
const startedContainer = await createAria2Container().start();
|
||||
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||
|
||||
// Acts
|
||||
const actAsync = async () => await aria2Integration.pauseQueueAsync();
|
||||
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
await expect(getAsync()).resolves.toMatchObject({ status: { paused: true } });
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
}, 30_000); // Timeout of 30 seconds
|
||||
|
||||
test("Items should be empty", async () => {
|
||||
// Arrange
|
||||
const startedContainer = await createAria2Container().start();
|
||||
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||
|
||||
// Act
|
||||
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
|
||||
|
||||
// Assert
|
||||
await expect(getAsync()).resolves.not.toThrow();
|
||||
await expect(getAsync()).resolves.toMatchObject({
|
||||
items: [],
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
}, 30_000); // Timeout of 30 seconds
|
||||
|
||||
test("1 Items should exist after adding one", async () => {
|
||||
// Arrange
|
||||
const startedContainer = await createAria2Container().start();
|
||||
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||
await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration);
|
||||
|
||||
// Act
|
||||
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
|
||||
|
||||
// Assert
|
||||
await expect(getAsync()).resolves.not.toThrow();
|
||||
expect((await getAsync()).items).toHaveLength(1);
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
}, 30_000); // Timeout of 30 seconds
|
||||
|
||||
test("Delete item should result in empty items", async () => {
|
||||
// Arrange
|
||||
const startedContainer = await createAria2Container().start();
|
||||
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||
const item = await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await aria2Integration.deleteItemAsync(item, true);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
// NzbGet is slow and we wait for a second before querying the items. Test was flaky without this.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const result = await aria2Integration.getClientJobsAndStatusAsync();
|
||||
expect(result.items).toHaveLength(0);
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
}, 30_000); // Timeout of 30 seconds
|
||||
});
|
||||
|
||||
const createAria2Container = () => {
|
||||
return new GenericContainer(IMAGE_NAME)
|
||||
.withExposedPorts(8080)
|
||||
.withEnvironment({
|
||||
PUID: "1000",
|
||||
PGID: "1000",
|
||||
ARIA2RPCPORT: "443",
|
||||
RPC_SECRET: API_KEY,
|
||||
})
|
||||
.withWaitStrategy(Wait.forLogMessage("listening on TCP port"));
|
||||
};
|
||||
|
||||
const createAria2Intergration = (container: StartedTestContainer, apikey: string) => {
|
||||
return new Aria2Integration({
|
||||
id: "1",
|
||||
decryptedSecrets: [
|
||||
{
|
||||
kind: "apiKey",
|
||||
value: apikey,
|
||||
},
|
||||
],
|
||||
name: "Aria2",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(8080)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const aria2AddItemAsync = async (container: StartedTestContainer, apiKey: string, integration: Aria2Integration) => {
|
||||
await fetch(`http://${container.getHost()}:${container.getMappedPort(8080)}/jsonrpc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
|
||||
method: "aria2.addUri",
|
||||
params: [`token:${apiKey}`, ["https://google.com"]],
|
||||
}),
|
||||
});
|
||||
|
||||
await delay(3000);
|
||||
|
||||
const {
|
||||
items: [item],
|
||||
} = await integration.getClientJobsAndStatusAsync();
|
||||
|
||||
if (!item) {
|
||||
throw new Error("No item found");
|
||||
}
|
||||
|
||||
return item;
|
||||
};
|
||||
const delay = (microseconds: number) => new Promise((resolve) => setTimeout(resolve, microseconds));
|
||||
@@ -24,16 +24,16 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"ioredis": "5.6.0",
|
||||
"ioredis": "5.6.1",
|
||||
"superjson": "2.2.2",
|
||||
"winston": "3.17.0",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -33,19 +33,19 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.17.4",
|
||||
"@mantine/core": "^7.17.5",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.2.5",
|
||||
"next": "15.3.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint": "^9.25.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
interface AppSelectModalProps {
|
||||
onSelect?: (appId: string) => void;
|
||||
}
|
||||
|
||||
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
|
||||
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
apps
|
||||
.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[apps, search],
|
||||
);
|
||||
|
||||
const handleSelect = (appId: string) => {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(appId);
|
||||
}
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAddNewApp = () => {
|
||||
openQuickAddAppModal({
|
||||
onClose(createdAppId) {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(createdAppId);
|
||||
}
|
||||
actions.closeModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch />}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0].id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<IconPlus size={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.create.action")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<Grid.Col key={app.id} span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<Image src={app.iconUrl || ""} alt={app.name} width={24} height={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={() => handleSelect(app.id)} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.select.action", { app: app.name })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Grid.Col span={12}>
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
size: "xl",
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export { AppSelectModal } from "./app-select-modal";
|
||||
export { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -8,7 +9,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
interface QuickAddAppModalProps {
|
||||
onClose: (createdAppId: string) => Promise<void>;
|
||||
onClose: (createdAppId: string) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const AddBoardModal = createModal(({ actions }) => {
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const AddGroupModal = createModal<void>(({ actions }) => {
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" color="teal">
|
||||
<Button loading={isPending} type="submit">
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user