Compare commits

..
68 Commits
Author SHA1 Message Date
Releases Homarr 9c377957ec chore(release): 1.31.0 [skip ci] 2025-08-01 19:18:12 +00:00
homarr-releases[bot] ec3d560ad9 chore(release): automatic release v1.31.0 2025-08-01 19:17:21 +00:00
1dc1854cbf feat: OPNsense integration and widget (#3424)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: deepsource-io[bot] <42547082+deepsource-io[bot]@users.noreply.github.com>
2025-08-01 18:34:06 +02:00
homarr-renovate[bot] 511551aee7 fix(deps): update tanstack-query monorepo to ^5.84.1 (#3729)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-08-01 12:38:37 +00:00
homarr-update-contributors[bot] a9de2c39ff chore: update contributors (#3728)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2025-08-01 12:38:17 +00:00
homarr-renovate[bot] a14c3a2095 chore(deps): update dependency dotenv-cli to v10 (#3713)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-08-01 14:03:08 +02:00
Andre Silva f63e64627c feat(releases-widget): add Mark as read action to mark releases as seen (#3676) 2025-08-01 14:02:07 +02:00
homarr-renovate[bot]andMeier Lukas d66cccb0db chore(deps): update dependency typescript to ^5.9.2 (#3722)
* chore(deps): update dependency typescript to ^5.9.2

* fix: typecheck issue

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-08-01 13:59:35 +02:00
homarr-renovate[bot] 0bfb8d845b chore(deps): update dependency @types/bcrypt to v6 (#3656)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-08-01 13:59:06 +02:00
Andre Silva 3f3d31eb6a refactor: renamed GitHubPackages integration to GitHubContainerRegistry, and all related code (#3727) 2025-08-01 12:37:00 +02:00
Meier Lukas 9a69644732 chore(docs): update sitemap (#3726) 2025-08-01 12:08:52 +02:00
ManuelandMeier Lukas 8eb44c9f23 revert: "feat(ping): ignore certificate error and show request durati… (#3680)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-08-01 12:08:34 +02:00
Andre SilvaandMeier Lukas c92bbd2da0 feat(releases-widget): add new providers, Github Packages, linuxserver.io and Quay (#3607)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-08-01 11:13:20 +02:00
Andre Silva 03adf538b0 feat(releases-widget): Allow to display only the icon (#3695) 2025-08-01 11:13:05 +02:00
Andre Silva 949c7a40d9 feat(releases-widget): add Select/Deselect All to import from docker screen (#3674) 2025-08-01 11:12:56 +02:00
NikeLaosClericusandMeier Lukas 6a819e38ed fix: characters are not considered Symbols when entering a new password (#3689)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-08-01 11:12:25 +02:00
homarr-renovate[bot] 4b1699ab33 chore(deps): update node.js to v22.18.0 (#3724)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-08-01 08:36:47 +00:00
homarr-renovate[bot] ab5e08a4b0 fix(deps): update dependency swagger-ui-react to ^5.27.1 (#3725)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-08-01 08:28:32 +00:00
homarr-renovate[bot] e7b21697ef fix(deps): update tanstack-query monorepo to ^5.84.0 (#3723)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-31 20:23:56 +00:00
homarr-renovate[bot] f4e37134d8 fix(deps): update tanstack-query monorepo to ^5.83.1 (#3719)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-31 15:23:39 +00:00
homarr-renovate[bot] 58e686765c chore(deps): update pnpm to v10.14.0 (#3720)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-31 14:25:03 +00:00
homarr-renovate[bot] 4f9ecae104 fix(deps): update dependency ioredis to v5.7.0 (#3718)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-31 11:21:21 +00:00
homarr-renovate[bot] 002063f6e1 chore(deps): update dependency proxmox-api&gt;undici to v7.13.0 (#3716)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-31 11:20:30 +00:00
homarr-renovate[bot] 183564176c fix(deps): update mantine monorepo to ^8.2.2 (#3715)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-31 10:24:24 +00:00
homarr-renovate[bot] b0bbd0d87a fix(deps): update dependency undici to v7.13.0 (#3717)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-31 09:29:01 +00:00
homarr-renovate[bot] d0a112c791 fix(deps): update nextjs monorepo to v15.4.5 (#3712)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-29 21:22:03 +00:00
homarr-renovate[bot] 952d4f71c9 fix(deps): update dependency mysql2 to v3.14.3 (#3711)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-29 17:24:35 +00:00
homarr-renovate[bot] 6126fac915 fix(deps): update dependency drizzle-orm to ^0.44.4 (#3708)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-29 16:26:51 +00:00
homarr-renovate[bot] a358377578 chore(deps): update react monorepo (#3707)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-29 16:26:08 +00:00
homarr-renovate[bot] 8a9bca39ea chore(deps): update dependency @types/node to ^22.17.0 (#3710)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-29 15:25:32 +00:00
homarr-renovate[bot] 0dc1f7cf14 fix(deps): update react monorepo to v19.1.1 (#3706)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-28 15:24:23 +00:00
homarr-renovate[bot] 1aecf4f90d fix(deps): update dependency ldapts to v8.0.9 (#3703)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-28 06:31:57 +00:00
homarr-crowdin[bot] 851a75aa54 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-07-27 01:01:59 +00:00
homarr-renovate[bot] 91ae3ec84c chore(deps): update dependency cross-env to v10 (#3690)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-26 20:19:24 +02:00
homarr-renovate[bot] 6996f6d1a9 chore(deps): update node.js to v22.17.1 (#3640)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-26 20:06:46 +02:00
homarr-crowdin[bot] a16e9032f4 chore(lang): update translations from crowdin (#3646)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-07-26 20:04:40 +02:00
Manuel 4b57d7b269 chore: update issue template for bug 2025-07-25 21:28:24 +02:00
homarr-renovate[bot] 6d677828d4 chore(deps): update dependency eslint to ^9.32.0 (#3687)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-25 19:20:19 +00:00
Releases Homarr 28242d4335 chore(release): 1.30.1 [skip ci] 2025-07-25 19:18:07 +00:00
homarr-releases[bot] 83dd6c3d1b chore(release): automatic release v1.30.1 2025-07-25 19:17:13 +00:00
Manuel 588c134d91 fix: #3667 plex media releases (#3682) 2025-07-25 21:03:33 +02:00
Manuel 470448e5fc fix: #3678 season number badge calendar (#3681) 2025-07-25 21:03:10 +02:00
homarr-update-contributors[bot] edbd301673 chore: update contributors (#3686)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2025-07-25 12:37:09 +00:00
homarr-renovate[bot] d3fba3eff4 fix(deps): update dependency dotenv to ^17.2.1 (#3684)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-24 22:21:49 +00:00
homarr-renovate[bot] 04c15de0c8 fix(deps): update dependency @ctrl/deluge to ^7.1.1 (#3677)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-24 21:21:16 +00:00
homarr-renovate[bot] abb011ff9a fix(deps): update nextjs monorepo to v15.4.4 (#3683)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-24 20:23:40 +00:00
homarr-renovate[bot] e858bad2ed fix(deps): update testcontainers-node monorepo to ^11.4.0 (#3679)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-23 14:24:33 +00:00
homarr-renovate[bot] d218170ec2 fix(deps): update testcontainers-node monorepo to ^11.3.2 (#3671)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-22 21:21:39 +00:00
homarr-renovate[bot] 0094ec2567 fix(deps): update nextjs monorepo to v15.4.3 (#3672)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-22 20:25:23 +00:00
homarr-renovate[bot] 50a7d24b1b fix(deps): update dependency typescript-eslint to ^8.38.0 (#3668)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-21 18:29:19 +00:00
Manuel f6d6acf8dc chore: update issue template for 1.30.0 2025-07-21 18:05:03 +02:00
homarr-renovate[bot] 5a28535f59 fix(deps): update dependency ldapts to v8.0.8 (#3665)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-21 15:23:51 +00:00
homarr-renovate[bot] 9cdffc7ead fix(deps): update mantine monorepo to ^8.2.1 (#3666)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-21 13:42:41 +00:00
Releases Homarr fd62524db3 chore(release): 1.30.0 [skip ci] 2025-07-21 08:43:16 +00:00
homarr-releases[bot] 4f0ba7a191 chore(release): automatic release v1.30.0 2025-07-21 08:42:26 +00:00
Meier Lukas ecda928bef fix(security): upgrade eslint-config-prettier to latest version without malicious code 2025-07-21 07:54:13 +00:00
homarr-renovate[bot] bf88f0d091 fix(deps): update dependency @tabler/icons-react to ^3.34.1 (#3661)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-21 01:25:53 +00:00
homarr-renovate[bot] 98f8279b61 fix(deps): update testcontainers-node monorepo to ^11.3.1 (#3659)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-20 15:21:47 +00:00
Meier Lukas 8e960324bc feat(infra): add external redis (#3639) 2025-07-20 17:13:57 +02:00
homarr-renovate[bot] 732bce72ae fix(deps): update dependency @gitbeaker/rest to v43 (#3623)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-20 17:00:34 +02:00
Meier Lukas 66ebb5061f feat(widgets): add media release widget (#3219) 2025-07-20 16:59:03 +02:00
homarr-renovate[bot] fa8e704112 chore(deps): update dependency prettier-plugin-packagejson to ^2.5.19 (#3658)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-20 14:21:07 +00:00
homarr-renovate[bot] 3f29a453b9 chore(deps): update dependency esbuild to ^0.25.8 (#3657)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-19 20:22:44 +00:00
Andre Silva 36b0f576e5 fix: improved import from docker parsing (#3642) 2025-07-19 21:22:11 +02:00
Manuel 18c3ed559e chore: update issue template with 1.29.0 2025-07-19 15:25:12 +02:00
homarr-renovate[bot] 6fca92ac49 chore(deps): update dependency esbuild to ^0.25.7 (#3655)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-18 23:21:52 +00:00
homarr-renovate[bot] 42301b4a1e fix(deps): update nextjs monorepo to v15.4.2 (#3654)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-18 21:22:56 +00:00
homarr-renovate[bot] 2394159f8b fix(deps): update testcontainers-node monorepo to ^11.3.0 (#3652)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-07-18 20:23:21 +00:00
166 changed files with 6464 additions and 2095 deletions

No files matched your search

+3
View File
@@ -31,6 +31,9 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.30.1
- 1.30.0
- 1.29.0
- 1.28.1
- 1.28.0
- 1.27.0
+1 -1
View File
@@ -1 +1 @@
22.17.1
22.18.0
+63
View File
@@ -1,3 +1,66 @@
## [1.31.0](https://github.com/homarr-labs/homarr/compare/v1.30.1...v1.31.0) (2025-08-01)
### Features
* OPNsense integration and widget ([#3424](https://github.com/homarr-labs/homarr/issues/3424)) ([1dc1854](https://github.com/homarr-labs/homarr/commit/1dc1854cbfa39f4f22755cb84e1d6882e7e73721))
* **releases-widget:** add `Mark as read` action to mark releases as seen ([#3676](https://github.com/homarr-labs/homarr/issues/3676)) ([f63e646](https://github.com/homarr-labs/homarr/commit/f63e64627c79004b1e1bbb5a525fdde58f803da7))
* **releases-widget:** add `Select/Deselect All` to `import from docker` screen ([#3674](https://github.com/homarr-labs/homarr/issues/3674)) ([949c7a4](https://github.com/homarr-labs/homarr/commit/949c7a40d934b2d58a24bf9842f44703e73474ee))
* **releases-widget:** add new providers, Github Packages, linuxserver.io and Quay ([#3607](https://github.com/homarr-labs/homarr/issues/3607)) ([c92bbd2](https://github.com/homarr-labs/homarr/commit/c92bbd2da062221c3b5464838ad715c31a9d07b0))
* **releases-widget:** Allow to display only the icon ([#3695](https://github.com/homarr-labs/homarr/issues/3695)) ([03adf53](https://github.com/homarr-labs/homarr/commit/03adf538b03270404956dc16b191069f913e54d1))
### Bug Fixes
* characters are not considered Symbols when entering a new password ([#3689](https://github.com/homarr-labs/homarr/issues/3689)) ([6a819e3](https://github.com/homarr-labs/homarr/commit/6a819e38edca9f95b926a0162abdb6b873ddec08))
* **deps:** update dependency drizzle-orm to ^0.44.4 ([#3708](https://github.com/homarr-labs/homarr/issues/3708)) ([6126fac](https://github.com/homarr-labs/homarr/commit/6126fac915197c19ea6d448da8c4fc1b06b1383b))
* **deps:** update dependency ioredis to v5.7.0 ([#3718](https://github.com/homarr-labs/homarr/issues/3718)) ([4f9ecae](https://github.com/homarr-labs/homarr/commit/4f9ecae10406be4f6176947b517706843bd1c06d))
* **deps:** update dependency ldapts to v8.0.9 ([#3703](https://github.com/homarr-labs/homarr/issues/3703)) ([1aecf4f](https://github.com/homarr-labs/homarr/commit/1aecf4f90d4776bfa93b6f601fca16a7da8ffaba))
* **deps:** update dependency mysql2 to v3.14.3 ([#3711](https://github.com/homarr-labs/homarr/issues/3711)) ([952d4f7](https://github.com/homarr-labs/homarr/commit/952d4f71c9197cd88fa44ce5dbcf7d89f4c482d3))
* **deps:** update dependency swagger-ui-react to ^5.27.1 ([#3725](https://github.com/homarr-labs/homarr/issues/3725)) ([ab5e08a](https://github.com/homarr-labs/homarr/commit/ab5e08a4b09845e3aa4818214bc0f9ce830a2942))
* **deps:** update dependency undici to v7.13.0 ([#3717](https://github.com/homarr-labs/homarr/issues/3717)) ([b0bbd0d](https://github.com/homarr-labs/homarr/commit/b0bbd0d87ad6df7a9be83296060d8b0be6c091e0))
* **deps:** update mantine monorepo to ^8.2.2 ([#3715](https://github.com/homarr-labs/homarr/issues/3715)) ([1835641](https://github.com/homarr-labs/homarr/commit/183564176c9421faea70dff199d0b6f81325bf70))
* **deps:** update nextjs monorepo to v15.4.5 ([#3712](https://github.com/homarr-labs/homarr/issues/3712)) ([d0a112c](https://github.com/homarr-labs/homarr/commit/d0a112c7919cbcff9aaf2cc6b2dec8fd562246db))
* **deps:** update react monorepo to v19.1.1 ([#3706](https://github.com/homarr-labs/homarr/issues/3706)) ([0dc1f7c](https://github.com/homarr-labs/homarr/commit/0dc1f7cf14c0ca49d5eb8d37060773c3dab4834a))
* **deps:** update tanstack-query monorepo to ^5.83.1 ([#3719](https://github.com/homarr-labs/homarr/issues/3719)) ([f4e3713](https://github.com/homarr-labs/homarr/commit/f4e37134d806dac719012cb4dee19fb7e5daf7f2))
* **deps:** update tanstack-query monorepo to ^5.84.0 ([#3723](https://github.com/homarr-labs/homarr/issues/3723)) ([e7b2169](https://github.com/homarr-labs/homarr/commit/e7b21697efb0f90c3539152beaea94416cbb6fef))
* **deps:** update tanstack-query monorepo to ^5.84.1 ([#3729](https://github.com/homarr-labs/homarr/issues/3729)) ([511551a](https://github.com/homarr-labs/homarr/commit/511551aee7d5be9dadb70c596c5addbb646651cb))
### Reverts
* "feat(ping): ignore certificate error and show request durati… ([#3680](https://github.com/homarr-labs/homarr/issues/3680)) ([8eb44c9](https://github.com/homarr-labs/homarr/commit/8eb44c9f23584078b22605dac569387713dcd173))
## [1.30.1](https://github.com/homarr-labs/homarr/compare/v1.30.0...v1.30.1) (2025-07-25)
### Bug Fixes
* [#3667](https://github.com/homarr-labs/homarr/issues/3667) plex media releases ([#3682](https://github.com/homarr-labs/homarr/issues/3682)) ([588c134](https://github.com/homarr-labs/homarr/commit/588c134d913f6811f7ed39d1954b586acc294932))
* [#3678](https://github.com/homarr-labs/homarr/issues/3678) season number badge calendar ([#3681](https://github.com/homarr-labs/homarr/issues/3681)) ([470448e](https://github.com/homarr-labs/homarr/commit/470448e5fc1243dc78539dd8da87a974f504252a))
* **deps:** update dependency @ctrl/deluge to ^7.1.1 ([#3677](https://github.com/homarr-labs/homarr/issues/3677)) ([04c15de](https://github.com/homarr-labs/homarr/commit/04c15de0c87be963f333ccb6aa1932c445793672))
* **deps:** update dependency dotenv to ^17.2.1 ([#3684](https://github.com/homarr-labs/homarr/issues/3684)) ([d3fba3e](https://github.com/homarr-labs/homarr/commit/d3fba3eff4fd1fff3e86090436ecfd2fbcd93c28))
* **deps:** update dependency ldapts to v8.0.8 ([#3665](https://github.com/homarr-labs/homarr/issues/3665)) ([5a28535](https://github.com/homarr-labs/homarr/commit/5a28535f59f6ea1928a941d5c11050752dd3ecc9))
* **deps:** update dependency typescript-eslint to ^8.38.0 ([#3668](https://github.com/homarr-labs/homarr/issues/3668)) ([50a7d24](https://github.com/homarr-labs/homarr/commit/50a7d24b1b3a7cbfa6195525b10b994709b0beb3))
* **deps:** update mantine monorepo to ^8.2.1 ([#3666](https://github.com/homarr-labs/homarr/issues/3666)) ([9cdffc7](https://github.com/homarr-labs/homarr/commit/9cdffc7ead2668ed259c1a38c774a3efea5a1f0e))
* **deps:** update nextjs monorepo to v15.4.3 ([#3672](https://github.com/homarr-labs/homarr/issues/3672)) ([0094ec2](https://github.com/homarr-labs/homarr/commit/0094ec25677ca97f77920018c18f18a4b84812f8))
* **deps:** update nextjs monorepo to v15.4.4 ([#3683](https://github.com/homarr-labs/homarr/issues/3683)) ([abb011f](https://github.com/homarr-labs/homarr/commit/abb011ff9a7b930dad2c382c4e454fba29633364))
* **deps:** update testcontainers-node monorepo to ^11.3.2 ([#3671](https://github.com/homarr-labs/homarr/issues/3671)) ([d218170](https://github.com/homarr-labs/homarr/commit/d218170ec28a70b80c521efd1482164087d46e05))
* **deps:** update testcontainers-node monorepo to ^11.4.0 ([#3679](https://github.com/homarr-labs/homarr/issues/3679)) ([e858bad](https://github.com/homarr-labs/homarr/commit/e858bad2eda8847269c1beee4cfbf957fa318fe4))
## [1.30.0](https://github.com/homarr-labs/homarr/compare/v1.29.0...v1.30.0) (2025-07-21)
### Features
* **infra:** add external redis ([#3639](https://github.com/homarr-labs/homarr/issues/3639)) ([8e96032](https://github.com/homarr-labs/homarr/commit/8e960324bcc43a64100fd9c6c1fd6822c7759160))
* **widgets:** add media release widget ([#3219](https://github.com/homarr-labs/homarr/issues/3219)) ([66ebb50](https://github.com/homarr-labs/homarr/commit/66ebb5061fd219f9c804ef3a6bbe3372d296f740))
### Bug Fixes
* **deps:** update dependency @gitbeaker/rest to v43 ([#3623](https://github.com/homarr-labs/homarr/issues/3623)) ([732bce7](https://github.com/homarr-labs/homarr/commit/732bce72aee6c99d0ee94d04e173d378c2552a16))
* **deps:** update dependency @tabler/icons-react to ^3.34.1 ([#3661](https://github.com/homarr-labs/homarr/issues/3661)) ([bf88f0d](https://github.com/homarr-labs/homarr/commit/bf88f0d0918b4073a5770ab3589e00e5f89cacf1))
* **deps:** update nextjs monorepo to v15.4.2 ([#3654](https://github.com/homarr-labs/homarr/issues/3654)) ([42301b4](https://github.com/homarr-labs/homarr/commit/42301b4a1ed9e9f638b449a460862db9cd7ed50b))
* **deps:** update testcontainers-node monorepo to ^11.3.0 ([#3652](https://github.com/homarr-labs/homarr/issues/3652)) ([2394159](https://github.com/homarr-labs/homarr/commit/2394159f8b546e8b6e2d3cdc7c6bd0f75483209c))
* **deps:** update testcontainers-node monorepo to ^11.3.1 ([#3659](https://github.com/homarr-labs/homarr/issues/3659)) ([98f8279](https://github.com/homarr-labs/homarr/commit/98f8279b61013c312d492522e317bcf93f10e2a6))
* improved import from docker parsing ([#3642](https://github.com/homarr-labs/homarr/issues/3642)) ([36b0f57](https://github.com/homarr-labs/homarr/commit/36b0f576e5a00726d76695fd3300710be6f925f8))
* **security:** upgrade eslint-config-prettier to latest version without malicious code ([ecda928](https://github.com/homarr-labs/homarr/commit/ecda928bef407c67f01ce798c5f7a086cbd0dd9c))
## [1.29.0](https://github.com/homarr-labs/homarr/compare/v1.28.1...v1.29.0) (2025-07-18)
### Features
+2 -1
View File
@@ -1,4 +1,4 @@
FROM node:22.17.0-alpine AS base
FROM node:22.17.1-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -64,6 +64,7 @@ ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
ENTRYPOINT [ "/app/entrypoint.sh" ]
+22 -21
View File
@@ -25,15 +25,16 @@
"@homarr/boards": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
@@ -49,17 +50,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^8.1.3",
"@mantine/core": "^8.1.3",
"@mantine/dropzone": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
"@mantine/tiptap": "^8.1.3",
"@mantine/colors-generator": "^8.2.2",
"@mantine/core": "^8.2.2",
"@mantine/dropzone": "^8.2.2",
"@mantine/hooks": "^8.2.2",
"@mantine/modals": "^8.2.2",
"@mantine/tiptap": "^8.2.2",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-devtools": "^5.83.0",
"@tanstack/react-query-next-experimental": "^5.83.0",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-query-devtools": "^5.84.1",
"@tanstack/react-query-next-experimental": "^5.84.1",
"@trpc/client": "^11.4.3",
"@trpc/next": "^11.4.3",
"@trpc/react-query": "^11.4.3",
@@ -70,21 +71,21 @@
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^17.2.0",
"dotenv": "^17.2.1",
"flag-icons": "^7.5.0",
"glob": "^11.0.3",
"jotai": "^2.12.5",
"mantine-react-table": "2.0.0-beta.9",
"next": "15.4.1",
"next": "15.4.5",
"postcss-preset-mantine": "^1.18.0",
"prismjs": "^1.30.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-error-boundary": "^6.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.89.2",
"superjson": "2.2.2",
"swagger-ui-react": "^5.27.0",
"swagger-ui-react": "^5.27.1",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.25.76"
},
@@ -93,15 +94,15 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.16.4",
"@types/node": "^22.17.0",
"@types/prismjs": "^1.26.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.9",
"@types/react-dom": "19.1.7",
"@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.2.0",
"eslint": "^9.31.0",
"eslint": "^9.32.0",
"node-loader": "^2.1.0",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
}
}
@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params;
const imageProxy = new ImageProxy();
const image = await imageProxy.forwardImageAsync(id);
if (!image) {
notFound();
}
return new Response(image, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
},
});
};
@@ -23,7 +23,7 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ id: stri
headers.set("Content-Type", image.contentType);
headers.set("Content-Length", image.content.length.toString());
return new NextResponse(image.content, {
return new NextResponse(new Uint8Array(image.content), {
status: 200,
headers,
});
+1 -2
View File
@@ -1,5 +1,4 @@
import { createEnv } from "@homarr/env";
import { createBooleanSchema } from "@homarr/env/schemas";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
+9 -8
View File
@@ -10,7 +10,7 @@
"main": "./src/main.ts",
"types": "./src/main.ts",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --external:bcrypt --outfile=tasks.cjs",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
@@ -30,26 +30,27 @@
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"dotenv": "^17.2.0",
"dotenv": "^17.2.1",
"fastify": "^5.4.0",
"superjson": "2.2.2",
"undici": "7.12.0"
"undici": "7.13.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.16.4",
"dotenv-cli": "^8.0.0",
"esbuild": "^0.25.6",
"eslint": "^9.31.0",
"@types/node": "^22.17.0",
"dotenv-cli": "^10.0.0",
"esbuild": "^0.25.8",
"eslint": "^9.32.0",
"prettier": "^3.6.2",
"tsx": "4.20.3",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
}
}
+4 -4
View File
@@ -25,7 +25,7 @@
"@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.2.0",
"dotenv": "^17.2.1",
"tsx": "4.20.3",
"ws": "^8.18.3"
},
@@ -34,9 +34,9 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.1",
"esbuild": "^0.25.6",
"eslint": "^9.31.0",
"esbuild": "^0.25.8",
"eslint": "^9.32.0",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
}
}
+29 -1
View File
@@ -1,9 +1,10 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createRedisContainer } from "./shared/redis-container";
describe("Health checks", () => {
test("ready and live should return 200 OK", async () => {
test("ready and live should return 200 OK with normal image and no extra configuration", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
@@ -15,4 +16,31 @@ describe("Health checks", () => {
expect(readyResponse.status).toBe(200);
expect(liveResponse.status).toBe(200);
}, 20_000);
test("ready and live should return 200 OK with external redis", async () => {
// Arrange
const redisContainer = await createRedisContainer().start();
const homarrContainer = await createHomarrContainer({
environment: {
REDIS_IS_EXTERNAL: "true",
REDIS_HOST: "host.docker.internal",
REDIS_PORT: redisContainer.getMappedPort(6379).toString(),
REDIS_PASSWORD: redisContainer.getPassword(),
},
}).start();
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(
readyResponse.status,
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
).toBe(200);
expect(
liveResponse.status,
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
).toBe(200);
}, 20_000);
});
+5
View File
@@ -0,0 +1,5 @@
import { RedisContainer } from "@testcontainers/redis";
export const createRedisContainer = () => {
return new RedisContainer("redis:latest").withPassword("homarr");
};
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.29.0",
"version": "1.31.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -38,24 +38,25 @@
"@semantic-release/github": "^11.0.3",
"@semantic-release/npm": "^12.0.2",
"@semantic-release/release-notes-generator": "^14.0.3",
"@testcontainers/redis": "^11.4.0",
"@turbo/gen": "^2.5.5",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^7.0.3",
"cross-env": "^10.0.0",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"semantic-release": "^24.2.7",
"testcontainers": "^11.2.1",
"testcontainers": "^11.4.0",
"turbo": "^2.5.5",
"typescript": "^5.8.3",
"typescript": "^5.9.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"packageManager": "pnpm@10.13.1",
"packageManager": "pnpm@10.14.0",
"engines": {
"node": ">=22.17.1"
"node": ">=22.18.0"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -70,7 +71,7 @@
"tree-sitter-json"
],
"overrides": {
"proxmox-api>undici": "7.12.0"
"proxmox-api>undici": "7.13.0"
},
"allowUnusedPatches": true,
"ignoredBuiltDependencies": [
+2 -2
View File
@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+7 -6
View File
@@ -35,20 +35,21 @@
"@homarr/log": "workspace:^",
"@homarr/old-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.3.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query": "^5.84.1",
"@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3",
"@trpc/server": "^11.4.3",
"@trpc/tanstack-react-query": "^11.4.3",
"lodash.clonedeep": "^4.5.0",
"next": "15.4.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.4.5",
"react": "19.1.1",
"react-dom": "19.1.1",
"superjson": "2.2.2",
"trpc-to-openapi": "^2.3.2",
"zod": "^3.25.76"
@@ -57,8 +58,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"eslint": "^9.32.0",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
}
}
@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createDb } from "@homarr/db/test";
import * as ping from "@homarr/ping";
import { appRouter } from "../../widgets/app";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/ping", () => ({ sendPingRequestAsync: async () => await Promise.resolve(null) }));
describe("ping should call sendPingRequestAsync with url and return result", () => {
test("ping with error response should return error and url", async () => {
// Arrange
const spy = vi.spyOn(ping, "sendPingRequestAsync");
const url = "http://localhost";
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ error: "error" }));
// Act
const result = await caller.ping({ url });
// Assert
expect(result.url).toBe(url);
expect("error" in result).toBe(true);
});
test("ping with success response should return statusCode and url", async () => {
// Arrange
const spy = vi.spyOn(ping, "sendPingRequestAsync");
const url = "http://localhost";
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ statusCode: 200, durationMs: 123 }));
// Act
const result = await caller.ping({ url });
// Assert
expect(result.url).toBe(url);
expect("statusCode" in result).toBe(true);
});
});
+17 -13
View File
@@ -1,12 +1,20 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { pingUrlChannel } from "@homarr/redis";
import { pingRequestHandler } from "@homarr/request-handler/ping";
import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const appRouter = createTRPCRouter({
ping: publicProcedure.input(z.object({ url: z.string() })).query(async ({ input }) => {
const pingResult = await sendPingRequestAsync(input.url);
return {
url: input.url,
...pingResult,
};
}),
updatedPing: publicProcedure
.input(
z.object({
@@ -15,20 +23,16 @@ export const appRouter = createTRPCRouter({
)
.subscription(async ({ input }) => {
await pingUrlChannel.addAsync(input.url);
const innerHandler = pingRequestHandler.handler({ url: input.url });
const pingResult = await sendPingRequestAsync(input.url);
return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>(
(emit) => {
// Run ping request in background
void innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then(({ data }) => {
emit.next({ url: input.url, ...data });
});
const unsubscribe = innerHandler.subscribe((pingResponse) => {
emit.next({
url: input.url,
...pingResponse,
});
emit.next({ url: input.url, ...pingResult });
const unsubscribe = pingChannel.subscribe((message) => {
// Only emit if same url
if (message.url !== input.url) return;
emit.next(message);
});
return () => {
+215
View File
@@ -0,0 +1,215 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
firewallMemoryRequestHandler,
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallCpuRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallCpuStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallCpuSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallInterfacesStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallInterfacesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallInterfacesStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallInterfacesSummary[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallVersionStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallVersionRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallVersionStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallVersionSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallMemoryStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallMemoryRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallMemoryStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallMemorySummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});
+4
View File
@@ -3,8 +3,10 @@ import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { downloadsRouter } from "./downloads";
import { firewallRouter } from "./firewall";
import { healthMonitoringRouter } from "./health-monitoring";
import { indexerManagerRouter } from "./indexer-manager";
import { mediaReleaseRouter } from "./media-release";
import { mediaRequestsRouter } from "./media-requests";
import { mediaServerRouter } from "./media-server";
import { mediaTranscodingRouter } from "./media-transcoding";
@@ -27,6 +29,7 @@ export const widgetRouter = createTRPCRouter({
smartHome: smartHomeRouter,
stockPrice: stockPriceRouter,
mediaServer: mediaServerRouter,
mediaRelease: mediaReleaseRouter,
calendar: calendarRouter,
downloads: downloadsRouter,
mediaRequests: mediaRequestsRouter,
@@ -38,5 +41,6 @@ export const widgetRouter = createTRPCRouter({
options: optionsRouter,
releases: releasesRouter,
networkController: networkControllerRouter,
firewall: firewallRouter,
notifications: notificationsRouter,
});
@@ -0,0 +1,67 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
}),
);
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
integration: result.integration,
})),
);
}),
subscribeToReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"mediaRelease"> }>;
releases: MediaRelease[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((releases) => {
emit.next({
integration,
releases,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});
+1 -2
View File
@@ -1,8 +1,7 @@
import { z } from "zod";
import { createBooleanSchema, createDurationSchema, createEnv } from "@homarr/core/infrastructure/env";
import { supportedAuthProviders } from "@homarr/definitions";
import { createEnv } from "@homarr/env";
import { createBooleanSchema, createDurationSchema } from "@homarr/env/schemas";
const authProvidersSchema = z
.string()
+8 -8
View File
@@ -27,28 +27,28 @@
"@auth/drizzle-adapter": "^1.10.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^6.0.0",
"cookies": "^0.9.1",
"ldapts": "8.0.6",
"next": "15.4.1",
"ldapts": "8.0.9",
"next": "15.4.5",
"next-auth": "5.0.0-beta.29",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/bcrypt": "6.0.0",
"@types/cookies": "0.9.1",
"eslint": "^9.31.0",
"eslint": "^9.32.0",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
}
}
+4 -4
View File
@@ -25,14 +25,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"react": "19.1.0",
"react-dom": "19.1.0"
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+3 -3
View File
@@ -24,13 +24,13 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"undici": "7.12.0"
"undici": "7.13.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { Agent as HttpsAgent } from "node:https";
import path from "node:path";
import { checkServerIdentity, rootCertificates } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import { env } from "@homarr/common/env";
@@ -131,8 +132,8 @@ export const createAxiosCertificateInstanceAsync = async (
});
};
export const fetchWithTrustedCertificatesAsync: typeof fetch = async (url, options) => {
const agent = await createCertificateAgentAsync();
export const fetchWithTrustedCertificatesAsync = async (url: RequestInfo, options?: RequestInit): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
return fetch(url, {
...options,
dispatcher: agent,
+4 -4
View File
@@ -28,14 +28,14 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.2.0"
"dotenv": "^17.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "^0.25.6",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"esbuild": "^0.25.8",
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { randomBytes } from "crypto";
import { z } from "zod";
import { createEnv } from "@homarr/env";
import { createEnv } from "@homarr/core/infrastructure/env";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
+7 -7
View File
@@ -27,14 +27,14 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/env": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"dayjs": "^1.11.13",
"next": "15.4.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"undici": "7.12.0",
"next": "15.4.5",
"react": "19.1.1",
"react-dom": "19.1.1",
"undici": "7.13.0",
"zod": "^3.25.76",
"zod-validation-error": "^3.5.3"
},
@@ -42,7 +42,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+1
View File
@@ -12,3 +12,4 @@ export * from "./error";
export * from "./fetch-with-timeout";
export * from "./theme";
export * from "./function";
export * from "./id";
@@ -1,9 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
export default [...baseConfig];
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@homarr/core",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
"./infrastructure/redis": "./src/infrastructure/redis/client.ts",
"./infrastructure/env": "./src/infrastructure/env/index.ts",
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.8",
"ioredis": "5.7.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
@@ -7,3 +7,6 @@ export const defaultEnvOptions = {
} satisfies Partial<Parameters<typeof createEnvT3>[0]>;
export const createEnv: typeof createEnvT3 = (options) => createEnvT3({ ...defaultEnvOptions, ...options });
export * from "./prefix";
export * from "./schemas";
+13
View File
@@ -0,0 +1,13 @@
export const runtimeEnvWithPrefix = (prefix: `${string}_`) =>
Object.entries(process.env)
.filter(([key]) => key.startsWith(prefix))
.reduce(
(acc, [key, value]) => {
if (value === undefined) return acc;
const newKey = key.replace(prefix, "");
acc[newKey] = value;
return acc;
},
{} as Record<string, string>,
);
File renamed without changes.
@@ -0,0 +1,26 @@
import type { RedisOptions } from "ioredis";
import { Redis } from "ioredis";
import { redisEnv } from "./env";
const defaultRedisOptions = {
connectionName: "homarr",
} satisfies RedisOptions;
export type { Redis as RedisClient } from "ioredis";
export const createRedisClient = () =>
redisEnv.IS_EXTERNAL
? new Redis({
...defaultRedisOptions,
host: redisEnv.HOST,
port: redisEnv.PORT,
tls: redisEnv.TLS_CA
? {
ca: redisEnv.TLS_CA,
}
: undefined,
username: redisEnv.USERNAME,
password: redisEnv.PASSWORD,
})
: new Redis(defaultRedisOptions);
@@ -0,0 +1,17 @@
import { z } from "zod/v4";
import { createEnv } from "../env";
import { runtimeEnvWithPrefix } from "../env/prefix";
import { createBooleanSchema } from "../env/schemas";
export const redisEnv = createEnv({
server: {
IS_EXTERNAL: createBooleanSchema(false),
HOST: z.string().optional(),
PORT: z.coerce.number().default(6379).optional(),
TLS_CA: z.string().optional(),
USERNAME: z.string().optional(),
PASSWORD: z.string().optional(),
},
runtimeEnv: runtimeEnvWithPrefix("REDIS_"),
});
File renamed without changes.
+6 -6
View File
@@ -26,15 +26,15 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query": "^5.84.1",
"@trpc/client": "^11.4.3",
"@trpc/server": "^11.4.3",
"@trpc/tanstack-react-query": "^11.4.3",
"node-cron": "^4.2.1",
"react": "19.1.0",
"react": "19.1.1",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -42,8 +42,8 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"@types/react": "19.1.8",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"@types/react": "19.1.9",
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import { env as commonEnv } from "@homarr/common/env";
import { createEnv } from "@homarr/env";
import { createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
+2 -2
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+2 -2
View File
@@ -33,7 +33,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
@@ -1,6 +1,7 @@
import { checkCron } from "./validation";
export const EVERY_5_SECONDS = checkCron("*/5 * * * * *") satisfies string;
export const EVERY_30_SECONDS = checkCron("*/30 * * * * *") satisfies string;
export const EVERY_MINUTE = checkCron("* * * * *") satisfies string;
export const EVERY_5_MINUTES = checkCron("*/5 * * * *") satisfies string;
export const EVERY_10_MINUTES = checkCron("*/10 * * * *") satisfies string;
+3 -2
View File
@@ -32,6 +32,7 @@
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
@@ -43,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+10
View File
@@ -3,6 +3,12 @@ import { dockerContainersJob } from "./jobs/docker";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
import { downloadsJob } from "./jobs/integrations/downloads";
import {
firewallCpuJob,
firewallInterfacesJob,
firewallMemoryJob,
firewallVersionJob,
} from "./jobs/integrations/firewall";
import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
@@ -39,6 +45,10 @@ export const jobGroup = createCronJobGroup({
minecraftServerStatus: minecraftServerStatusJob,
dockerContainers: dockerContainersJob,
networkController: networkControllerJob,
firewallCpu: firewallCpuJob,
firewallMemory: firewallMemoryJob,
firewallVersion: firewallVersionJob,
firewallInterfaces: firewallInterfacesJob,
refreshNotifications: refreshNotificationsJob,
});
@@ -0,0 +1,46 @@
import { EVERY_5_SECONDS, EVERY_30_SECONDS, EVERY_HOUR, EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
firewallMemoryRequestHandler,
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const firewallCpuJob = createCronJob("firewallCpu", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(firewallCpuRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);
export const firewallMemoryJob = createCronJob("firewallMemory", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(firewallMemoryRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);
export const firewallInterfacesJob = createCronJob("firewallInterfaces", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(firewallInterfacesRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);
export const firewallVersionJob = createCronJob("firewallVersion", EVERY_HOUR).withCallback(
createRequestIntegrationJobHandler(firewallVersionRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);
+14 -4
View File
@@ -2,8 +2,8 @@ import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { logger } from "@homarr/log";
import { pingUrlChannel } from "@homarr/redis";
import { pingRequestHandler } from "@homarr/request-handler/ping";
import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { createCronJob } from "../lib";
@@ -28,6 +28,16 @@ export const pingJob = createCronJob("ping", EVERY_MINUTE, {
});
const pingAsync = async (url: string) => {
const handler = pingRequestHandler.handler({ url });
await handler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
const pingResult = await sendPingRequestAsync(url);
if ("statusCode" in pingResult) {
logger.debug(`executed ping for url ${url} with status code ${pingResult.statusCode}`);
} else {
logger.error(`Executing ping for url ${url} failed with error: ${pingResult.error}`);
}
await pingChannel.publishAsync({
url,
...pingResult,
});
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { env as commonEnv } from "@homarr/common/env";
import { createEnv } from "@homarr/env";
import { createEnv } from "@homarr/core/infrastructure/env";
const drivers = {
betterSqlite3: "better-sqlite3",
+10 -10
View File
@@ -40,19 +40,19 @@
"dependencies": {
"@auth/core": "^0.40.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^8.1.3",
"@mantine/core": "^8.2.2",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^11.2.1",
"@testcontainers/mysql": "^11.4.0",
"better-sqlite3": "^12.2.0",
"dotenv": "^17.2.0",
"dotenv": "^17.2.1",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.3",
"drizzle-orm": "^0.44.4",
"drizzle-zod": "^0.7.1",
"mysql2": "3.14.2",
"mysql2": "3.14.3",
"superjson": "2.2.2"
},
"devDependencies": {
@@ -60,11 +60,11 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.13",
"dotenv-cli": "^8.0.0",
"esbuild": "^0.25.6",
"eslint": "^9.31.0",
"dotenv-cli": "^10.0.0",
"esbuild": "^0.25.8",
"eslint": "^9.32.0",
"prettier": "^3.6.2",
"tsx": "4.20.3",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
}
}
+2 -2
View File
@@ -31,8 +31,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"eslint": "^9.32.0",
"tsx": "4.20.3",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
}
}
@@ -39,22 +39,16 @@ export type HomarrDocumentationPath =
| "/search"
| "/docs/tags"
| "/docs/tags/active-directory"
| "/docs/tags/ad-guard"
| "/docs/tags/ad-guard-home"
| "/docs/tags/administration"
| "/docs/tags/advanced"
| "/docs/tags/analytics"
| "/docs/tags/api"
| "/docs/tags/apps"
| "/docs/tags/background"
| "/docs/tags/banner"
| "/docs/tags/blocking"
| "/docs/tags/boards"
| "/docs/tags/bookmark"
| "/docs/tags/bookmarks"
| "/docs/tags/caddy"
| "/docs/tags/certificates"
| "/docs/tags/checklist"
| "/docs/tags/code"
| "/docs/tags/community"
| "/docs/tags/configuration"
@@ -64,63 +58,37 @@ export type HomarrDocumentationPath =
| "/docs/tags/database"
| "/docs/tags/developer"
| "/docs/tags/development"
| "/docs/tags/dns"
| "/docs/tags/docker"
| "/docs/tags/donation"
| "/docs/tags/edit-mode"
| "/docs/tags/env"
| "/docs/tags/environment-variables"
| "/docs/tags/feeds"
| "/docs/tags/finance"
| "/docs/tags/getting-started"
| "/docs/tags/google"
| "/docs/tags/grafana"
| "/docs/tags/groups"
| "/docs/tags/hardware"
| "/docs/tags/health"
| "/docs/tags/help"
| "/docs/tags/icon-picker"
| "/docs/tags/icon-repositories"
| "/docs/tags/icons"
| "/docs/tags/iframe"
| "/docs/tags/images"
| "/docs/tags/installation"
| "/docs/tags/integrade"
| "/docs/tags/integration"
| "/docs/tags/integrations"
| "/docs/tags/interface"
| "/docs/tags/jellyserr"
| "/docs/tags/jobs"
| "/docs/tags/layout"
| "/docs/tags/ldap"
| "/docs/tags/links"
| "/docs/tags/lists"
| "/docs/tags/management"
| "/docs/tags/market"
| "/docs/tags/media"
| "/docs/tags/minecraft"
| "/docs/tags/monitoring"
| "/docs/tags/network"
| "/docs/tags/news"
| "/docs/tags/notebook"
| "/docs/tags/notes"
| "/docs/tags/oidc"
| "/docs/tags/open-collective"
| "/docs/tags/open-media-vault"
| "/docs/tags/overseerr"
| "/docs/tags/permissions"
| "/docs/tags/pgid"
| "/docs/tags/pi-hole"
| "/docs/tags/ping"
| "/docs/tags/programming"
| "/docs/tags/proxmox"
| "/docs/tags/proxy"
| "/docs/tags/puid"
| "/docs/tags/releases"
| "/docs/tags/repositories"
| "/docs/tags/responsive"
| "/docs/tags/roles"
| "/docs/tags/rss"
| "/docs/tags/search"
| "/docs/tags/search-engines"
| "/docs/tags/security"
@@ -128,24 +96,15 @@ export type HomarrDocumentationPath =
| "/docs/tags/seo"
| "/docs/tags/server"
| "/docs/tags/settings"
| "/docs/tags/sinkhole"
| "/docs/tags/sso"
| "/docs/tags/stocks"
| "/docs/tags/system"
| "/docs/tags/table"
| "/docs/tags/tasks"
| "/docs/tags/technical-documentation"
| "/docs/tags/text"
| "/docs/tags/torrent"
| "/docs/tags/traefik"
| "/docs/tags/translations"
| "/docs/tags/unifi-controller"
| "/docs/tags/unraid"
| "/docs/tags/uploads"
| "/docs/tags/usenet"
| "/docs/tags/users"
| "/docs/tags/variables"
| "/docs/tags/widgets"
| "/docs/advanced/command-line"
| "/docs/advanced/command-line/fix-usernames"
| "/docs/advanced/command-line/password-recovery"
@@ -188,17 +147,38 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/source"
| "/docs/getting-started/installation/synology"
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/cloud"
| "/docs/integrations/containers"
| "/docs/integrations/dns"
| "/docs/integrations/hardware"
| "/docs/integrations/adguard-home"
| "/docs/integrations/codeberg"
| "/docs/integrations/dash-dot"
| "/docs/integrations/deluge"
| "/docs/integrations/docker-hub"
| "/docs/integrations/docker"
| "/docs/integrations/emby"
| "/docs/integrations/github"
| "/docs/integrations/gitlab"
| "/docs/integrations/home-assistant"
| "/docs/integrations/jellyfin"
| "/docs/integrations/jellyseerr"
| "/docs/integrations/kubernetes"
| "/docs/integrations/media-requester"
| "/docs/integrations/media-server"
| "/docs/integrations/network"
| "/docs/integrations/servarr"
| "/docs/integrations/torrent"
| "/docs/integrations/usenet"
| "/docs/integrations/lidarr"
| "/docs/integrations/nextcloud"
| "/docs/integrations/npm"
| "/docs/integrations/ntfy"
| "/docs/integrations/nzbget"
| "/docs/integrations/open-media-vault"
| "/docs/integrations/overseerr"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
| "/docs/integrations/prowlarr"
| "/docs/integrations/proxmox"
| "/docs/integrations/q-bittorent"
| "/docs/integrations/radarr"
| "/docs/integrations/readarr"
| "/docs/integrations/sabnzbd"
| "/docs/integrations/sonarr"
| "/docs/integrations/tdarr"
| "/docs/integrations/transmission"
| "/docs/integrations/unifi-controller"
| "/docs/management/api"
| "/docs/management/apps"
| "/docs/management/boards"
@@ -209,23 +189,32 @@ export type HomarrDocumentationPath =
| "/docs/management/settings"
| "/docs/management/tasks"
| "/docs/management/users"
| "/docs/widgets/app"
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
| "/docs/widgets/clock"
| "/docs/widgets/dns-hole"
| "/docs/widgets/dns-hole-controls"
| "/docs/widgets/dns-hole-summary"
| "/docs/widgets/docker-containers"
| "/docs/widgets/downloads"
| "/docs/widgets/health-monitoring"
| "/docs/widgets/home-assistant"
| "/docs/widgets/iframe"
| "/docs/widgets/indexer-manager"
| "/docs/widgets/media-requests"
| "/docs/widgets/media-releases"
| "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats"
| "/docs/widgets/media-server"
| "/docs/widgets/media-transcoding"
| "/docs/widgets/minecraft-server-status"
| "/docs/widgets/network-controller"
| "/docs/widgets/network-controller-status"
| "/docs/widgets/network-controller-summary"
| "/docs/widgets/notebook"
| "/docs/widgets/notifications"
| "/docs/widgets/releases"
| "/docs/widgets/rss"
| "/docs/widgets/stocks"
| "/docs/widgets/rss-feed"
| "/docs/widgets/smart-home-entity-state"
| "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/stock-price"
| "/docs/widgets/video"
| "/docs/widgets/weather"
| ""
+33 -3
View File
@@ -92,19 +92,19 @@ export const integrationDefs = {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService"],
category: ["mediaService", "mediaRelease"],
},
emby: {
name: "Emby",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService"],
category: ["mediaService", "mediaRelease"],
},
plex: {
name: "Plex",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService"],
category: ["mediaService", "mediaRelease"],
},
jellyseerr: {
name: "Jellyseerr",
@@ -172,6 +172,12 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
},
opnsense: {
name: "OPNsense",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
category: ["firewall"],
},
github: {
name: "Github",
secretKinds: [[], ["personalAccessToken"]],
@@ -207,6 +213,27 @@ export const integrationDefs = {
category: ["releasesProvider"],
defaultUrl: "https://codeberg.org",
},
linuxServerIO: {
name: "LinuxServer.io",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.linuxserver.io",
},
gitHubContainerRegistry: {
name: "GitHub Container Registry",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.github.com",
},
quay: {
name: "Quay",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png",
category: ["releasesProvider"],
defaultUrl: "https://quay.io",
},
ntfy: {
name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]],
@@ -224,6 +251,7 @@ export const integrationDefs = {
"downloadClient",
"healthMonitoring",
"indexerManager",
"mediaRelease",
"mediaRequest",
"mediaService",
"mediaTranscoding",
@@ -282,6 +310,7 @@ export const integrationCategories = [
"mediaService",
"calendar",
"mediaSearch",
"mediaRelease",
"mediaRequest",
"downloadClient",
"usenet",
@@ -295,6 +324,7 @@ export const integrationCategories = [
"networkController",
"releasesProvider",
"notifications",
"firewall",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+2
View File
@@ -24,7 +24,9 @@ export const widgetKinds = [
"indexerManager",
"healthMonitoring",
"releases",
"mediaReleases",
"dockerContainers",
"firewall",
"notifications",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+3 -3
View File
@@ -25,7 +25,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"dockerode": "^4.0.7"
},
"devDependencies": {
@@ -33,7 +33,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.42",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import { createEnv } from "@homarr/env";
import { createBooleanSchema } from "@homarr/env/schemas";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
+3 -3
View File
@@ -26,14 +26,14 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^8.1.3",
"@mantine/form": "^8.2.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+4 -4
View File
@@ -29,15 +29,15 @@
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.1.3",
"react": "19.1.0",
"@mantine/core": "^8.2.2",
"react": "19.1.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+2 -2
View File
@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+4
View File
@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@homarr/image-proxy",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"bcrypt": "^6.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "6.0.0",
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+133
View File
@@ -0,0 +1,133 @@
import bcrypt from "bcrypt";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { createGetSetChannel } from "@homarr/redis";
const createHashChannel = (hash: `${string}.${string}`) => createGetSetChannel<string>(`image-proxy:hash:${hash}`);
const createUrlByIdChannel = (id: string) =>
createGetSetChannel<{
url: `${string}.${string}`;
headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`);
const saltChannel = createGetSetChannel<string>("image-proxy:salt");
export class ImageProxy {
private static salt: string | null = null;
private async getOrCreateSaltAsync(): Promise<string> {
if (ImageProxy.salt) return ImageProxy.salt;
const existingSalt = await saltChannel.getAsync();
if (existingSalt) {
ImageProxy.salt = existingSalt;
return existingSalt;
}
const salt = await bcrypt.genSalt(10);
logger.debug(`Generated new salt for image proxy salt="${salt}"`);
ImageProxy.salt = salt;
await saltChannel.setAsync(salt);
return salt;
}
public async createImageAsync(url: string, headers?: Record<string, string>): Promise<string> {
const existingId = await this.getExistingIdAsync(url, headers);
if (existingId) {
logger.debug(
`Image already exists in the proxy id="${existingId}" url="${this.redactUrl(url)}" headers="${this.redactHeaders(headers ?? null)}"`,
);
return this.createImageUrl(existingId);
}
const id = createId();
await this.storeImageAsync(id, url, headers);
return this.createImageUrl(id);
}
public async forwardImageAsync(id: string): Promise<Blob | null> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) {
return null;
}
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
const proxyUrl = this.createImageUrl(id);
if (!response.ok) {
logger.error(
`Failed to fetch image id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl}" statusCode="${response.status}"`,
);
return null;
}
const blob = (await response.blob()) as Blob;
logger.debug(
`Forwarding image succeeded id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl} size="${(blob.size / 1024).toFixed(1)}KB"`,
);
return blob;
}
private createImageUrl(id: string): string {
return `/api/image-proxy/${id}`;
}
private async getImageUrlAndHeadersAsync(id: string) {
const urlHeaderChannel = createUrlByIdChannel(id);
const urlHeader = await urlHeaderChannel.getAsync();
if (!urlHeader) {
logger.warn(`Image not found in the proxy id="${id}"`);
return null;
}
return {
url: decryptSecret(urlHeader.url),
headers: JSON.parse(decryptSecret(urlHeader.headers)) as Record<string, string> | null,
};
}
private async getExistingIdAsync(url: string, headers: Record<string, string> | undefined): Promise<string | null> {
const salt = await this.getOrCreateSaltAsync();
const urlHash = await bcrypt.hash(url, salt);
const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt);
const channel = createHashChannel(`${urlHash}.${headerHash}`);
return await channel.getAsync();
}
private async storeImageAsync(id: string, url: string, headers: Record<string, string> | undefined): Promise<void> {
const salt = await this.getOrCreateSaltAsync();
const urlHash = await bcrypt.hash(url, salt);
const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt);
const hashChannel = createHashChannel(`${urlHash}.${headerHash}`);
const urlHeaderChannel = createUrlByIdChannel(id);
await urlHeaderChannel.setAsync({
url: encryptSecret(url),
headers: encryptSecret(JSON.stringify(headers ?? null)),
});
await hashChannel.setAsync(id);
logger.debug(
`Stored image in the proxy id="${id}" url="${this.redactUrl(url)}" headers="${this.redactHeaders(headers ?? null)}"`,
);
}
private redactUrl(url: string): string {
const urlObject = new URL(url);
const redactedSearch = [...urlObject.searchParams.keys()].map((key) => `${key}=REDACTED`).join("&");
return `${urlObject.origin}${urlObject.pathname}${redactedSearch ? `?${redactedSearch}` : ""}`;
}
private redactHeaders(headers: Record<string, string> | null): string | null {
if (!headers) return null;
return Object.keys(headers).join(", ");
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"types": ["node"],
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+6 -5
View File
@@ -25,14 +25,15 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@ctrl/deluge": "^7.1.0",
"@ctrl/deluge": "^7.1.1",
"@ctrl/qbittorrent": "^9.6.0",
"@ctrl/transmission": "^7.2.0",
"@gitbeaker/rest": "^42.5.0",
"@gitbeaker/rest": "^43.3.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/node-unifi": "^2.6.0",
"@homarr/redis": "workspace:^0.1.0",
@@ -44,7 +45,7 @@
"octokit": "^5.0.3",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.5",
"undici": "7.12.0",
"undici": "7.13.0",
"xml2js": "^0.6.2",
"zod": "^3.25.76"
},
@@ -54,7 +55,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "^2.5.1",
"@types/xml2js": "^0.4.14",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
@@ -14,11 +14,13 @@ import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorre
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration";
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration";
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
@@ -29,11 +31,13 @@ import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { QuayIntegration } from "../quay/quay-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -99,11 +103,15 @@ export const integrationCreators = {
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
github: GithubIntegration,
dockerHub: DockerHubIntegration,
gitlab: GitlabIntegration,
npm: NPMIntegration,
codeberg: CodebergIntegration,
linuxServerIO: LinuxServerIOIntegration,
gitHubContainerRegistry: GitHubContainerRegistryIntegration,
quay: QuayIntegration,
ntfy: NTFYIntegration,
mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
@@ -20,7 +20,7 @@ const localLogger = logger.child({ module: "CodebergIntegration" });
export class CodebergIntegration extends Integration implements ReleasesProviderIntegration {
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback({});
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
@@ -61,7 +61,7 @@ export class CodebergIntegration extends Integration implements ReleasesProvider
const details = await this.getDetailsAsync(owner, name);
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return fetchWithTrustedCertificatesAsync(
return await fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
{ headers },
);
@@ -30,7 +30,8 @@ export class DockerHubIntegration extends Integration implements ReleasesProvide
}
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) return await callback({});
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken"))
return await callback(undefined);
const storedSession = await this.sessionStore.getAsync();
@@ -2,6 +2,7 @@ import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -10,6 +11,7 @@ import type { TestingResult } from "../base/test-connection/test-connection-serv
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
const sessionSchema = z.object({
NowPlayingItem: z
@@ -31,7 +33,34 @@ const sessionSchema = z.object({
UserName: z.string().nullish(),
});
export class EmbyIntegration extends Integration implements IMediaServerIntegration {
const itemSchema = z.object({
Id: z.string(),
ServerId: z.string(),
Name: z.string(),
Taglines: z.array(z.string()),
Studios: z.array(z.object({ Name: z.string() })),
Overview: z.string().optional(),
PremiereDate: z
.string()
.datetime()
.transform((date) => new Date(date))
.optional(),
DateCreated: z
.string()
.datetime()
.transform((date) => new Date(date)),
Genres: z.array(z.string()),
CommunityRating: z.number().optional(),
RunTimeTicks: z.number(),
Type: z.string(), // for example "Movie"
});
const userSchema = z.object({
Id: z.string(),
Name: z.string(),
});
export class EmbyIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private static readonly apiKeyHeader = "X-Emby-Token";
private static readonly deviceId = "homarr-emby-integration";
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
@@ -103,4 +132,69 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
};
});
}
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
const limit = 100;
const users = await this.fetchUsersPublicAsync();
const userId = users.at(0)?.id;
if (!userId) {
throw new Error("No users found");
}
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(
super.url(
`/Users/${userId}/Items/Latest?Limit=${limit}&Fields=CommunityRating,Studios,PremiereDate,Genres,ChildCount,ProductionYear,DateCreated,Overview,Taglines`,
),
{
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
},
);
if (!response.ok) {
throw new ResponseError(response);
}
const items = z.array(itemSchema).parse(await response.json());
return items.map((item) => ({
id: item.Id,
type: item.Type === "Movie" ? "movie" : item.Type === "Series" ? "tv" : "unknown",
title: item.Name,
subtitle: item.Taglines.at(0),
description: item.Overview,
releaseDate: item.PremiereDate ?? item.DateCreated,
imageUrls: {
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios.at(0)?.Name,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres,
href: super.url(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
}));
}
// https://dev.emby.media/reference/RestAPI/UserService/getUsersPublic.html
private async fetchUsersPublicAsync(): Promise<{ id: string; name: string }[]> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/Users/Public"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
if (!response.ok) {
throw new ResponseError(response);
}
const users = z.array(userSchema).parse(await response.json());
return users.map((user) => ({
id: user.Id,
name: user.Name,
}));
}
}
@@ -0,0 +1,145 @@
import { Octokit, RequestError } from "octokit";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GitHubContainerRegistryIntegration" });
export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration {
private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration";
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const headers: RequestInit["headers"] = {
"User-Agent": GitHubContainerRegistryIntegration.userAgent,
};
if (this.hasSecretValue("personalAccessToken"))
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
const response = await input.fetchAsync(this.url("/octocat"), {
headers,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with GitHub Container Registry integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try {
const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({
username: owner,
package_type: "container",
package_name: name,
per_page: 100,
});
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.metadata?.container?.tags || !(release.metadata.container.tags.length > 0)) return acc;
release.metadata.container.tags.forEach((tag) => {
acc.push({
latestRelease: tag,
latestReleaseAt: new Date(release.updated_at),
releaseUrl: release.html_url,
releaseDescription: release.description ?? undefined,
});
});
return acc;
}, []);
return getLatestRelease(releasesProviderResponse, repository, details);
} catch (error) {
const errorMessage = error instanceof RequestError ? error.message : String(error);
localLogger.warn(`Failed to get releases for ${owner}\\${name} with GitHub Container Registry integration`, {
owner,
name,
error: errorMessage,
});
return {
id: repository.id,
error: { message: errorMessage },
};
}
}
protected async getDetailsAsync(
api: Octokit,
owner: string,
name: string,
): Promise<DetailsProviderResponse | undefined> {
try {
const response = await api.rest.packages.getPackageForUser({
username: owner,
package_type: "container",
package_name: name,
});
return {
projectUrl: response.data.repository?.html_url ?? response.data.html_url,
projectDescription: response.data.repository?.description ?? undefined,
isFork: response.data.repository?.fork,
isArchived: response.data.repository?.archived,
createdAt: new Date(response.data.created_at),
starsCount: response.data.repository?.stargazers_count,
openIssues: response.data.repository?.open_issues_count,
forksCount: response.data.repository?.forks_count,
};
} catch (error) {
localLogger.warn(`Failed to get details for ${owner}\\${name} with GitHub Container Registry integration`, {
owner,
name,
error: error instanceof RequestError ? error.message : String(error),
});
return undefined;
}
}
private getApi() {
return new Octokit({
baseUrl: this.url("/").origin,
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
userAgent: GitHubContainerRegistryIntegration.userAgent,
throttle: { enabled: false }, // Disable throttling for this integration, Octokit will retry by default after a set time, thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
});
}
}
@@ -58,7 +58,6 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
}
const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try {
+8 -1
View File
@@ -21,13 +21,20 @@ export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
// Types
export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type {
FirewallInterface,
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallVersionSummary,
FirewallMemorySummary,
} from "./interfaces/firewall-summary/firewall-summary-types";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
@@ -0,0 +1,13 @@
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "./firewall-summary-types";
export interface FirewallSummaryIntegration {
getFirewallCpuAsync(): Promise<FirewallCpuSummary>;
getFirewallMemoryAsync(): Promise<FirewallMemorySummary>;
getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]>;
getFirewallVersionAsync(): Promise<FirewallVersionSummary>;
}
@@ -0,0 +1,24 @@
export interface FirewallInterfacesSummary {
data: FirewallInterface[];
timestamp: Date;
}
export interface FirewallInterface {
name: string;
receive: number;
transmit: number;
}
export interface FirewallVersionSummary {
version: string;
}
export interface FirewallCpuSummary {
total: number;
}
export interface FirewallMemorySummary {
used: number;
total: number;
percent: number;
}
@@ -0,0 +1,76 @@
import type { MantineColor } from "@mantine/core";
export const mediaTypeConfigurations = {
movie: {
color: "blue",
},
tv: {
color: "violet",
},
music: {
color: "green",
},
book: {
color: "orange",
},
game: {
color: "yellow",
},
video: {
color: "red",
},
article: {
color: "pink",
},
unknown: {
color: "gray",
},
} satisfies Record<string, { color: MantineColor }>;
export type MediaType = keyof typeof mediaTypeConfigurations;
export interface MediaRelease {
id: string;
type: MediaType;
title: string;
/**
* The subtitle of the media item, if applicable.
* Can also contain the season number for TV shows.
*/
subtitle?: string;
description?: string;
releaseDate: Date;
imageUrls: {
poster: string | undefined;
backdrop: string | undefined;
};
/**
* The name of the studio, publisher or author.
*/
producer?: string;
/**
* Price in USD
*/
price?: number;
/**
* Rating in any format (e.g. 5/10, 4.5/5, 90%, etc.)
*/
rating?: string;
/**
* List of tags / genres / categories
*/
tags: string[];
/**
* Link to the media item
*/
href: string;
/*
* Video / Music: duration in seconds
* Book: number of pages
*/
length?: number;
}
export interface IMediaReleasesIntegration {
getMediaReleasesAsync(): Promise<MediaRelease[]>;
}
@@ -2,6 +2,8 @@ import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
import type { AxiosInstance } from "axios";
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
@@ -13,9 +15,10 @@ import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration {
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: {
name: "Homarr",
@@ -70,6 +73,43 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
});
}
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
const apiClient = await this.getApiAsync();
const userLibraryApi = getUserLibraryApi(apiClient);
const userApi = getUserApi(apiClient);
const users = await userApi.getUsers();
const userId = users.data.at(0)?.Id;
if (!userId) {
throw new Error("No users found");
}
const result = await userLibraryApi.getLatestMedia({
fields: ["CustomRating", "Studios", "Genres", "ChildCount", "DateCreated", "Overview", "Taglines"],
userId,
limit: 100,
});
return result.data.map((item) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: item.Id!,
type: item.Type === "Movie" ? "movie" : item.Type === "Series" ? "tv" : "unknown",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
title: item.Name!,
subtitle: item.Taglines?.at(0),
description: item.Overview ?? undefined,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
imageUrls: {
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios?.at(0)?.Name ?? undefined,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres ?? [],
href: super.url(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
}));
}
/**
* Constructs an ApiClient synchronously with an ApiKey or asynchronously
* with a username and password.
@@ -0,0 +1,88 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import type { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types";
import { releasesResponseSchema } from "./linuxserverio-schemas";
const localLogger = logger.child({ module: "LinuxServerIOsIntegration" });
export class LinuxServerIOIntegration extends Integration implements ReleasesProviderIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/health"));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images"));
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
message: error.message,
},
};
} else {
const release = data.data.repositories.linuxserver.find((repo) => repo.name === name);
if (!release) {
localLogger.warn(`Repository ${name} not found on provider, with LinuxServerIO integration`, {
owner,
name,
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
return {
id: repository.id,
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date ? new Date(release.initial_date) : undefined,
starsCount: release.stars,
};
}
}
}
@@ -0,0 +1,31 @@
import { z } from "zod";
export const releasesResponseSchema = z.object({
data: z.object({
repositories: z.object({
linuxserver: z.array(
z.object({
name: z.string(),
initial_date: z
.string()
.transform((value) => new Date(value))
.optional(),
github_url: z.string(),
description: z.string(),
version: z.string(),
version_timestamp: z.string().transform((value) => new Date(value)),
stars: z.number(),
deprecated: z.boolean(),
changelog: z
.array(
z.object({
date: z.string().transform((value) => new Date(value)),
desc: z.string(),
}),
)
.optional(),
}),
),
}),
}),
});
@@ -0,0 +1,128 @@
import type { IMediaReleasesIntegration, MediaRelease } from "../../interfaces/media-releases";
export class MediaReleasesMockService implements IMediaReleasesIntegration {
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
return await Promise.resolve(mockMediaReleases);
}
}
export const mockMediaReleases: MediaRelease[] = [
{
id: "1",
type: "movie",
title: "Inception",
subtitle: "A mind-bending thriller",
description:
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/inception_backdrop.jpg",
},
producer: "Warner Bros.",
price: 14.99,
rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"],
href: "https://example.com/inception",
length: 148,
},
{
id: "2",
type: "tv",
title: "Breaking Bad",
subtitle: "S5E14 - Ozymandias",
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
},
producer: "AMC",
rating: "9.5/10",
tags: ["Crime", "Drama"],
href: "https://example.com/breaking_bad",
},
{
id: "3",
type: "music",
title: "Random Access Memories",
subtitle: "Daft Punk",
description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ram_backdrop.jpg",
},
producer: "Columbia Records",
price: 9.99,
rating: "8.5/10",
tags: ["Electronic", "Dance", "Pop", "Funk"],
href: "https://example.com/ram",
},
{
id: "4",
type: "book",
title: "The Great Gatsby",
subtitle: "F. Scott Fitzgerald",
description: "A novel about the American dream and the disillusionment that comes with it.",
releaseDate: new Date("1925-04-10"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/gatsby_backdrop.jpg",
},
producer: "Scribner",
price: 10.99,
rating: "4.2/5",
tags: ["Classic", "Fiction"],
href: "https://example.com/gatsby",
},
{
id: "5",
type: "game",
title: "The Legend of Zelda: Breath of the Wild",
subtitle: "Nintendo Switch",
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
releaseDate: new Date("2017-03-03"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/zelda_backdrop.jpg",
},
producer: "Nintendo",
price: 59.99,
rating: "10/10",
tags: ["Action", "Adventure"],
href: "https://example.com/zelda",
},
{
id: "6",
type: "article",
title: "The Rise of AI in Healthcare",
subtitle: "Tech Innovations",
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
releaseDate: new Date("2023-10-01"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
},
producer: "Tech Innovations",
rating: "4.8/5",
tags: ["Technology", "Healthcare"],
href: "https://example.com/ai_healthcare",
},
{
id: "7",
type: "video",
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
releaseDate: new Date("2024-05-18T17:00:00Z"),
imageUrls: {
poster:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
backdrop:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
},
producer: "PietSmiet",
rating: "1K",
tags: [],
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
},
];
@@ -9,6 +9,7 @@ import type {
ISystemHealthMonitoringIntegration,
} from "../interfaces/health-monitoring/health-monitoring-integration";
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
import type { IMediaReleasesIntegration } from "../interfaces/media-releases";
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
@@ -19,6 +20,7 @@ import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monito
import { DnsHoleMockService } from "./data/dns-hole";
import { DownloadClientMockService } from "./data/download";
import { IndexerManagerMockService } from "./data/indexer-manager";
import { MediaReleasesMockService } from "./data/media-releases";
import { MediaRequestMockService } from "./data/media-request";
import { MediaServerMockService } from "./data/media-server";
import { MediaTranscodingMockService } from "./data/media-transcoding";
@@ -36,6 +38,7 @@ export class MockIntegration
IClusterHealthMonitoringIntegration,
ISystemHealthMonitoringIntegration,
IIndexerManagerIntegration,
IMediaReleasesIntegration,
IMediaRequestIntegration,
IMediaServerIntegration,
IMediaTranscodingIntegration,
@@ -48,6 +51,7 @@ export class MockIntegration
private static readonly clusterMonitoring = new ClusterHealthMonitoringMockService();
private static readonly systemMonitoring = new SystemHealthMonitoringMockService();
private static readonly indexerManager = new IndexerManagerMockService();
private static readonly mediaReleases = new MediaReleasesMockService();
private static readonly mediaRequest = new MediaRequestMockService();
private static readonly mediaServer = new MediaServerMockService();
private static readonly mediaTranscoding = new MediaTranscodingMockService();
@@ -87,6 +91,9 @@ export class MockIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
// MediaReleasesIntegration
getMediaReleasesAsync = MockIntegration.mediaReleases.getMediaReleasesAsync.bind(MockIntegration.mediaReleases);
// MediaRequestIntegration
getSeriesInformationAsync = MockIntegration.mediaRequest.getSeriesInformationAsync.bind(MockIntegration.mediaRequest);
requestMediaAsync = MockIntegration.mediaRequest.requestMediaAsync.bind(MockIntegration.mediaRequest);
@@ -0,0 +1,189 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError, ResponseError } from "@homarr/common/server";
import { createChannelEventHistory } from "@homarr/redis";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { FirewallSummaryIntegration } from "../interfaces/firewall-summary/firewall-summary-integration";
import type {
FirewallCpuSummary,
FirewallInterface,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "../interfaces/firewall-summary/firewall-summary-types";
import {
opnsenseCPUSchema,
opnsenseInterfacesSchema,
opnsenseMemorySchema,
opnsenseSystemSummarySchema,
} from "./opnsense-types";
@HandleIntegrationErrors([])
export class OPNsenseIntegration extends Integration implements FirewallSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/diagnostics/system/system_information"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
const result = await response.json();
if (typeof result === "object" && result !== null) return { success: true };
return TestConnectionError.ParseResult(new ParseError("Expected object data"));
}
private getAuthHeaders() {
const username = super.getSecretValue("username");
const password = super.getSecretValue("password");
return `Basic ${btoa(`${username}:${password}`)}`;
}
public async getFirewallVersionAsync(): Promise<FirewallVersionSummary> {
const responseVersion = await fetchWithTrustedCertificatesAsync(
this.url("/api/diagnostics/system/system_information"),
{
headers: {
Authorization: this.getAuthHeaders(),
},
},
);
if (!responseVersion.ok) {
throw new ResponseError(responseVersion);
}
const summary = opnsenseSystemSummarySchema.parse(await responseVersion.json());
return {
version: summary.versions.at(0) ?? "Unknown",
};
}
private getInterfacesChannel() {
return createChannelEventHistory<FirewallInterface[]>(`integration:${this.integration.id}:interfaces`, 15);
}
public async getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]> {
const channel = this.getInterfacesChannel();
const responseInterfaces = await fetchWithTrustedCertificatesAsync(this.url("/api/diagnostics/traffic/interface"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!responseInterfaces.ok) {
throw new ResponseError(responseInterfaces);
}
const interfaces = opnsenseInterfacesSchema.parse(await responseInterfaces.json());
const returnValue: FirewallInterface[] = [];
const interfaceKeys = Object.keys(interfaces.interfaces);
for (const key of interfaceKeys) {
const inter = interfaces.interfaces[key];
if (!inter) continue;
const bytesTransmitted = inter["bytes transmitted"];
const bytesReceived = inter["bytes received"];
const receiveValue = parseInt(bytesReceived, 10);
const transmitValue = parseInt(bytesTransmitted, 10);
returnValue.push({
name: inter.name,
receive: receiveValue,
transmit: transmitValue,
});
}
await channel.pushAsync(returnValue);
return await channel.getSliceAsync(0, 1);
}
public async getFirewallMemoryAsync(): Promise<FirewallMemorySummary> {
const responseMemory = await fetchWithTrustedCertificatesAsync(
this.url("/api/diagnostics/system/systemResources"),
{
headers: {
Authorization: this.getAuthHeaders(),
},
},
);
if (!responseMemory.ok) {
throw new ResponseError(responseMemory);
}
const memory = opnsenseMemorySchema.parse(await responseMemory.json());
// Using parseInt for memoryTotal is normal, the api sends the total memory as a string
const memoryTotal = parseInt(memory.memory.total);
const memoryUsed = memory.memory.used;
const memoryPercent = (100 * memoryUsed) / memoryTotal;
return {
total: memoryTotal,
used: memoryUsed,
percent: memoryPercent,
};
}
public async getFirewallCpuAsync(): Promise<FirewallCpuSummary> {
const responseCpu = await fetchWithTrustedCertificatesAsync(this.url("/api/diagnostics/cpu_usage/stream"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!responseCpu.ok) {
throw new ResponseError(responseCpu);
}
if (!responseCpu.body) {
throw new Error("ReadableStream not supported in this environment.");
}
const reader = responseCpu.body.getReader();
const decoder = new TextDecoder();
let loopCounter = 0;
try {
while (loopCounter < 10) {
loopCounter++;
const result = await reader.read();
if (result.done) {
break;
}
if (!(result.value instanceof Uint8Array)) {
throw new Error("Received value is not an Uint8Array.");
}
const value: AllowSharedBufferSource = result.value;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (!line.startsWith("data:")) {
continue;
}
if (loopCounter < 2) {
continue;
}
const data = line.substring(5).trim();
const cpuValues = opnsenseCPUSchema.parse(JSON.parse(data));
return {
...cpuValues,
};
}
}
throw new Error("No valid CPU data found.");
} finally {
await reader.cancel();
}
}
}
@@ -0,0 +1,30 @@
import { z } from "zod";
// API documentation : https://docs.opnsense.org/development/api.html#core-api
export const opnsenseSystemSummarySchema = z.object({
name: z.string(),
versions: z.array(z.string()),
});
export const opnsenseMemorySchema = z.object({
memory: z.object({
total: z.string(),
used: z.number(),
}),
});
const interfaceSchema = z.object({
"bytes received": z.string(),
"bytes transmitted": z.string(),
name: z.string(),
});
export const opnsenseInterfacesSchema = z.object({
interfaces: z.record(interfaceSchema),
time: z.number(),
});
export const opnsenseCPUSchema = z.object({
total: z.number(),
});
@@ -1,7 +1,9 @@
import { parseStringPromise } from "xml2js";
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError } from "@homarr/common/server";
import { ImageProxy } from "@homarr/image-proxy";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
@@ -10,9 +12,10 @@ import { TestConnectionError } from "../base/test-connection/test-connection-err
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
import type { PlexResponse } from "./interface";
export class PlexIntegration extends Integration implements IMediaServerIntegration {
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
@@ -66,6 +69,99 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
return medias;
}
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
const token = super.getSecretValue("apiKey");
const machineIdentifier = await this.getMachineIdentifierAsync();
const response = await fetchWithTrustedCertificatesAsync(super.url("/library/recentlyAdded"), {
headers: {
"X-Plex-Token": token,
Accept: "application/json",
},
});
const json = await response.json();
const data = await recentlyAddedSchema.parseAsync(json);
const imageProxy = new ImageProxy();
const images =
data.MediaContainer.Metadata?.filter((item) => item.Image)
.flatMap((item) => [
{
mediaKey: item.key,
type: "poster",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
url: item.Image!.find((image) => image?.type === "coverPoster")?.url,
},
{
mediaKey: item.key,
type: "backdrop",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
url: item.Image!.find((image) => image?.type === "background")?.url,
},
])
.filter(
(image): image is { mediaKey: string; type: "poster" | "backdrop"; url: string } => image.url !== undefined,
) ?? [];
const proxiedImages = await Promise.all(
images.map(async (image) => {
const imageUrl = super.url(image.url as `/${string}`);
const proxiedImageUrl = await imageProxy
.createImageAsync(imageUrl.toString(), {
"X-Plex-Token": token,
})
.catch((error) => {
logger.debug(new Error("Failed to proxy image", { cause: error }));
return undefined;
});
return {
mediaKey: image.mediaKey,
type: image.type,
url: proxiedImageUrl,
};
}),
);
const media =
data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => {
return {
id: item.Media?.at(0)?.id.toString() ?? item.key,
type: mapType(item.type),
title: item.title,
subtitle: item.tagline,
description: item.summary,
releaseDate: item.originallyAvailableAt
? new Date(item.originallyAvailableAt)
: new Date(item.addedAt * 1000),
imageUrls: {
poster: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "poster")?.url,
backdrop: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "backdrop")?.url,
},
producer: item.studio,
rating: item.rating?.toFixed(1),
tags: item.Genre?.map((genre) => genre.tag) ?? [],
href: super
.url(`/web/index.html#!/server/${machineIdentifier}/details?key=${encodeURIComponent(item.key)}`)
.toString(),
length: item.duration ? Math.round(item.duration / 1000) : undefined,
};
}) ?? [];
return media;
}
private async getMachineIdentifierAsync(): Promise<string> {
const token = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/identity"), {
headers: {
"X-Plex-Token": token,
Accept: "application/json",
},
});
const data = await identitySchema.parseAsync(await response.json());
return data.MediaContainer.machineIdentifier;
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const token = super.getSecretValue("apiKey");
@@ -111,3 +207,67 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
}
}
}
// https://plexapi.dev/api-reference/library/get-recently-added
const recentlyAddedSchema = z.object({
MediaContainer: z.object({
Metadata: z
.array(
z.object({
key: z.string(),
studio: z.string().optional(),
type: z.string(), // For example "movie", "album"
title: z.string(),
summary: z.string().optional(),
duration: z.number().optional(),
addedAt: z.number(),
rating: z.number().optional(),
tagline: z.string().optional(),
originallyAvailableAt: z.string().optional(),
Media: z
.array(
z.object({
id: z.number(),
}),
)
.optional(),
Image: z
.array(
z
.object({
type: z.string(), // for example "coverPoster" or "background"
url: z.string(),
})
.optional(),
)
.optional(),
Genre: z
.array(
z.object({
tag: z.string(),
}),
)
.optional(),
}),
)
.optional(),
}),
});
// https://plexapi.dev/api-reference/server/get-server-identity
const identitySchema = z.object({
MediaContainer: z.object({
machineIdentifier: z.string(),
}),
});
const mapType = (type: string): "movie" | "tv" | "unknown" => {
switch (type) {
case "movie":
return "movie";
case "tv":
return "tv";
default:
return "unknown";
}
};
@@ -0,0 +1,109 @@
import type { RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { releasesResponseSchema } from "./quay-schemas";
const localLogger = logger.child({ module: "QuayIntegration" });
export class QuayIntegration extends Integration implements ReleasesProviderIntegration {
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await this.withHeadersAsync(async (headers) => {
return await input.fetchAsync(this.url("/api/v1/discovery"), {
headers,
});
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(
this.url(
`/api/v1/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?includeTags=true&includeStats=true`,
),
{
headers,
},
);
});
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
message: error.message,
},
};
} else {
const details = {
projectDescription: data.description,
};
const releasesProviderResponse = Object.entries(data.tags).reduce<ReleaseProviderResponse[]>((acc, [_, tag]) => {
if (!tag.name || !tag.last_modified) return acc;
acc.push({
latestRelease: tag.name,
latestReleaseAt: new Date(tag.last_modified),
releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`,
});
return acc;
}, []);
return getLatestRelease(releasesProviderResponse, repository, details);
}
}
}
@@ -0,0 +1,11 @@
import { z } from "zod";
export const releasesResponseSchema = z.object({
description: z.string().optional(),
tags: z.record(
z.object({
name: z.string(),
last_modified: z.string(),
}),
),
});
+3
View File
@@ -1,6 +1,7 @@
export * from "./interfaces/calendar/calendar-types";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
@@ -8,3 +9,5 @@ export * from "./base/searchable-integration";
export * from "./homeassistant/homeassistant-types";
export * from "./proxmox/proxmox-types";
export * from "./unifi-controller/unifi-controller-types";
export * from "./opnsense/opnsense-types";
export * from "./interfaces/media-releases";
+3 -4
View File
@@ -24,8 +24,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/env": "workspace:^0.1.0",
"ioredis": "5.6.1",
"@homarr/core": "workspace:^0.1.0",
"superjson": "2.2.2",
"winston": "3.17.0",
"zod": "^3.25.76"
@@ -34,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { createEnv } from "@homarr/env";
import { createEnv } from "@homarr/core/infrastructure/env";
import { logLevels } from "./constants";
+5 -3
View File
@@ -1,7 +1,9 @@
import { Redis } from "ioredis";
import superjson from "superjson";
import Transport from "winston-transport";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
const messageSymbol = Symbol.for("message");
const levelSymbol = Symbol.for("level");
@@ -10,7 +12,7 @@ const levelSymbol = Symbol.for("level");
// of the base functionality and `.exceptions.handle()`.
//
export class RedisTransport extends Transport {
private redis: Redis | null = null;
private redis: RedisClient | null = null;
/**
* Log the info to the Redis channel
@@ -21,7 +23,7 @@ export class RedisTransport extends Transport {
});
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= new Redis();
this.redis ??= createRedisClient();
this.redis
.publish(
+7 -7
View File
@@ -33,19 +33,19 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.1.3",
"@tabler/icons-react": "^3.34.0",
"@mantine/core": "^8.2.2",
"@tabler/icons-react": "^3.34.1",
"dayjs": "^1.11.13",
"next": "15.4.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.4.5",
"react": "19.1.1",
"react-dom": "19.1.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+5 -5
View File
@@ -24,15 +24,15 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"react": "19.1.0"
"@mantine/core": "^8.2.2",
"@mantine/hooks": "^8.2.2",
"react": "19.1.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+4 -4
View File
@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^8.1.3",
"@tabler/icons-react": "^3.34.0"
"@mantine/notifications": "^8.2.2",
"@tabler/icons-react": "^3.34.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+7 -7
View File
@@ -37,12 +37,12 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/core": "^8.2.2",
"@mantine/hooks": "^8.2.2",
"adm-zip": "0.5.16",
"next": "15.4.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.4.5",
"react": "19.1.1",
"react-dom": "19.1.1",
"superjson": "2.2.2",
"zod": "^3.25.76",
"zod-form-data": "^2.0.7"
@@ -52,7 +52,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "0.5.7",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+2 -2
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+4
View File
@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];
File renamed without changes.
@@ -1,12 +1,11 @@
{
"name": "@homarr/env",
"name": "@homarr/ping",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",
"./schemas": "./src/schemas.ts"
".": "./index.ts"
},
"typesVersions": {
"*": {
@@ -23,14 +22,15 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.8",
"zod": "^3.25.76"
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+37
View File
@@ -0,0 +1,37 @@
import { fetch } from "undici";
import { extractErrorMessage } from "@homarr/common";
import { LoggingAgent } from "@homarr/common/server";
import { logger } from "@homarr/log";
export const sendPingRequestAsync = async (url: string) => {
try {
const controller = new AbortController();
// 10 seconds timeout:
const timeoutId = setTimeout(() => controller.abort(), 10000);
const start = performance.now();
return await fetch(url, {
dispatcher: new LoggingAgent({
connect: {
rejectUnauthorized: false, // Ping should always work, even with untrusted certificates
},
}),
signal: controller.signal,
})
.finally(() => {
clearTimeout(timeoutId);
})
.then((response) => {
const end = performance.now();
const durationMs = end - start;
return { statusCode: response.status, durationMs };
});
} catch (error) {
logger.error(new Error(`Failed to send ping request to "${url}"`, { cause: error }));
return {
error: extractErrorMessage(error),
};
}
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"types": ["node"],
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+5 -3
View File
@@ -23,16 +23,18 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^",
"@homarr/core": "workspace:^",
"@homarr/db": "workspace:^",
"@homarr/definitions": "workspace:^",
"@homarr/log": "workspace:^",
"ioredis": "5.6.1",
"ioredis": "5.7.0",
"superjson": "2.2.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
"eslint": "^9.32.0",
"typescript": "^5.9.2"
}
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { LogLevel } from "@homarr/log/constants";
import type { LogLevel } from "@homarr/log/constants";
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
@@ -9,11 +9,16 @@ export {
createIntegrationOptionsChannel,
createWidgetOptionsChannel,
createChannelWithLatestAndEvents,
createChannelEventHistory,
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
} from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<
{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }
>("ping");
export const pingUrlChannel = createListChannel<string>("ping-url");
export const homeAssistantEntityState = createSubPubChannel<{
+1 -1
View File
@@ -232,7 +232,7 @@ export const createChannelEventHistory = <TData>(channelName: string, maxElement
if (length <= maxElements) {
return;
}
await getSetClient.ltrim(channelName, length - maxElements, length);
await getSetClient.ltrim(channelName, 0, maxElements - 1);
};
return {
+4 -3
View File
@@ -1,4 +1,5 @@
import { Redis } from "ioredis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
/**
* Creates a new Redis connection
@@ -7,8 +8,8 @@ import { Redis } from "ioredis";
export const createRedisConnection = () => {
if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) {
// Return null if we are in CI as we don't want to connect to Redis
return null as unknown as Redis;
return null as unknown as RedisClient;
}
return new Redis();
return createRedisClient();
};
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more