Compare commits

..
1 Commits
Author SHA1 Message Date
Meier Lukas 8889537ff8 wip: libsql migration 2025-12-19 22:11:35 +01:00
149 changed files with 2486 additions and 3651 deletions

No files matched your search

-3
View File
@@ -33,9 +33,6 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.49.0
- 1.48.0
- 1.47.0
- 1.46.0 - 1.46.0
- 1.45.3 - 1.45.3
- 1.45.2 - 1.45.2
-102
View File
@@ -1,105 +1,3 @@
## [1.50.1](https://github.com/homarr-labs/homarr/compare/v1.50.0...v1.50.1) (2026-01-16)
### Bug Fixes
* **deps:** update dependency ioredis to v5.9.0 ([#4827](https://github.com/homarr-labs/homarr/issues/4827)) ([155d4f2](https://github.com/homarr-labs/homarr/commit/155d4f2270b3b35c4ad696499b0aaa0b10100989))
* **deps:** update dependency ioredis to v5.9.1 ([#4844](https://github.com/homarr-labs/homarr/issues/4844)) ([5d00642](https://github.com/homarr-labs/homarr/commit/5d006422688cf3fe7440ae3ab66ad3b2cc9286a8))
* **deps:** update dependency ldapts to v8.1.3 ([#4841](https://github.com/homarr-labs/homarr/issues/4841)) ([7fa9907](https://github.com/homarr-labs/homarr/commit/7fa990712bddc88bbb3bbe01e758bfef7cb4dea3))
* **deps:** update dependency react-error-boundary to ^6.0.2 ([#4821](https://github.com/homarr-labs/homarr/issues/4821)) ([e9ba102](https://github.com/homarr-labs/homarr/commit/e9ba102cf668c0ef54628d3dbc6e6cbda2d059b5))
* **deps:** update dependency react-error-boundary to ^6.0.3 ([#4847](https://github.com/homarr-labs/homarr/issues/4847)) ([c24006d](https://github.com/homarr-labs/homarr/commit/c24006dca691f26a7187c0ba112d9ed2ec424bb0))
* **deps:** update dependency sass to ^1.97.2 ([#4835](https://github.com/homarr-labs/homarr/issues/4835)) ([07ba531](https://github.com/homarr-labs/homarr/commit/07ba53165c90d675aa54c5ad3fca930fef5cd35e))
* **deps:** update dependency typescript-eslint to ^8.52.0 ([#4830](https://github.com/homarr-labs/homarr/issues/4830)) ([fa8af6a](https://github.com/homarr-labs/homarr/commit/fa8af6a453b846af2305db3c318c8c1c303a3a88))
* **deps:** update dependency undici to v7.17.0 ([#4826](https://github.com/homarr-labs/homarr/issues/4826)) ([21a91b1](https://github.com/homarr-labs/homarr/commit/21a91b1489ff1a1f0a1428a3f50f00db8932da72))
* **deps:** update dependency undici to v7.18.0 ([#4829](https://github.com/homarr-labs/homarr/issues/4829)) ([b76ab29](https://github.com/homarr-labs/homarr/commit/b76ab2961d84081c9ef04d76193f21a1068e72d9))
* **deps:** update dependency undici to v7.18.2 ([#4837](https://github.com/homarr-labs/homarr/issues/4837)) ([16733c2](https://github.com/homarr-labs/homarr/commit/16733c2a0efabcbc5b693a8b6f82833f6fc344b7))
* **deps:** update dependency ws to ^8.19.0 ([#4833](https://github.com/homarr-labs/homarr/issues/4833)) ([629359e](https://github.com/homarr-labs/homarr/commit/629359e3e44ddad880842cfd576edb514f80d61a))
* **deps:** update mantine monorepo to ^8.3.11 ([#4822](https://github.com/homarr-labs/homarr/issues/4822)) ([0364e10](https://github.com/homarr-labs/homarr/commit/0364e102cd6c0f560bc49e0b9ccd71df0b75eed6))
* **deps:** update mantine monorepo to ^8.3.12 ([#4848](https://github.com/homarr-labs/homarr/issues/4848)) ([275a346](https://github.com/homarr-labs/homarr/commit/275a346afaaebfa894ef2193f70966986a138dbb))
* **deps:** update tiptap monorepo to v3.15.0 ([#4832](https://github.com/homarr-labs/homarr/issues/4832)) ([ab3ee4f](https://github.com/homarr-labs/homarr/commit/ab3ee4f78a739bdb3b49a81652420df6ec143b62))
* **deps:** update tiptap monorepo to v3.15.1 ([#4838](https://github.com/homarr-labs/homarr/issues/4838)) ([d479b63](https://github.com/homarr-labs/homarr/commit/d479b63a803f2295ccdf20fcd31c418a4aab148c))
* **deps:** update tiptap monorepo to v3.15.2 ([#4842](https://github.com/homarr-labs/homarr/issues/4842)) ([55c2666](https://github.com/homarr-labs/homarr/commit/55c2666f9d27ca34b6d028070f0d7b84e4c40e97))
* **deps:** update tiptap monorepo to v3.15.3 ([#4843](https://github.com/homarr-labs/homarr/issues/4843)) ([b26cf0e](https://github.com/homarr-labs/homarr/commit/b26cf0e65fd8150e241465628df2cb8b5a72088e))
* **deps:** update turbo monorepo to ^2.7.3 ([#4831](https://github.com/homarr-labs/homarr/issues/4831)) ([50410fe](https://github.com/homarr-labs/homarr/commit/50410fedb3f33ed131f923257e61ae2dca4e0288))
## [1.50.0](https://github.com/homarr-labs/homarr/compare/v1.49.1...v1.50.0) (2026-01-09)
### Features
* **auth:** extend API key authentication to tRPC endpoints ([#4732](https://github.com/homarr-labs/homarr/issues/4732)) ([0dc61a8](https://github.com/homarr-labs/homarr/commit/0dc61a88b8d12c61fd436adf0693c9fa5087a8c0))
* **users:** add libravatar / gravatar support ([#4277](https://github.com/homarr-labs/homarr/issues/4277)) ([a2a3412](https://github.com/homarr-labs/homarr/commit/a2a34124ae11b0b9242b9c2579f7376768d35d52))
### Bug Fixes
* **deps:** update dependency @tabler/icons-react to ^3.36.0 ([#4707](https://github.com/homarr-labs/homarr/issues/4707)) ([35e0198](https://github.com/homarr-labs/homarr/commit/35e0198b5df2a315ea2efb584752c5af062f960d))
* **deps:** update dependency @tabler/icons-react to ^3.36.1 ([#4812](https://github.com/homarr-labs/homarr/issues/4812)) ([dabb2bb](https://github.com/homarr-labs/homarr/commit/dabb2bb7821f6a68d41c4a931bb70c41fdd21a72))
* **deps:** update dependency @tanstack/react-query to ^5.90.15 ([#4794](https://github.com/homarr-labs/homarr/issues/4794)) ([ea848bd](https://github.com/homarr-labs/homarr/commit/ea848bdd0bc1268675d00213bb86dc1d1b298162))
* **deps:** update dependency @tanstack/react-query to ^5.90.16 ([#4799](https://github.com/homarr-labs/homarr/issues/4799)) ([413e846](https://github.com/homarr-labs/homarr/commit/413e846133f13a800f509273b8b87cf677f0024d))
* **deps:** update dependency ldapts to v8.1.0 ([#4804](https://github.com/homarr-labs/homarr/issues/4804)) ([c8ecf5f](https://github.com/homarr-labs/homarr/commit/c8ecf5f0a13b1c74f24cebced76a60728eb2d543))
* **deps:** update dependency ldapts to v8.1.2 ([#4805](https://github.com/homarr-labs/homarr/issues/4805)) ([9e63e34](https://github.com/homarr-labs/homarr/commit/9e63e3443af7946199999104024f00991cc1cba2))
* **deps:** update dependency next-intl to v4.7.0 ([#4806](https://github.com/homarr-labs/homarr/issues/4806)) ([35042a7](https://github.com/homarr-labs/homarr/commit/35042a783ce5c381425bb96766adbefcac75bfdd))
* **deps:** update dependency typescript-eslint to ^8.51.0 ([#4795](https://github.com/homarr-labs/homarr/issues/4795)) ([f0a2551](https://github.com/homarr-labs/homarr/commit/f0a2551fdf5b5e02ece789d473411ad3edcfb093))
## [1.49.1](https://github.com/homarr-labs/homarr/compare/v1.49.0...v1.49.1) (2026-01-03)
### Bug Fixes
* **db:** connection issue do to missing host and name of db when connecting with host ([#4786](https://github.com/homarr-labs/homarr/issues/4786)) ([7d33225](https://github.com/homarr-labs/homarr/commit/7d3322576c3ed1b9917873bf707d77d2a4eef1a8))
* **deps:** update dependency ldapts to v8.0.36 ([#4790](https://github.com/homarr-labs/homarr/issues/4790)) ([1842e5f](https://github.com/homarr-labs/homarr/commit/1842e5fff5c072c8da45a01be860e63d12d5062a))
* **deps:** update dependency react-error-boundary to ^6.0.1 ([#4791](https://github.com/homarr-labs/homarr/issues/4791)) ([1b70973](https://github.com/homarr-labs/homarr/commit/1b70973084c9e9ebed34e8d7d9690a9e5530f60f))
* **unraid:** temperature of disks can be null ([#4788](https://github.com/homarr-labs/homarr/issues/4788)) ([60f9d4e](https://github.com/homarr-labs/homarr/commit/60f9d4e6e69df2190171e5d8e263b2030447ab2e))
## [1.49.0](https://github.com/homarr-labs/homarr/compare/v1.48.0...v1.49.0) (2026-01-02)
### Features
* unraid integration ([#4439](https://github.com/homarr-labs/homarr/issues/4439)) ([9b0dd6d](https://github.com/homarr-labs/homarr/commit/9b0dd6d6cebda4a541c8859506d89e9e9f782fa4))
### Bug Fixes
* add orphaned status for containers with invalid stats ([#4441](https://github.com/homarr-labs/homarr/issues/4441)) ([f04f537](https://github.com/homarr-labs/homarr/commit/f04f53759b6890d5d2dd683a9bf124d8ff12780b))
* **deps:** update dependency @xterm/addon-fit to v0.11.0 ([#4756](https://github.com/homarr-labs/homarr/issues/4756)) ([1c727e5](https://github.com/homarr-labs/homarr/commit/1c727e59af4041e5e41da854633ccd3c030c1d18))
* **deps:** update dependency @xterm/xterm to v6 ([#4757](https://github.com/homarr-labs/homarr/issues/4757)) ([fad478c](https://github.com/homarr-labs/homarr/commit/fad478c549cdbc616f921d9b444bff48b59e77a5))
* **deps:** update dependency isomorphic-dompurify to ^2.35.0 ([#4779](https://github.com/homarr-labs/homarr/issues/4779)) ([236fa35](https://github.com/homarr-labs/homarr/commit/236fa35f7e0943be33bba153e7030ce4e3bb204e))
* **deps:** update dependency jotai to ^2.16.1 ([#4772](https://github.com/homarr-labs/homarr/issues/4772)) ([d002700](https://github.com/homarr-labs/homarr/commit/d00270046a041a10c1c540cfa79c1eee477f3303))
* **deps:** update dependency ldapts to v8.0.31 ([#4755](https://github.com/homarr-labs/homarr/issues/4755)) ([8b09693](https://github.com/homarr-labs/homarr/commit/8b096939e4e1da8e98791a8e05f108a136fe2810))
* **deps:** update dependency ldapts to v8.0.32 ([#4762](https://github.com/homarr-labs/homarr/issues/4762)) ([49afb82](https://github.com/homarr-labs/homarr/commit/49afb8244615f67af0586aaacf77a509231ac6dc))
* **deps:** update dependency ldapts to v8.0.34 ([#4765](https://github.com/homarr-labs/homarr/issues/4765)) ([809bf55](https://github.com/homarr-labs/homarr/commit/809bf559fe1b96df9a903dc790d138cad7368adb))
* **deps:** update dependency ldapts to v8.0.35 ([#4768](https://github.com/homarr-labs/homarr/issues/4768)) ([e574dd7](https://github.com/homarr-labs/homarr/commit/e574dd70f1421e9254a289a0f02fd0b4d43e26ad))
* **deps:** update dependency typescript-eslint to ^8.50.1 ([#4758](https://github.com/homarr-labs/homarr/issues/4758)) ([adaa803](https://github.com/homarr-labs/homarr/commit/adaa8039dd545109246ccd9fa15570739f755f62))
* **deps:** update nextjs monorepo to v16.1.1 ([#4759](https://github.com/homarr-labs/homarr/issues/4759)) ([7712ab4](https://github.com/homarr-labs/homarr/commit/7712ab410b5094e5d84df75461e07efdfccc285b))
* **deps:** update tanstack-query monorepo ([#4782](https://github.com/homarr-labs/homarr/issues/4782)) ([1e5d38f](https://github.com/homarr-labs/homarr/commit/1e5d38fd3ca4c68fa4c61b02757a3cff39839fdb))
* **deps:** update testcontainers-node monorepo to ^11.11.0 ([#4781](https://github.com/homarr-labs/homarr/issues/4781)) ([e40a116](https://github.com/homarr-labs/homarr/commit/e40a11608cb21a75e6910028abf97e14b5275f56))
* **deps:** update turbo monorepo to ^2.7.2 ([#4764](https://github.com/homarr-labs/homarr/issues/4764)) ([9191430](https://github.com/homarr-labs/homarr/commit/919143022969350f6ee7be2f53be2fe09a89bba3))
## [1.48.0](https://github.com/homarr-labs/homarr/compare/v1.47.0...v1.48.0) (2025-12-26)
### Features
* enable dns caching by default ([#4706](https://github.com/homarr-labs/homarr/issues/4706)) ([fbb5298](https://github.com/homarr-labs/homarr/commit/fbb5298cb05b12eb2f19c121a1bc103caeb3e576))
* **http:** add proxy support ([#4721](https://github.com/homarr-labs/homarr/issues/4721)) ([5a57115](https://github.com/homarr-labs/homarr/commit/5a57115ca073aea1c75a000fda6a23648c5488e4))
### Bug Fixes
* **deps:** update dependency @t3-oss/env-nextjs to ^0.13.10 ([#4710](https://github.com/homarr-labs/homarr/issues/4710)) ([689523a](https://github.com/homarr-labs/homarr/commit/689523a194ab48bdc072b1cdf65f07ade6cbc584))
* **deps:** update dependency ldapts to v8.0.24 ([#4711](https://github.com/homarr-labs/homarr/issues/4711)) ([648f7e2](https://github.com/homarr-labs/homarr/commit/648f7e2c7743af81f4bced36b7e1e80c21e851e5))
* **deps:** update dependency ldapts to v8.0.25 ([#4724](https://github.com/homarr-labs/homarr/issues/4724)) ([ef12b2b](https://github.com/homarr-labs/homarr/commit/ef12b2b5c2e1131138937fb6da61ce4c167fe0eb))
* **deps:** update dependency ldapts to v8.0.27 ([#4737](https://github.com/homarr-labs/homarr/issues/4737)) ([8891ac4](https://github.com/homarr-labs/homarr/commit/8891ac42399e47fda6efc657b8ff2fe58aebca9e))
* **deps:** update dependency ldapts to v8.0.28 ([#4743](https://github.com/homarr-labs/homarr/issues/4743)) ([75c5453](https://github.com/homarr-labs/homarr/commit/75c545344aae39550917331458c01a894b6a942f))
* **deps:** update dependency ldapts to v8.0.29 ([#4747](https://github.com/homarr-labs/homarr/issues/4747)) ([5dd7400](https://github.com/homarr-labs/homarr/commit/5dd74006843ae72f63e631b14f0fd50c911d610b))
* **deps:** update dependency ldapts to v8.0.30 ([#4749](https://github.com/homarr-labs/homarr/issues/4749)) ([96c2c0d](https://github.com/homarr-labs/homarr/commit/96c2c0d604737c64722fa21bf79965c806bf4da3))
* **deps:** update dependency mysql2 to v3.16.0 ([#4731](https://github.com/homarr-labs/homarr/issues/4731)) ([f34bdce](https://github.com/homarr-labs/homarr/commit/f34bdce6446d4e62b54b96f63e91cfdddd51e901))
* **deps:** update dependency next-intl to v4.6.1 ([#4730](https://github.com/homarr-labs/homarr/issues/4730)) ([f4d782a](https://github.com/homarr-labs/homarr/commit/f4d782abefb78e085ca3fc66034c0ca54fb21120))
* **deps:** update dependency sass to ^1.97.0 ([#4726](https://github.com/homarr-labs/homarr/issues/4726)) ([88baad8](https://github.com/homarr-labs/homarr/commit/88baad8143f4ca830e50644d6bd1d1980ef1da82))
* **deps:** update dependency sass to ^1.97.1 ([#4746](https://github.com/homarr-labs/homarr/issues/4746)) ([6b774f4](https://github.com/homarr-labs/homarr/commit/6b774f47d0a8bf731e7e1837a9d47e0dc53a1ff0))
* **deps:** update dependency typescript-eslint to ^8.50.0 ([#4720](https://github.com/homarr-labs/homarr/issues/4720)) ([2dc8fea](https://github.com/homarr-labs/homarr/commit/2dc8feae7ea6174a1d826d8f0151ddc2c04c6aa6))
* **deps:** update dependency zod to ^4.2.0 ([#4712](https://github.com/homarr-labs/homarr/issues/4712)) ([6e39524](https://github.com/homarr-labs/homarr/commit/6e39524f9d28d201c13738212d238726e1685832))
* **deps:** update dependency zod to ^4.2.1 ([#4722](https://github.com/homarr-labs/homarr/issues/4722)) ([68dc684](https://github.com/homarr-labs/homarr/commit/68dc6844fdee53aca55b44dd7d19bf9d3a35c057))
* **deps:** update nextjs monorepo to v16.1.0 ([#4739](https://github.com/homarr-labs/homarr/issues/4739)) ([99f2842](https://github.com/homarr-labs/homarr/commit/99f2842a5a102056c76ddc68cb5083791f324c3c))
* **deps:** update tiptap monorepo to v3.14.0 ([#4744](https://github.com/homarr-labs/homarr/issues/4744)) ([adbe707](https://github.com/homarr-labs/homarr/commit/adbe707c73f462771c3d21cbbcc21e8245eea6f3))
* **deps:** update trpc monorepo to ^11.8.1 ([#4750](https://github.com/homarr-labs/homarr/issues/4750)) ([93bf9ab](https://github.com/homarr-labs/homarr/commit/93bf9ab84b5387eefb8cc0b03057a4ccf0b13aa9))
* **deps:** update turbo monorepo to ^2.7.0 ([#4745](https://github.com/homarr-labs/homarr/issues/4745)) ([5d659a3](https://github.com/homarr-labs/homarr/commit/5d659a3ebbf79266158cb57236de90e7883e7a4e))
* **deps:** update turbo monorepo to ^2.7.1 ([#4751](https://github.com/homarr-labs/homarr/issues/4751)) ([d8db016](https://github.com/homarr-labs/homarr/commit/d8db0169a142c2ec302806fe946cb17431ccae6b))
## [1.47.0](https://github.com/homarr-labs/homarr/compare/v1.46.0...v1.47.0) (2025-12-19) ## [1.47.0](https://github.com/homarr-labs/homarr/compare/v1.46.0...v1.47.0) (2025-12-19)
### Features ### Features
+5 -5
View File
@@ -2,8 +2,8 @@
* @homarr-labs/maintainers * @homarr-labs/maintainers
# Exempt Renovate‑managed files (no owners) # Exempt Renovate‑managed files (no owners)
package.json package.json @homarr-labs/none
package-lock.json package-lock.json @homarr-labs/none
pnpm-lock.yaml pnpm-lock.yaml @homarr-labs/none
Dockerfile Dockerfile @homarr-labs/none
docker-compose.yml docker-compose.yml @homarr-labs/none
-1
View File
@@ -45,7 +45,6 @@ COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder /app/packages/db/migrations ./db/migrations COPY --from=builder /app/packages/db/migrations ./db/migrations
+23 -23
View File
@@ -48,24 +48,24 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^8.3.12", "@mantine/colors-generator": "^8.3.10",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.10",
"@mantine/dropzone": "^8.3.12", "@mantine/dropzone": "^8.3.10",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.3.10",
"@mantine/modals": "^8.3.12", "@mantine/modals": "^8.3.10",
"@mantine/tiptap": "^8.3.12", "@mantine/tiptap": "^8.3.10",
"@million/lint": "1.0.14", "@million/lint": "1.0.14",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-query-next-experimental": "^5.91.0", "@tanstack/react-query-next-experimental": "^5.91.0",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.0",
"@trpc/next": "^11.8.1", "@trpc/next": "^11.8.0",
"@trpc/react-query": "^11.8.1", "@trpc/react-query": "^11.8.0",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.0",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.11.0", "@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^5.5.0",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"chroma-js": "^3.2.0", "chroma-js": "^3.2.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -73,30 +73,30 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"glob": "^13.0.0", "glob": "^13.0.0",
"isomorphic-dompurify": "^2.35.0", "isomorphic-dompurify": "^2.34.0",
"jotai": "^2.16.1", "jotai": "^2.16.0",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "16.1.1", "next": "16.0.10",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-error-boundary": "^6.0.3", "react-error-boundary": "^6.0.0",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.97.2", "sass": "^1.96.0",
"superjson": "2.2.6", "superjson": "2.2.6",
"swagger-ui-react": "^5.31.0", "swagger-ui-react": "^5.31.0",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
"@types/node": "^24.10.7", "@types/node": "^24.10.4",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "19.2.8", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
-1
View File
@@ -106,7 +106,6 @@ export default async function Layout(props: {
forceDisableStatus: serverSettings.board.forceDisableStatus, forceDisableStatus: serverSettings.board.forceDisableStatus,
}, },
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId }, search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
}} }}
{...innerProps} {...innerProps}
/> />
@@ -1,26 +0,0 @@
"use client";
import { Switch } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
const tUser = useScopedI18n("management.page.settings.section.user");
return (
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
{(form) => (
<>
<Switch
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
label={tUser("enableGravatar.label")}
description={tUser("enableGravatar.description")}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -12,7 +12,6 @@ import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form"; import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form"; import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form"; import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
@@ -43,10 +42,6 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.board.title")}</Title> <Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} /> <BoardSettingsForm defaultValues={serverSettings.board} />
</Stack> </Stack>
<Stack>
<Title order={2}>{tSettings("section.user.title")}</Title>
<UserSettingsForm defaultValues={serverSettings.user} />
</Stack>
<Stack> <Stack>
<Title order={2}>{tSettings("section.search.title")}</Title> <Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} /> <SearchSettingsForm defaultValues={serverSettings.search} />
@@ -76,7 +76,6 @@ const createColumns = (
accessorKey: "ports", accessorKey: "ports",
header: t("docker.field.ports.label"), header: t("docker.field.ports.label"),
Cell({ cell }) { Cell({ cell }) {
if (!cell.row.original.ports.length) return null;
return ( return (
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} /> <OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} />
); );
@@ -200,10 +200,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl" shadow="md" withBorder> <Card p="xl" shadow="md" withBorder>
<Stack maw={300} align="center" mx="auto"> <Stack maw={300} align="center" mx="auto">
<UserAvatar <UserAvatar size="xl" user={{ name: generalForm.values.username, image: null }} />
size="xl"
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
/>
<Text tt="uppercase" fw="bolder" size="xl"> <Text tt="uppercase" fw="bolder" size="xl">
{generalForm.values.username} {generalForm.values.username}
</Text> </Text>
@@ -44,7 +44,7 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
<Card> <Card>
{group.owner ? ( {group.owner ? (
<Group> <Group>
<UserAvatar user={group.owner} size={"lg"} /> <UserAvatar user={{ name: group.owner.name, image: group.owner.image }} size={"lg"} />
<Stack align={"start"} gap={3}> <Stack align={"start"} gap={3}>
<Text fw={"bold"}>{group.owner.name}</Text> <Text fw={"bold"}>{group.owner.name}</Text>
<Text>{group.owner.email}</Text> <Text>{group.owner.email}</Text>
+69 -11
View File
@@ -3,24 +3,21 @@ import { userAgent } from "next/server";
import { createOpenApiFetchHandler } from "trpc-to-openapi"; import { createOpenApiFetchHandler } from "trpc-to-openapi";
import { appRouter, createTRPCContext } from "@homarr/api"; import { appRouter, createTRPCContext } from "@homarr/api";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; import type { Session } from "@homarr/auth";
import { ipAddressFromHeaders } from "@homarr/common/server"; import { hashPasswordAsync } from "@homarr/auth";
import { createSessionAsync } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db } from "@homarr/db"; import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
const logger = createLogger({ module: "trpcOpenApiRoute" }); const logger = createLogger({ module: "trpcOpenApiRoute" });
const handlerAsync = async (req: NextRequest) => { const handlerAsync = async (req: NextRequest) => {
const apiKeyHeaderValue = req.headers.get(API_KEY_HEADER_NAME); const apiKeyHeaderValue = req.headers.get("ApiKey");
const ipAddress = ipAddressFromHeaders(req.headers); const ipAddress = req.headers.get("x-forwarded-for");
const { ua } = userAgent(req); const { ua } = userAgent(req);
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
logger.info(
`Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`,
);
const session = await getSessionFromApiKeyAsync(db, apiKeyHeaderValue, ipAddress, ua);
// Fallback to JSON if no content type is set // Fallback to JSON if no content type is set
if (!req.headers.has("Content-Type")) { if (!req.headers.has("Content-Type")) {
@@ -38,6 +35,67 @@ const handlerAsync = async (req: NextRequest) => {
}); });
}; };
const getSessionOrDefaultFromHeadersAsync = async (
apiKeyHeaderValue: string | null,
ipAdress: string | null,
userAgent: string,
): Promise<Session | null> => {
logger.info(
`Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`,
);
if (apiKeyHeaderValue === null) {
return null;
}
const [apiKeyId, apiKey] = apiKeyHeaderValue.split(".");
if (!apiKeyId || !apiKey) {
logger.warn("An attempt to authenticate over API has failed due to invalid API key format", {
ipAdress,
userAgent,
});
return null;
}
const apiKeyFromDb = await db.query.apiKeys.findFirst({
where: eq(apiKeys.id, apiKeyId),
columns: {
id: true,
apiKey: true,
salt: true,
},
with: {
user: {
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
},
},
},
});
if (!apiKeyFromDb) {
logger.warn("An attempt to authenticate over API has failed", { ipAdress, userAgent });
return null;
}
const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt);
if (apiKeyFromDb.apiKey !== hashedApiKey) {
logger.warn("An attempt to authenticate over API has failed", { ipAdress, userAgent });
return null;
}
logger.info("Read session from API request and found user", {
name: apiKeyFromDb.user.name,
id: apiKeyFromDb.user.id,
});
return await createSessionAsync(db, apiKeyFromDb.user);
};
export { export {
handlerAsync as DELETE, handlerAsync as DELETE,
handlerAsync as GET, handlerAsync as GET,
+1 -14
View File
@@ -1,14 +1,10 @@
import { userAgent } from "next/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter, createTRPCContext } from "@homarr/api"; import { appRouter, createTRPCContext } from "@homarr/api";
import { trpcPath } from "@homarr/api/shared"; import { trpcPath } from "@homarr/api/shared";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db } from "@homarr/db";
const logger = createLogger({ module: "trpcRoute" }); const logger = createLogger({ module: "trpcRoute" });
@@ -32,20 +28,11 @@ export function OPTIONS() {
} }
const handler = auth(async (req) => { const handler = auth(async (req) => {
// Try API key auth first, fall back to session cookie
const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME);
const ipAddress = ipAddressFromHeaders(req.headers);
const { ua } = userAgent(req);
const apiKeySession = await getSessionFromApiKeyAsync(db, apiKeyHeader, ipAddress, ua);
const session = apiKeySession ?? req.auth;
const response = await fetchRequestHandler({ const response = await fetchRequestHandler({
endpoint: trpcPath, endpoint: trpcPath,
router: appRouter, router: appRouter,
req, req,
createContext: () => createTRPCContext({ session, headers: req.headers }), createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
onError({ error, path, type }) { onError({ error, path, type }) {
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error })); logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
}, },
@@ -26,7 +26,6 @@ interface UserAccessPermission<TPermission extends string> {
user: { user: {
name: string | null; name: string | null;
image: string | null; image: string | null;
email: string | null;
id: string; id: string;
}; };
} }
@@ -67,7 +66,6 @@ interface Props<TPermission extends string> {
id: string; id: string;
name: string | null; name: string | null;
image: string | null; image: string | null;
email: string | null;
} | null; } | null;
}; };
translate: (key: TPermission) => string; translate: (key: TPermission) => string;
@@ -1,6 +1,5 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { TablerIcon } from "@tabler/icons-react";
import type { TablerIcon } from "@homarr/ui";
const AccessContext = createContext<{ const AccessContext = createContext<{
permissions: readonly string[]; permissions: readonly string[];
@@ -21,7 +21,6 @@ export interface FormProps<TPermission extends string> {
id: string; id: string;
name: string | null; name: string | null;
image: string | null; image: string | null;
email: string | null;
} | null; } | null;
}; };
accessQueryData: AccessQueryData<TPermission>; accessQueryData: AccessQueryData<TPermission>;
@@ -119,7 +118,6 @@ interface UserItemContentProps {
id: string; id: string;
name: string | null; name: string | null;
image: string | null; image: string | null;
email: string | null;
}; };
} }
@@ -13,7 +13,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps { interface InnerProps {
presentUserIds: string[]; presentUserIds: string[];
excludeExternalProviders?: boolean; excludeExternalProviders?: boolean;
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>; onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -36,7 +36,6 @@ export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps })
id: currentUser.id, id: currentUser.id,
name: currentUser.name ?? "", name: currentUser.name ?? "",
image: currentUser.image ?? "", image: currentUser.image ?? "",
email: currentUser.email ?? null,
}); });
setLoading(false); setLoading(false);
@@ -34,7 +34,6 @@ export class BoardMockBuilder {
id: createId(), id: createId(),
image: null, image: null,
name: "User", name: "User",
email: null,
}, },
groupPermissions: [], groupPermissions: [],
userPermissions: [], userPermissions: [],
+1 -2
View File
@@ -1,6 +1,5 @@
import { Anchor, Card, Stack, Text } from "@mantine/core"; import { Anchor, Card, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import type { TablerIcon } from "@homarr/ui";
interface NoResultsProps { interface NoResultsProps {
icon: TablerIcon; icon: TablerIcon;
+8 -12
View File
@@ -3,21 +3,17 @@ import type { MantineSize } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
interface CurrentUserAvatarProps { interface UserAvatarProps {
size: MantineSize; size: MantineSize;
} }
export const CurrentUserAvatar = async ({ size }: CurrentUserAvatarProps) => { export const CurrentUserAvatar = async ({ size }: UserAvatarProps) => {
const currentSession = await auth(); const currentSession = await auth();
return ( const user = {
<UserAvatar name: currentSession?.user.name ?? null,
user={{ image: currentSession?.user.image ?? null,
name: currentSession?.user.name ?? null, };
image: currentSession?.user.image ?? null,
email: currentSession?.user.email ?? null, return <UserAvatar user={user} size={size} />;
}}
size={size}
/>
);
}; };
+3 -3
View File
@@ -41,15 +41,15 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"superjson": "2.2.6", "superjson": "2.2.6",
"undici": "7.18.2" "undici": "7.16.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^24.10.7", "@types/node": "^24.10.4",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"esbuild": "^0.27.2", "esbuild": "^0.27.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"tsx": "4.20.4", "tsx": "4.20.4",
+2 -2
View File
@@ -27,14 +27,14 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"tsx": "4.20.4", "tsx": "4.20.4",
"ws": "^8.19.0" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"esbuild": "^0.27.2", "esbuild": "^0.27.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
+2 -2
View File
@@ -4,10 +4,10 @@
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0" "better-sqlite3": "^12.5.0"
}, },
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.25.0",
"engines": { "engines": {
"node": ">=24.12.0", "node": ">=24.12.0",
"pnpm": ">=10.28.0" "pnpm": ">=10.25.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
-7
View File
@@ -314,13 +314,6 @@
<br/> <br/>
<p align="center">Unifi<br/>Controller</p> <p align="center">Unifi<br/>Controller</p>
</a> </a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/unraid" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg" alt="Unraid" width="90" height="90" />
<br/>
<p align="center">Unraid</p>
</a>
</td></tr> </td></tr>
</tbody> </tbody>
</table> </table>
+17 -18
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.50.1", "version": "1.47.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -42,33 +42,32 @@
"@semantic-release/github": "^12.0.2", "@semantic-release/github": "^12.0.2",
"@semantic-release/npm": "^13.1.3", "@semantic-release/npm": "^13.1.3",
"@semantic-release/release-notes-generator": "^14.1.0", "@semantic-release/release-notes-generator": "^14.1.0",
"@testcontainers/redis": "^11.11.0", "@testcontainers/redis": "^11.10.0",
"@turbo/gen": "^2.7.3", "@turbo/gen": "^2.6.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16", "@vitest/coverage-v8": "^4.0.15",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.15",
"conventional-changelog-conventionalcommits": "^9.1.0", "conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"jsdom": "^27.4.0", "jsdom": "^27.3.0",
"json5": "^2.2.3", "json5": "^2.2.3",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"semantic-release": "^25.0.2", "semantic-release": "^25.0.2",
"testcontainers": "^11.11.0", "testcontainers": "^11.10.0",
"turbo": "^2.7.3", "turbo": "^2.6.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.0.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.16" "vitest": "^4.0.15"
}, },
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.25.0",
"engines": { "engines": {
"node": ">=24.12.0", "node": ">=24.12.0",
"pnpm": ">=10.28.0" "pnpm": ">=10.25.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml", "@tree-sitter-grammars/tree-sitter-yaml",
"bcrypt", "bcrypt",
"better-sqlite3",
"cpu-features", "cpu-features",
"esbuild", "esbuild",
"sharp", "sharp",
@@ -82,20 +81,20 @@
"axios@>=1.0.0 <1.8.2": ">=1.13.2", "axios@>=1.0.0 <1.8.2": ">=1.13.2",
"brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1", "brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1",
"brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1", "brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1",
"esbuild@<=0.24.2": ">=0.27.2", "esbuild@<=0.24.2": ">=0.27.1",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5", "form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.11.4", "hono@<4.6.5": ">=4.11.0",
"linkifyjs@<4.3.2": ">=4.3.2", "linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.6", "nanoid@>=4.0.0 <5.0.9": ">=5.1.6",
"prismjs@<1.30.0": ">=1.30.0", "prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.18.2", "proxmox-api>undici": "7.16.0",
"react-is": "^19.2.3", "react-is": "^19.2.3",
"rollup@>=4.0.0 <4.22.4": ">=4.55.1", "rollup@>=4.0.0 <4.22.4": ">=4.53.3",
"sha.js@<=2.4.11": ">=2.4.12", "sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.1", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.1",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.1", "tar-fs@>=2.0.0 <2.1.3": ">=3.1.1",
"tmp@<=0.2.3": ">=0.2.5", "tmp@<=0.2.3": ">=0.2.5",
"vite@>=5.0.0 <=5.4.18": ">=7.3.1" "vite@>=5.0.0 <=5.4.18": ">=7.2.7"
}, },
"patchedDependencies": { "patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch", "@types/node-unifi": "patches/@types__node-unifi.patch",
+7 -7
View File
@@ -41,18 +41,18 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.12",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.0",
"@trpc/react-query": "^11.8.1", "@trpc/react-query": "^11.8.0",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.0",
"@trpc/tanstack-react-query": "^11.8.1", "@trpc/tanstack-react-query": "^11.8.0",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"next": "16.1.1", "next": "16.0.10",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"superjson": "2.2.6", "superjson": "2.2.6",
"trpc-to-openapi": "^3.1.0", "trpc-to-openapi": "^3.1.0",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+1 -3
View File
@@ -1,7 +1,5 @@
import { generateOpenApiDocument } from "trpc-to-openapi"; import { generateOpenApiDocument } from "trpc-to-openapi";
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./root"; import { appRouter } from "./root";
export const openApiDocument = (base: string) => export const openApiDocument = (base: string) =>
@@ -13,7 +11,7 @@ export const openApiDocument = (base: string) =>
securitySchemes: { securitySchemes: {
apikey: { apikey: {
type: "apiKey", type: "apiKey",
name: API_KEY_HEADER_NAME, name: "ApiKey",
description: "API key which can be obtained in the Homarr administration dashboard", description: "API key which can be obtained in the Homarr administration dashboard",
in: "header", in: "header",
}, },
-1
View File
@@ -22,7 +22,6 @@ export const apiKeysRouter = createTRPCRouter({
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
}, },
}, },
+10 -8
View File
@@ -5,7 +5,7 @@ import { createId } from "@homarr/common";
import { asc, eq, inArray, like } from "@homarr/db"; import { asc, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema"; import { apps } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas"; import { selectAppSchema } from "@homarr/db/validationSchemas";
import { getIconForName } from "@homarr/icons"; import { getIconForNameAsync } from "@homarr/icons";
import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app"; import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
@@ -141,13 +141,15 @@ export const appRouter = createTRPCRouter({
.output(z.void()) .output(z.void())
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values( await ctx.db.insert(apps).values(
input.map((app) => ({ await Promise.all(
id: createId(), input.map(async (app) => ({
name: app.name, id: createId(),
description: app.description, name: app.name,
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon, description: app.description,
href: app.href, iconUrl: app.iconUrl ?? (await getIconForNameAsync(ctx.db, app.name))?.url ?? defaultIcon,
})), href: app.href,
})),
),
); );
}), }),
update: permissionRequiredProcedure update: permissionRequiredProcedure
-290
View File
@@ -155,7 +155,6 @@ export const boardRouter = createTRPCRouter({
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
}, },
userPermissions: { userPermissions: {
@@ -478,47 +477,6 @@ export const boardRouter = createTRPCRouter({
} }
}); });
}, },
handleSync(db) {
db.transaction((transaction) => {
transaction
.insert(boards)
.values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
})
.run();
if (layoutsToInsert.length > 0) {
transaction.insert(layouts).values(layoutsToInsert).run();
}
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
if (sectionCollapseStatesToInsert.length > 0) {
transaction.insert(sectionCollapseStates).values(sectionCollapseStatesToInsert).run();
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
if (itemLayoutsToInsert.length > 0) {
transaction.insert(itemLayouts).values(itemLayoutsToInsert).run();
}
if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
}
});
},
}); });
}), }),
renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => { renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => {
@@ -955,216 +913,6 @@ export const boardRouter = createTRPCRouter({
} }
}); });
}, },
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
)
.run();
if (addedSections.some((section) => section.kind === "dynamic")) {
transaction
.insert(sectionLayouts)
.values(
addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
)
.run();
}
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
)
.run();
transaction
.insert(itemLayouts)
.values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
)
.run();
}
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(items.id, item.id))
.run();
for (const itemSectionLayout of item.layouts) {
transaction
.update(itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(and(eq(itemLayouts.itemId, item.id), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)))
.run();
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
.update(sections)
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id))
.run();
if (section.kind !== "dynamic") continue;
for (const sectionLayout of section.layouts) {
transaction
.update(sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(eq(sectionLayouts.sectionId, section.id), eq(sectionLayouts.layoutId, sectionLayout.layoutId)),
)
.run();
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
)
.run();
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
}); });
}), }),
getBoardPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => { getBoardPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
@@ -1196,7 +944,6 @@ export const boardRouter = createTRPCRouter({
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
}, },
}, },
@@ -1258,24 +1005,6 @@ export const boardRouter = createTRPCRouter({
); );
}); });
}, },
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
saveGroupBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => { saveGroupBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => {
@@ -1299,24 +1028,6 @@ export const boardRouter = createTRPCRouter({
); );
}); });
}, },
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
importOldmarrConfig: permissionRequiredProcedure importOldmarrConfig: permissionRequiredProcedure
@@ -1539,7 +1250,6 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
}, },
sections: { sections: {
@@ -91,7 +91,7 @@ export const certificateRouter = createTRPCRouter({
hostname: input.hostname, hostname: input.hostname,
thumbprint: input.thumbprint, thumbprint: input.thumbprint,
}); });
const dbResult = await ctx.db await ctx.db
.delete(trustedCertificateHostnames) .delete(trustedCertificateHostnames)
.where( .where(
and( and(
@@ -103,7 +103,6 @@ export const certificateRouter = createTRPCRouter({
logger.info("Removed trusted hostname", { logger.info("Removed trusted hostname", {
hostname: input.hostname, hostname: input.hostname,
thumbprint: input.thumbprint, thumbprint: input.thumbprint,
count: dbResult.changes,
}); });
}), }),
removeCertificate: permissionRequiredProcedure removeCertificate: permissionRequiredProcedure
-7
View File
@@ -260,13 +260,6 @@ export const groupRouter = createTRPCRouter({
} }
}); });
}, },
handleSync: (db) => {
db.transaction((trx) => {
for (const { id, position } of positions) {
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
}
});
},
}); });
}), }),
savePermissions: permissionRequiredProcedure savePermissions: permissionRequiredProcedure
@@ -476,7 +476,6 @@ export const integrationRouter = createTRPCRouter({
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
}, },
}, },
@@ -542,27 +541,6 @@ export const integrationRouter = createTRPCRouter({
); );
}); });
}, },
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
saveGroupIntegrationPermissions: protectedProcedure saveGroupIntegrationPermissions: protectedProcedure
@@ -588,27 +566,6 @@ export const integrationRouter = createTRPCRouter({
); );
}); });
}, },
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
searchInIntegration: protectedProcedure searchInIntegration: protectedProcedure
+2 -8
View File
@@ -1,7 +1,6 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, publicProcedure } from "../trpc";
@@ -37,12 +36,7 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput) .input(locationSearchCityInput)
.output(locationSearchCityOutput) .output(locationSearchCityOutput)
.query(async ({ input }) => { .query(async ({ input }) => {
const res = await withTimeoutAsync(async (signal) => { const res = await fetchWithTimeoutAsync(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
return await fetchWithTrustedCertificatesAsync(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
{ signal },
);
});
return (await res.json()) as z.infer<typeof locationSearchCityOutput>; return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
}), }),
}); });
@@ -39,7 +39,6 @@ export const mediaRouter = createTRPCRouter({
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
}, },
}, },
+2 -2
View File
@@ -1220,11 +1220,11 @@ describe("getBoardPermissions should return board permissions", () => {
expect(result.users).toEqual( expect(result.users).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ {
user: { id: user1, name: null, image: null, email: null }, user: { id: user1, name: null, image: null },
permission: "view", permission: "view",
}, },
{ {
user: { id: user2, name: null, image: null, email: null }, user: { id: user2, name: null, image: null },
permission: "modify", permission: "modify",
}, },
]), ]),
+2 -5
View File
@@ -174,7 +174,7 @@ export const userRouter = createTRPCRouter({
// Is protected because also used in board access / integration access forms // Is protected because also used in board access / integration access forms
selectable: protectedProcedure selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional()) .input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true }))) .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
.meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } }) .meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
.query(({ ctx, input }) => { .query(({ ctx, input }) => {
return ctx.db.query.users.findMany({ return ctx.db.query.users.findMany({
@@ -182,7 +182,6 @@ export const userRouter = createTRPCRouter({
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined, where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
}); });
@@ -195,7 +194,7 @@ export const userRouter = createTRPCRouter({
limit: z.number().min(1).max(100).default(10), limit: z.number().min(1).max(100).default(10),
}), }),
) )
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true }))) .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } }) .meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({ const dbUsers = await ctx.db.query.users.findMany({
@@ -203,7 +202,6 @@ export const userRouter = createTRPCRouter({
id: true, id: true,
name: true, name: true,
image: true, image: true,
email: true,
}, },
where: like(users.name, `%${input.query}%`), where: like(users.name, `%${input.query}%`),
limit: input.limit, limit: input.limit,
@@ -212,7 +210,6 @@ export const userRouter = createTRPCRouter({
id: user.id, id: user.id,
name: user.name ?? "", name: user.name ?? "",
image: user.image, image: user.image,
email: user.email,
})); }));
}), }),
getById: protectedProcedure getById: protectedProcedure
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({ export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure getSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock")) .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
@@ -26,7 +26,7 @@ export const healthMonitoringRouter = createTRPCRouter({
); );
}), }),
subscribeSystemHealthStatus: publicProcedure subscribeSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock")) .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => { return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
+1 -1
View File
@@ -24,7 +24,7 @@ export const notebookRouter = createTRPCRouter({
where: eq(items.id, input.itemId), where: eq(items.id, input.itemId),
}); });
if (item?.boardId !== input.boardId) { if (!item || item.boardId !== input.boardId) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Specified item was not found", message: "Specified item was not found",
-1
View File
@@ -1 +0,0 @@
export const API_KEY_HEADER_NAME = "ApiKey";
@@ -1,76 +0,0 @@
import type { Session } from "next-auth";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
import { hashPasswordAsync } from "../security";
import { createSessionAsync } from "../server";
const logger = createLogger({ module: "apiKeyAuth" });
/**
* Validate an API key from the request header and return a session if valid.
*
* @param db - The database instance
* @param apiKeyHeaderValue - The value of the ApiKey header (format: "id.token")
* @param ipAddress - The IP address of the request (for logging)
* @param userAgent - The user agent of the request (for logging)
* @returns A session if the API key is valid, null otherwise
*/
export const getSessionFromApiKeyAsync = async (
db: Database,
apiKeyHeaderValue: string | null,
ipAddress: string | null,
userAgent: string,
): Promise<Session | null> => {
if (apiKeyHeaderValue === null) {
return null;
}
const [apiKeyId, apiKey] = apiKeyHeaderValue.split(".");
if (!apiKeyId || !apiKey) {
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_INVALID_FORMAT" });
return null;
}
const apiKeyFromDb = await db.query.apiKeys.findFirst({
where: eq(apiKeys.id, apiKeyId),
columns: {
id: true,
apiKey: true,
salt: true,
},
with: {
user: {
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
},
},
},
});
if (!apiKeyFromDb) {
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_NOT_FOUND" });
return null;
}
const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt);
if (apiKeyFromDb.apiKey !== hashedApiKey) {
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_MISMATCH" });
return null;
}
logger.info("Successfully authenticated with api-key", {
name: apiKeyFromDb.user.name,
id: apiKeyFromDb.user.id,
});
return await createSessionAsync(db, apiKeyFromDb.user);
};
-2
View File
@@ -1,2 +0,0 @@
export { getSessionFromApiKeyAsync } from "./get-api-key-session";
export { API_KEY_HEADER_NAME } from "./constants";
@@ -1,133 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import { createId } from "@homarr/common";
import { apiKeys, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createSaltAsync, hashPasswordAsync } from "../../security";
import { getSessionFromApiKeyAsync } from "../get-api-key-session";
// Mock the logger to avoid console output during tests
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
warn: vi.fn(),
info: vi.fn(),
}),
}));
const defaultUserId = createId();
const defaultUsername = "testuser";
const defaultApiKeyId = createId();
const defaultIpAddress = "127.0.0.1";
const defaultUserAgent = "test-agent";
const defaultLogParams = [defaultIpAddress, defaultUserAgent] as const;
describe("getSessionFromApiKeyAsync", () => {
test("should return null when api key header is null", async () => {
// Arrange
const { db } = await setupAsync();
const apiKey = null;
// Act
const result = await getSessionFromApiKeyAsync(db, apiKey, ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test.each([
["invalidformat", "no dot"],
["keyid.", "missing token"],
[".token", "missing id"],
])("should return null when api key format is invalid key=%s reason=%s", async (apiKey) => {
// Arrange
const { db } = await setupAsync();
// Act
const result = await getSessionFromApiKeyAsync(db, apiKey, ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test("should return null when api key is not found in database", async () => {
// Arrange
const { db } = await setupAsync();
// Act
const result = await getSessionFromApiKeyAsync(db, "nonexistent.token", ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test("should return null when api key token does not match", async () => {
// Arrange
const { db } = await setupAsync({ token: "correcttoken" });
// Act
const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.wrongtoken`, ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test("should return session when api key is valid", async () => {
// Arrange
const token = "validtesttoken123";
const { db } = await setupAsync({ token });
// Act
const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.${token}`, ...defaultLogParams);
// Assert
expect(result).not.toBeNull();
expect(result!.user.id).toEqual(defaultUserId);
expect(result!.user.name).toEqual(defaultUsername);
});
test("should work with null ip address", async () => {
// Arrange
const token = "validtesttoken456";
const { db } = await setupAsync({ token });
// Act
const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.${token}`, null, defaultUserAgent);
// Assert
expect(result).not.toBeNull();
expect(result!.user.id).toEqual(defaultUserId);
});
});
interface SetupOptions {
/**
* If provided, inserts an API key into the database for testing.
*/
token?: string;
}
const setupAsync = async (options?: SetupOptions) => {
const db = createDb();
await db.insert(users).values({
id: defaultUserId,
name: defaultUsername,
email: "test@example.com",
});
if (options?.token) {
const salt = await createSaltAsync();
await db.insert(apiKeys).values({
id: defaultApiKeyId,
apiKey: await hashPasswordAsync(options.token, salt),
salt,
userId: defaultUserId,
});
}
return {
db,
};
};
+3 -4
View File
@@ -8,7 +8,6 @@
".": "./index.ts", ".": "./index.ts",
"./next": "./next.ts", "./next": "./next.ts",
"./security": "./security.ts", "./security": "./security.ts",
"./api-key": "./api-key/index.ts",
"./client": "./client.ts", "./client": "./client.ts",
"./server": "./server.ts", "./server": "./server.ts",
"./shared": "./shared.ts", "./shared": "./shared.ts",
@@ -33,12 +32,12 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "8.1.3", "ldapts": "8.0.23",
"next": "16.1.1", "next": "16.0.10",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+1 -1
View File
@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "^0.27.2", "esbuild": "^0.27.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
+3 -3
View File
@@ -29,12 +29,12 @@
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^3.1.0", "@paralleldrive/cuid2": "^3.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"next": "16.1.1", "next": "16.0.10",
"octokit": "^5.0.5", "octokit": "^5.0.5",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"undici": "7.18.2", "undici": "7.16.0",
"zod": "^4.2.1", "zod": "^4.1.13",
"zod-validation-error": "^5.0.0" "zod-validation-error": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./security"; export * from "./security";
export * from "./encryption"; export * from "./encryption";
export * from "./request"; export * from "./user-agent";
export * from "./errors"; export * from "./errors";
@@ -9,7 +9,3 @@ export const userAgent = (headers: Headers) => {
}; };
export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined; export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined;
export const ipAddressFromHeaders = (headers: Headers): string | null => {
return headers.get("x-forwarded-for");
};
+5 -6
View File
@@ -37,22 +37,21 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@t3-oss/env-nextjs": "^0.13.10", "@libsql/client": "^0.15.15",
"better-sqlite3": "^12.5.0", "@t3-oss/env-nextjs": "^0.13.8",
"dns-caching": "^0.2.9", "dns-caching": "^0.2.9",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"ioredis": "5.9.1", "ioredis": "5.8.2",
"mysql2": "3.16.0", "mysql2": "3.15.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"superjson": "2.2.6", "superjson": "2.2.6",
"winston": "3.19.0", "winston": "3.19.0",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.13",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
@@ -26,9 +26,7 @@ const createMysqlDbConnection = () => {
return mysql.createPool({ return mysql.createPool({
...defaultOptions, ...defaultOptions,
host: dbEnv.HOST,
port: dbEnv.PORT, port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER, user: dbEnv.USER,
password: dbEnv.PASSWORD, password: dbEnv.PASSWORD,
}); });
@@ -1,10 +1,10 @@
import Database from "better-sqlite3"; import { createClient } from "@libsql/client";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3"; import { drizzle as drizzleSqlite } from "drizzle-orm/libsql";
import { dbEnv } from "../env"; import { dbEnv } from "../env";
import type { SharedDrizzleConfig } from "./shared"; import type { SharedDrizzleConfig } from "./shared";
export const createSqliteDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => { export const createSqliteDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
const connection = new Database(dbEnv.URL); const client = createClient({ url: `file:${dbEnv.URL}` });
return drizzleSqlite<TSchema>(connection, config); return drizzleSqlite<TSchema>(client, config);
}; };
+1 -1
View File
@@ -2,7 +2,7 @@ import { createBooleanSchema, createEnv } from "../env";
export const dnsEnv = createEnv({ export const dnsEnv = createEnv({
server: { server: {
ENABLE_DNS_CACHING: createBooleanSchema(true), ENABLE_DNS_CACHING: createBooleanSchema(false),
}, },
experimental__runtimeEnv: process.env, experimental__runtimeEnv: process.env,
}); });
@@ -1,5 +1,5 @@
import type { Dispatcher } from "undici"; import type { Dispatcher } from "undici";
import { EnvHttpProxyAgent } from "undici"; import { Agent } from "undici";
import type { ILogger } from "@homarr/core/infrastructure/logs"; import type { ILogger } from "@homarr/core/infrastructure/logs";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -7,15 +7,16 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
// The below import statement initializes dns-caching // The below import statement initializes dns-caching
import "@homarr/core/infrastructure/dns/init"; import "@homarr/core/infrastructure/dns/init";
interface HttpAgentOptions extends EnvHttpProxyAgent.Options { interface HttpAgentOptions extends Agent.Options {
logger?: ILogger; logger?: ILogger;
} }
export class UndiciHttpAgent extends EnvHttpProxyAgent { export class UndiciHttpAgent extends Agent {
private logger: ILogger; private logger: ILogger;
constructor(props?: HttpAgentOptions) { constructor(props?: HttpAgentOptions) {
super(props); super(props);
this.logger = props?.logger ?? createLogger({ module: "httpAgent" }); this.logger = props?.logger ?? createLogger({ module: "httpAgent" });
} }
@@ -42,13 +42,12 @@ export const createCertificateAgentAsync = async (override?: {
}; };
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => { export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
return new HttpsAgent({ return new HttpsAgent(
ca: await getAllTrustedCertificatesAsync(), override ?? {
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), ca: await getAllTrustedCertificatesAsync(),
// Override the ca and checkServerIdentity if provided checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
...override, },
proxyEnv: process.env, );
});
}; };
export const createAxiosCertificateInstanceAsync = async ( export const createAxiosCertificateInstanceAsync = async (
@@ -7,7 +7,7 @@ import { TestLogger } from "../logs";
vi.mock("undici", () => { vi.mock("undici", () => {
return { return {
EnvHttpProxyAgent: class EnvHttpProxyAgent { Agent: class Agent {
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean { dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean {
return true; return true;
} }
+6 -6
View File
@@ -28,20 +28,20 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0", "@homarr/cron-jobs": "workspace:^0.1.0",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.12",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.0",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.0",
"@trpc/tanstack-react-query": "^11.8.1", "@trpc/tanstack-react-query": "^11.8.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "19.2.3", "react": "19.2.3",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/react": "19.2.8", "@types/react": "19.2.7",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
@@ -121,46 +121,6 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
); );
} }
countDeleted += deadIcons.length;
});
},
handleSync() {
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
countDeleted += deadIcons.length; countDeleted += deadIcons.length;
}); });
}, },
+3 -15
View File
@@ -31,20 +31,8 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
return { return {
...context, ...context,
insertAll: (db: HomarrDatabase) => {
db.transaction((transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
transaction
.insert(schema[key])
.values(values as never)
.run();
}
}
});
},
// We allow any database that supports async passed here but then fallback to mysql to prevent typescript errors // We allow any database that supports async passed here but then fallback to mysql to prevent typescript errors
insertAllAsync: async (db: HomarrDatabaseMysql | HomarrDatabasePostgresql) => { insertAllAsync: async (db: HomarrDatabaseMysql | HomarrDatabasePostgresql | HomarrDatabase) => {
const innerDb = db as HomarrDatabaseMysql; const innerDb = db as HomarrDatabaseMysql;
await innerDb.transaction(async (transaction) => { await innerDb.transaction(async (transaction) => {
for (const [key, values] of objectEntries(context)) { for (const [key, values] of objectEntries(context)) {
@@ -61,7 +49,7 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
export const createDbInsertCollectionWithoutTransaction = <TTableKey extends TableKey>( export const createDbInsertCollectionWithoutTransaction = <TTableKey extends TableKey>(
tablesInInsertOrder: TTableKey[], tablesInInsertOrder: TTableKey[],
) => { ) => {
const { insertAll, insertAllAsync, ...collection } = createDbInsertCollectionForTransaction(tablesInInsertOrder); const { insertAllAsync, ...collection } = createDbInsertCollectionForTransaction(tablesInInsertOrder);
return { return {
...collection, ...collection,
@@ -75,7 +63,7 @@ export const createDbInsertCollectionWithoutTransaction = <TTableKey extends Tab
default: default:
// For better-sqlite3, we need to use the synchronous insertAll method // For better-sqlite3, we need to use the synchronous insertAll method
// default assumes better-sqlite3. It's original implementation. // default assumes better-sqlite3. It's original implementation.
insertAll(db); await insertAllAsync(db);
break; break;
} }
}, },
+2 -2
View File
@@ -1,4 +1,4 @@
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import type { LibSQLDatabase } from "drizzle-orm/libsql";
import type { MySql2Database } from "drizzle-orm/mysql2"; import type { MySql2Database } from "drizzle-orm/mysql2";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -6,6 +6,6 @@ import type * as mysqlSchema from "./schema/mysql";
import type * as pgSchema from "./schema/postgresql"; import type * as pgSchema from "./schema/postgresql";
import type * as sqliteSchema from "./schema/sqlite"; import type * as sqliteSchema from "./schema/sqlite";
export type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>; export type HomarrDatabase = LibSQLDatabase<typeof sqliteSchema>;
export type HomarrDatabaseMysql = MySql2Database<typeof mysqlSchema>; export type HomarrDatabaseMysql = MySql2Database<typeof mysqlSchema>;
export type HomarrDatabasePostgresql = NodePgDatabase<typeof pgSchema>; export type HomarrDatabasePostgresql = NodePgDatabase<typeof pgSchema>;
+5 -7
View File
@@ -47,16 +47,15 @@
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.10",
"@paralleldrive/cuid2": "^3.1.0", "@paralleldrive/cuid2": "^3.1.0",
"@testcontainers/mysql": "^11.11.0", "@testcontainers/mysql": "^11.10.0",
"@testcontainers/postgresql": "^11.11.0", "@testcontainers/postgresql": "^11.10.0",
"better-sqlite3": "^12.5.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"mysql2": "3.16.0", "mysql2": "3.15.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"superjson": "2.2.6" "superjson": "2.2.6"
}, },
@@ -64,10 +63,9 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.13",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"esbuild": "^0.27.2", "esbuild": "^0.27.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"tsx": "4.20.4", "tsx": "4.20.4",
+6 -5
View File
@@ -1,15 +1,16 @@
import Database from "better-sqlite3"; import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/better-sqlite3"; import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/libsql/migrator";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import * as sqliteSchema from "../schema/sqlite"; import * as sqliteSchema from "../schema/sqlite";
export const createDb = (debug?: boolean) => { export const createDb = (debug?: boolean) => {
const sqlite = new Database(":memory:"); const sqlite = createClient({ url: ":memory:" });
const db = drizzle(sqlite, { schema: sqliteSchema, logger: debug, casing: DB_CASING }); const db = drizzle(sqlite, { schema: sqliteSchema, logger: debug, casing: DB_CASING });
migrate(db, { // TODO: Make async and awaitable
void migrate(db, {
migrationsFolder: "./packages/db/migrations/sqlite", migrationsFolder: "./packages/db/migrations/sqlite",
}); });
+1 -8
View File
@@ -1,11 +1,9 @@
import { isMysql, isPostgresql } from "./collection";
import type { HomarrDatabase, HomarrDatabaseMysql } from "./driver"; import type { HomarrDatabase, HomarrDatabaseMysql } from "./driver";
import type { MySqlSchema } from "./schema"; import type { MySqlSchema } from "./schema";
import * as schema from "./schema"; import * as schema from "./schema";
interface HandleTransactionInput { interface HandleTransactionInput {
handleAsync: (db: HomarrDatabaseMysql, schema: MySqlSchema) => Promise<void>; handleAsync: (db: HomarrDatabaseMysql, schema: MySqlSchema) => Promise<void>;
handleSync: (db: HomarrDatabase) => void;
} }
/** /**
@@ -14,10 +12,5 @@ interface HandleTransactionInput {
* But it can also generally be used when dealing with different database drivers. * But it can also generally be used when dealing with different database drivers.
*/ */
export const handleDiffrentDbDriverOperationsAsync = async (db: HomarrDatabase, input: HandleTransactionInput) => { export const handleDiffrentDbDriverOperationsAsync = async (db: HomarrDatabase, input: HandleTransactionInput) => {
if (isMysql() || isPostgresql()) { await input.handleAsync(db as unknown as HomarrDatabaseMysql, schema as unknown as MySqlSchema);
// Schema type is always the correct one based on env variables
await input.handleAsync(db as unknown as HomarrDatabaseMysql, schema as unknown as MySqlSchema);
} else {
input.handleSync(db);
}
}; };
+1 -1
View File
@@ -25,7 +25,7 @@
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"fast-xml-parser": "^5.3.3", "fast-xml-parser": "^5.3.3",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -188,7 +188,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/transmission" | "/docs/integrations/transmission"
| "/docs/integrations/truenas" | "/docs/integrations/truenas"
| "/docs/integrations/unifi-controller" | "/docs/integrations/unifi-controller"
| "/docs/integrations/unraid"
| "/docs/management/api" | "/docs/management/api"
| "/docs/management/apps" | "/docs/management/apps"
| "/docs/management/boards" | "/docs/management/boards"
-7
View File
@@ -298,13 +298,6 @@ export const integrationDefs = {
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"), documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
}, },
unraid: {
name: "Unraid",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: { mock: {
name: "Mock", name: "Mock",
+2 -2
View File
@@ -26,9 +26,9 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^8.3.12", "@mantine/form": "^8.3.10",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+2 -2
View File
@@ -30,9 +30,9 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.10",
"react": "19.2.3", "react": "19.2.3",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Database } from "@homarr/db";
import { like } from "@homarr/db"; import { like } from "@homarr/db";
import { icons } from "@homarr/db/schema"; import { icons } from "@homarr/db/schema";
export const getIconForName = (db: Database, name: string) => { export const getIconForNameAsync = async (db: Database, name: string) => {
return db.query.icons.findFirst({ return await db.query.icons.findFirst({
where: like(icons.name, `%${name}%`), where: like(icons.name, `%${name}%`),
}); });
}; };
@@ -1,7 +1,6 @@
import { parse } from "path"; import { parse } from "path";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import type { IconRepositoryLicense } from "../types/icon-repository-license"; import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group"; import type { RepositoryIconGroup } from "../types/repository-icon-group";
@@ -20,12 +19,11 @@ export class GitHubIconRepository extends IconRepository {
} }
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> { protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
const url = this.repositoryIndexingUrl; if (!this.repositoryIndexingUrl || !this.repositoryBlobUrlTemplate) {
if (!url || !this.repositoryBlobUrlTemplate) {
throw new Error("Repository URLs are required for this repository"); throw new Error("Repository URLs are required for this repository");
} }
const response = await withTimeoutAsync(async (signal) => fetchWithTrustedCertificatesAsync(url, { signal })); const response = await fetchWithTimeoutAsync(this.repositoryIndexingUrl);
const listOfFiles = (await response.json()) as GitHubApiResponse; const listOfFiles = (await response.json()) as GitHubApiResponse;
return { return {
@@ -1,7 +1,6 @@
import { parse } from "path"; import { parse } from "path";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import type { IconRepositoryLicense } from "../types/icon-repository-license"; import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group"; import type { RepositoryIconGroup } from "../types/repository-icon-group";
@@ -20,9 +19,7 @@ export class JsdelivrIconRepository extends IconRepository {
} }
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> { protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
const response = await withTimeoutAsync(async (signal) => const response = await fetchWithTimeoutAsync(this.repositoryIndexingUrl);
fetchWithTrustedCertificatesAsync(this.repositoryIndexingUrl, { signal }),
);
const listOfFiles = (await response.json()) as JsdelivrApiResponse; const listOfFiles = (await response.json()) as JsdelivrApiResponse;
return { return {
+2 -2
View File
@@ -46,9 +46,9 @@
"octokit": "^5.0.5", "octokit": "^5.0.5",
"proxmox-api": "1.1.1", "proxmox-api": "1.1.1",
"tsdav": "^2.1.6", "tsdav": "^2.1.6",
"undici": "7.18.2", "undici": "7.16.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -38,7 +38,6 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { QuayIntegration } from "../quay/quay-integration"; import { QuayIntegration } from "../quay/quay-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration"; import type { Integration, IntegrationInput } from "./integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>( export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
@@ -102,7 +101,6 @@ export const integrationCreators = {
ntfy: NTFYIntegration, ntfy: NTFYIntegration,
mock: MockIntegration, mock: MockIntegration,
truenas: TrueNasIntegration, truenas: TrueNasIntegration,
unraid: UnraidIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>; } satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = { type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
-1
View File
@@ -22,7 +22,6 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration"; export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration"; export { TrueNasIntegration } from "./truenas/truenas-integration";
export { UnraidIntegration } from "./unraid/unraid-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration"; export { ICalIntegration } from "./ical/ical-integration";
@@ -261,7 +261,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
*/ */
private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) { private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
let webSocket = webSocketOverride ?? this.webSocket; let webSocket = webSocketOverride ?? this.webSocket;
if (webSocket?.readyState !== WebSocket.OPEN) { if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
logger.debug("Connecting to websocket", { logger.debug("Connecting to websocket", {
url: this.wsUrl(), url: this.wsUrl(),
}); });
@@ -1,189 +0,0 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import type { UnraidSystemInfo } from "./unraid-types";
import { unraidSystemInfoSchema } from "./unraid-types";
const logger = createLogger({ module: "UnraidIntegration" });
@HandleIntegrationErrors([])
export class UnraidIntegration extends Integration implements ISystemHealthMonitoringIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.queryGraphQLAsync<{ info: UnraidSystemInfo }>(
`
query {
info {
os { platform }
}
}
`,
input.fetchAsync,
);
return { success: true };
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemInfo = await this.getSystemInformationAsync();
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.info.cpu.cores;
// We use "info" object instead of the stats since this is the exact amount the kernel sees, which is what Unraid displays.
const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0);
const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);
const uptime = dayjs(systemInfo.info.os.uptime);
return {
version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilization / cpuCount,
memUsedInBytes: usedMemory,
memAvailableInBytes: totalMemory - usedMemory,
uptime: dayjs().diff(uptime, "seconds"),
network: null, // Not implemented, see https://github.com/unraid/api/issues/1602
loadAverage: null,
rebootRequired: false,
availablePkgUpdates: 0,
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), covert to bytes
available: `${disk.size * 1024}`, // API is in KiB (kibibytes), covert to bytes
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
smart: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
temperature: disk.temp ?? null,
overallStatus: disk.status,
})),
};
}
private async getSystemInformationAsync(): Promise<UnraidSystemInfo> {
logger.debug("Retrieving system information", {
url: this.url("/graphql"),
});
const query = `
query {
metrics {
cpu {
percentTotal
cpus {
percentTotal
}
},
memory {
available
used
free
total
swapFree
swapTotal
swapUsed
percentTotal
}
}
array {
state
capacity {
disks {
free
total
used
}
}
disks {
name
size
fsFree
fsUsed
status
temp
}
}
info {
devices {
network {
speed
dhcp
model
model
}
}
os {
platform,
distro,
release,
uptime
},
cpu {
manufacturer,
brand,
cores,
threads
},
memory {
layout {
size
}
}
}
}
`;
const response = await this.queryGraphQLAsync<UnraidSystemInfo>(query);
const result = await unraidSystemInfoSchema.parseAsync(response);
logger.debug("Retrieved system information", {
url: this.url("/graphql"),
});
return result;
}
private async queryGraphQLAsync<T>(
query: string,
fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
): Promise<T> {
const url = this.url("/graphql");
const apiKey = this.getSecretValue("apiKey");
logger.debug("Sending GraphQL query", {
url: url.toString(),
});
const response = await fetchAsync(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
throw new ResponseError(response);
}
const json = (await response.json()) as { data: T; errors?: { message: string }[] };
if (json.errors) {
throw new Error(`GraphQL errors: ${json.errors.map((error) => error.message).join(", ")}`);
}
return json.data;
}
}
@@ -1,73 +0,0 @@
import z from "zod";
export const unraidSystemInfoSchema = z.object({
metrics: z.object({
cpu: z.object({
percentTotal: z.number(),
cpus: z.array(
z.object({
percentTotal: z.number(),
}),
),
}),
memory: z.object({
available: z.number(),
used: z.number(),
free: z.number(),
total: z.number().min(0),
percentTotal: z.number().min(0).max(100),
}),
}),
array: z.object({
state: z.string(),
capacity: z.object({
disks: z.object({
free: z.coerce.number(),
total: z.coerce.number(),
used: z.coerce.number(),
}),
}),
disks: z.array(
z.object({
name: z.string(),
size: z.number(),
fsFree: z.number(),
fsUsed: z.number(),
status: z.string(),
temp: z.number().nullish(),
}),
),
}),
info: z.object({
devices: z.object({
network: z.array(
z.object({
speed: z.number(),
dhcp: z.boolean(),
model: z.string(),
}),
),
}),
os: z.object({
platform: z.string(),
distro: z.string(),
release: z.string(),
uptime: z.coerce.date(),
}),
cpu: z.object({
manufacturer: z.string(),
brand: z.string(),
cores: z.number(),
threads: z.number(),
}),
memory: z.object({
layout: z.array(
z.object({
size: z.number(),
}),
),
}),
}),
});
export type UnraidSystemInfo = z.infer<typeof unraidSystemInfoSchema>;
+4 -4
View File
@@ -33,13 +33,13 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.10",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.35.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"next": "16.1.1", "next": "16.0.10",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+2 -2
View File
@@ -24,8 +24,8 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.10",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.3.10",
"react": "19.2.3" "react": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -24,8 +24,8 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^8.3.12", "@mantine/notifications": "^8.3.10",
"@tabler/icons-react": "^3.36.1" "@tabler/icons-react": "^3.35.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+4 -4
View File
@@ -37,14 +37,14 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.10",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.3.10",
"adm-zip": "0.5.16", "adm-zip": "0.5.16",
"next": "16.1.1", "next": "16.0.10",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"superjson": "2.2.6", "superjson": "2.2.6",
"zod": "^4.2.1", "zod": "^4.1.13",
"zod-form-data": "^3.0.1" "zod-form-data": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -45,13 +45,6 @@ export const importInitialOldmarrAsync = async (
await integrationInsertCollection.insertAllAsync(transaction); await integrationInsertCollection.insertAllAsync(transaction);
}); });
}, },
handleSync(db) {
db.transaction((transaction) => {
boardInsertCollection.insertAll(transaction);
userInsertCollection.insertAll(transaction);
integrationInsertCollection.insertAll(transaction);
});
},
}); });
logger.info("Import successful", { duration: stopwatch.getElapsedInHumanWords() }); logger.info("Import successful", { duration: stopwatch.getElapsedInHumanWords() });
@@ -35,8 +35,5 @@ export const importSingleOldmarrConfigAsync = async (
async handleAsync(db) { async handleAsync(db) {
await boardInsertCollection.insertAllAsync(db); await boardInsertCollection.insertAllAsync(db);
}, },
handleSync(db) {
boardInsertCollection.insertAll(db);
},
}); });
}; };
+1 -1
View File
@@ -23,7 +23,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+1 -1
View File
@@ -26,7 +26,7 @@
"@homarr/core": "workspace:^", "@homarr/core": "workspace:^",
"@homarr/db": "workspace:^", "@homarr/db": "workspace:^",
"@homarr/definitions": "workspace:^", "@homarr/definitions": "workspace:^",
"ioredis": "5.9.1", "ioredis": "5.8.2",
"superjson": "2.2.6" "superjson": "2.2.6"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -33,8 +33,8 @@
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"octokit": "^5.0.5", "octokit": "^5.0.5",
"superjson": "2.2.6", "superjson": "2.2.6",
"undici": "7.18.2", "undici": "7.16.0",
"zod": "^4.2.1" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
+20 -47
View File
@@ -12,7 +12,9 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult", queryKey: "dockerContainersResult",
widgetKind: "dockerContainers", widgetKind: "dockerContainers",
async requestAsync() { async requestAsync() {
return await getContainersWithStatsAsync(); const containers = await getContainersWithStatsAsync();
return containers;
}, },
cacheDuration: dayjs.duration(20, "seconds"), cacheDuration: dayjs.duration(20, "seconds"),
}); });
@@ -26,7 +28,7 @@ async function getContainersWithStatsAsync() {
dockerInstances.map(async ({ instance, host }) => { dockerInstances.map(async ({ instance, host }) => {
const instanceContainers = await instance.listContainers({ all: true }); const instanceContainers = await instance.listContainers({ all: true });
return instanceContainers return instanceContainers
.filter((container) => !(dockerLabels.hide in container.Labels)) .filter((container) => dockerLabels.hide in container.Labels === false)
.map((container) => ({ ...container, instance: host })); .map((container) => ({ ...container, instance: host }));
}), }),
).then((res) => res.flat()); ).then((res) => res.flat());
@@ -43,21 +45,7 @@ async function getContainersWithStatsAsync() {
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance; const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
if (!instance) return null; if (!instance) return null;
// Get stats, falling back to an empty stats object if fetch fails const stats = await instance.getContainer(container.Id).stats({ stream: false, "one-shot": true });
// calculateCpuUsage and calculateMemoryUsage will return 0 for invalid/missing stats
const stats = await instance
.getContainer(container.Id)
.stats({ stream: false, "one-shot": true })
.catch(
() =>
({
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
memory_stats: { usage: 0 },
}) as ContainerStats,
);
const cpuUsage = calculateCpuUsage(stats);
const memoryUsage = calculateMemoryUsage(stats);
return { return {
id: container.Id, id: container.Id,
@@ -69,8 +57,19 @@ async function getContainersWithStatsAsync() {
if (!extractedImage) return false; if (!extractedImage) return false;
return icon.name.toLowerCase().includes(extractedImage.toLowerCase()); return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
})?.url ?? null, })?.url ?? null,
cpuUsage, cpuUsage: calculateCpuUsage(stats),
memoryUsage, // memory usage by default includes cache, which should not be shown as it is also not shown with docker stats command
// The below type is probably wrong, sometimes stats can be undefined
// See https://docs.docker.com/reference/cli/docker/container/stats/ how it is / was calculated
memoryUsage:
stats.memory_stats.usage -
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(stats.memory_stats.stats?.cache ??
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stats.memory_stats.stats?.total_inactive_file ??
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stats.memory_stats.stats?.inactive_file ??
0),
image: container.Image, image: container.Image,
ports: container.Ports, ports: container.Ports,
}; };
@@ -80,36 +79,10 @@ async function getContainersWithStatsAsync() {
} }
function calculateCpuUsage(stats: ContainerStats): number { function calculateCpuUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers) const numberOfCpus = stats.cpu_stats.online_cpus || 1;
if (!stats.cpu_stats.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage.total_usage) {
return 0;
}
const numberOfCpus = stats.cpu_stats.online_cpus;
const usage = stats.cpu_stats.system_cpu_usage; const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) { if (usage === 0) return 0;
return 0;
}
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100; return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
} }
function calculateMemoryUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers)
if (!stats.memory_stats.usage) {
return 0;
}
// memory usage by default includes cache, which should not be shown as it is also not shown with docker stats command
// See https://docs.docker.com/reference/cli/docker/container/stats/ how it is / was calculated
return (
stats.memory_stats.usage -
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(stats.memory_stats.stats?.cache ??
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stats.memory_stats.stats?.total_inactive_file ??
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stats.memory_stats.stats?.inactive_file ??
0)
);
}
@@ -57,6 +57,12 @@ export const createRequestIntegrationJobHandler = <
reduceWidgetOptionsWithDefaultValues( reduceWidgetOptionsWithDefaultValues(
itemForIntegration.kind, itemForIntegration.kind,
{ {
defaultSearchEngineId: serverSettings.search.defaultSearchEngineId,
openSearchInNewTab: true,
firstDayOfWeek: 1,
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault, enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus, forceDisableStatus: serverSettings.board.forceDisableStatus,
}, },
@@ -1,8 +1,7 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
@@ -12,9 +11,7 @@ export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHand
async requestAsync(input: { domain: string; isBedrockServer: boolean }) { async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`; const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
const response = await withTimeoutAsync(async (signal) => const response = await fetchWithTimeoutAsync(`https://api.mcsrvstat.us${path}`);
fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal }),
);
return responseSchema.parse(await response.json()); return responseSchema.parse(await response.json());
}, },
cacheDuration: dayjs.duration(5, "minutes"), cacheDuration: dayjs.duration(5, "minutes"),
+4 -8
View File
@@ -1,8 +1,7 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
@@ -10,12 +9,9 @@ export const fetchStockPriceHandler = createCachedWidgetRequestHandler({
queryKey: "fetchStockPriceResult", queryKey: "fetchStockPriceResult",
widgetKind: "stockPrice", widgetKind: "stockPrice",
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) { async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
const response = await withTimeoutAsync(async (signal) => { const response = await fetchWithTimeoutAsync(
return await fetchWithTrustedCertificatesAsync( `https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`,
`https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`, );
{ signal },
);
});
const data = dataSchema.parse(await response.json()); const data = dataSchema.parse(await response.json());
if ("error" in data) { if ("error" in data) {
@@ -3,7 +3,7 @@ import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer } from "semver-parser"; import { compareSemVer, isValidSemVer } from "semver-parser";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { createChannelWithLatestAndEvents } from "@homarr/redis"; import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler"; import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
@@ -23,7 +23,7 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({
const octokit = new Octokit({ const octokit = new Octokit({
request: { request: {
fetch: fetchWithTrustedCertificatesAsync, fetch: fetchWithTimeoutAsync,
}, },
}); });
const releases = await octokit.rest.repos.listReleases({ const releases = await octokit.rest.repos.listReleases({
+4 -8
View File
@@ -1,8 +1,7 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { z } from "zod"; import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
@@ -10,12 +9,9 @@ export const weatherRequestHandler = createCachedWidgetRequestHandler({
queryKey: "weatherAtLocation", queryKey: "weatherAtLocation",
widgetKind: "weather", widgetKind: "weather",
async requestAsync(input: { latitude: number; longitude: number }) { async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => { const res = await fetchWithTimeoutAsync(
return await fetchWithTrustedCertificatesAsync( `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`, );
{ signal },
);
});
const json: unknown = await res.json(); const json: unknown = await res.json();
const weather = await atLocationOutput.parseAsync(json); const weather = await atLocationOutput.parseAsync(json);
return { return {
-4
View File
@@ -5,7 +5,6 @@ export const defaultServerSettingsKeys = [
"analytics", "analytics",
"crawlingAndIndexing", "crawlingAndIndexing",
"board", "board",
"user",
"appearance", "appearance",
"culture", "culture",
"search", "search",
@@ -32,9 +31,6 @@ export const defaultServerSettings = {
enableStatusByDefault: true, enableStatusByDefault: true,
forceDisableStatus: false, forceDisableStatus: false,
}, },
user: {
enableGravatar: true,
},
appearance: { appearance: {
defaultColorScheme: "light" as ColorScheme, defaultColorScheme: "light" as ColorScheme,
}, },
+3 -2
View File
@@ -23,10 +23,11 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^8.3.12", "@mantine/dates": "^8.3.10",
"next": "16.1.1", "next": "16.0.10",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
+1 -4
View File
@@ -10,8 +10,7 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab" | "openSearchInNewTab"
| "pingIconsEnabled" | "pingIconsEnabled"
> & > &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> & Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
Pick<ServerSettings["user"], "enableGravatar">;
export interface PublicServerSettings { export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">; search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
@@ -19,7 +18,6 @@ export interface PublicServerSettings {
ServerSettings["board"], ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus" "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>; >;
user: Pick<ServerSettings["user"], "enableGravatar">;
} }
export type UserSettings = Pick< export type UserSettings = Pick<
@@ -47,5 +45,4 @@ export const createSettings = ({
pingIconsEnabled: user?.pingIconsEnabled ?? false, pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableStatusByDefault: serverSettings.board.enableStatusByDefault, enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus, forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
}); });
+6 -6
View File
@@ -33,12 +33,12 @@
"@homarr/settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.10",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.3.10",
"@mantine/spotlight": "^8.3.12", "@mantine/spotlight": "^8.3.10",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.35.0",
"jotai": "^2.16.1", "jotai": "^2.16.0",
"next": "16.1.1", "next": "16.0.10",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
@@ -1,4 +1,5 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core"; import { Group, Text, useMantineColorScheme } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { import {
IconBox, IconBox,
IconCategoryPlus, IconCategoryPlus,
@@ -16,7 +17,6 @@ import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection"; import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
@@ -1,4 +1,5 @@
import { Box, Group, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { IconCaretUpDown, IconSearch, IconSearchOff } from "@tabler/icons-react"; import { IconCaretUpDown, IconSearch, IconSearchOff } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -8,7 +9,6 @@ import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
@@ -11,7 +11,7 @@ import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record<string, unknown>. // This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type User = { id: string; name: string; image: string | null; email: string | null }; type User = { id: string; name: string; image: string | null };
const userChildrenOptions = createChildrenOptions<User>({ const userChildrenOptions = createChildrenOptions<User>({
useActions: () => [ useActions: () => [
+2 -2
View File
@@ -32,8 +32,8 @@
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"deepmerge": "4.3.1", "deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "16.1.1", "next": "16.0.10",
"next-intl": "4.7.0", "next-intl": "4.6.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
-7
View File
@@ -3294,13 +3294,6 @@
} }
} }
}, },
"user": {
"title": "",
"enableGravatar": {
"label": "",
"description": ""
}
},
"search": { "search": {
"title": "", "title": "",
"defaultSearchEngine": { "defaultSearchEngine": {
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more