Compare commits

..
Author SHA1 Message Date
Meier Lukas 0513a8e6f5 feat(integrations): add suggestions to manage home page 2025-06-12 22:36:37 +02:00
251 changed files with 4893 additions and 13589 deletions

No files matched your search

+2 -10
View File
@@ -12,10 +12,6 @@ AUTH_SECRET="supersecret"
# or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Cron job API key is generated every time the container starts as it is required for communication between nextjs-api and tasks-api
# export CRON_JOB_API_KEY=$(openssl rand -base64 32)
# CRON_JOB_API_KEY="your-generated-api-key"
LOG_LEVEL='info'
# This is how you can use the sqlite driver:
@@ -34,15 +30,11 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# The below path can be used to store trusted certificates, it is not required and can be left empty.
# If it is empty, it will default to `/appdata/trusted-certificates` in production.
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# If it is used, please use the full path to the directory where the certificates are stored.
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# ENABLE_KUBERNETES=true
-5
View File
@@ -31,11 +31,6 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.28.0
- 1.27.0
- 1.26.0
- 1.25.0
- 1.24.0
- 1.23.0
- 1.22.0
- 1.21.0
-4
View File
@@ -6,10 +6,6 @@
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
{
matchPackagePatterns: ["^zod$", "^drizzle-zod$", "^zod-form-data$"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
@@ -0,0 +1,14 @@
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
name: "[Conventions] Semantic Commits"
on:
pull_request:
branches: [ dev ]
jobs:
build:
name: Conventional Commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
+1 -1
View File
@@ -1 +1 @@
22.17.0
22.16.0
-190
View File
@@ -1,193 +1,3 @@
## [1.28.1](https://github.com/homarr-labs/homarr/compare/v1.28.0...v1.28.1) (2025-07-12)
### Bug Fixes
* **deps:** update tiptap monorepo to v2.26.1 ([#3603](https://github.com/homarr-labs/homarr/issues/3603)) ([473a9f4](https://github.com/homarr-labs/homarr/commit/473a9f47f352a532128c1bf2c02490e8e4c6ca7c))
* **releases-widget:** fixed bug with changing a missing provider ([#3605](https://github.com/homarr-labs/homarr/issues/3605)) ([5ecc4db](https://github.com/homarr-labs/homarr/commit/5ecc4db5dabcfd4f953f3b436527d34ea9191eef))
## [1.28.0](https://github.com/homarr-labs/homarr/compare/v1.27.0...v1.28.0) (2025-07-11)
### Features
* **icon-color:** add icon color clear button ([#3593](https://github.com/homarr-labs/homarr/issues/3593)) ([5a8653d](https://github.com/homarr-labs/homarr/commit/5a8653d8500388d9392b150d62a56474c05843d7))
* **notebook:** add placeholder, redo and undo button ([#3564](https://github.com/homarr-labs/homarr/issues/3564)) ([3411354](https://github.com/homarr-labs/homarr/commit/3411354677fedec6ff5b5b85eb25eacc94308425))
* **ping:** ignore certificate error and show request duration ([#3546](https://github.com/homarr-labs/homarr/issues/3546)) ([1eb4731](https://github.com/homarr-labs/homarr/commit/1eb47311fa81184bd67830d0669d9f87d9922e70))
* **releases-widget:** define providers as integrations ([#3253](https://github.com/homarr-labs/homarr/issues/3253)) ([5d8126d](https://github.com/homarr-labs/homarr/commit/5d8126d71eea61af9c023895cb911d24544f8066))
### Bug Fixes
* **bookmarks:** wrong widget icon ([#3572](https://github.com/homarr-labs/homarr/issues/3572)) ([fb57d46](https://github.com/homarr-labs/homarr/commit/fb57d46b573cc9798469540f4823cbf73afd21a5))
* **deps:** update dependency @ianvs/prettier-plugin-sort-imports to ^4.5.0 ([#3585](https://github.com/homarr-labs/homarr/issues/3585)) ([ee286c9](https://github.com/homarr-labs/homarr/commit/ee286c95563c99297b5419c005550936ca96757b))
* **deps:** update dependency @ianvs/prettier-plugin-sort-imports to ^4.5.1 ([#3586](https://github.com/homarr-labs/homarr/issues/3586)) ([7e92f1b](https://github.com/homarr-labs/homarr/commit/7e92f1bafadf1188ee3ddc9d2dc9904f103aab4e))
* **deps:** update dependency dotenv to ^17.1.0 ([#3567](https://github.com/homarr-labs/homarr/issues/3567)) ([a369beb](https://github.com/homarr-labs/homarr/commit/a369beb248e2cac1f1dcf32421efb48f241700a6))
* **deps:** update dependency dotenv to ^17.2.0 ([#3578](https://github.com/homarr-labs/homarr/issues/3578)) ([1724f9d](https://github.com/homarr-labs/homarr/commit/1724f9d26c5761e3195d336eb451dd47855266a4))
* **deps:** update dependency ldapts to v8.0.5 ([#3559](https://github.com/homarr-labs/homarr/issues/3559)) ([8b9832b](https://github.com/homarr-labs/homarr/commit/8b9832b2b81dcc1a4f9081755defe44fc0f38a1d))
* **deps:** update dependency mysql2 to v3.14.2 ([#3592](https://github.com/homarr-labs/homarr/issues/3592)) ([d68b80e](https://github.com/homarr-labs/homarr/commit/d68b80e6be38366860de84778a45e5a43aae06eb))
* **deps:** update dependency node-cron to ^4.2.1 ([#3590](https://github.com/homarr-labs/homarr/issues/3590)) ([e21b236](https://github.com/homarr-labs/homarr/commit/e21b236d37f076654768e88bc7ef139a3e51abbc))
* **deps:** update dependency swagger-ui-react to ^5.26.1 ([#3554](https://github.com/homarr-labs/homarr/issues/3554)) ([12f4e90](https://github.com/homarr-labs/homarr/commit/12f4e90e440a8ea1708a2a9ef64a851ef977bd6c))
* **deps:** update dependency swagger-ui-react to ^5.26.2 ([#3558](https://github.com/homarr-labs/homarr/issues/3558)) ([57be43f](https://github.com/homarr-labs/homarr/commit/57be43f51b7ef306b962299789194de68ec9e8ea))
* **deps:** update dependency typescript-eslint to ^8.36.0 ([#3563](https://github.com/homarr-labs/homarr/issues/3563)) ([7c0a742](https://github.com/homarr-labs/homarr/commit/7c0a7428d85d11f3d2226d809ba04671ca713b72))
* **deps:** update dependency zod to ^3.25.75 ([#3555](https://github.com/homarr-labs/homarr/issues/3555)) ([e9a35ba](https://github.com/homarr-labs/homarr/commit/e9a35ba811bd1807d63c5f0c760d470b20f4410f))
* **deps:** update dependency zod to ^3.25.76 ([#3569](https://github.com/homarr-labs/homarr/issues/3569)) ([c793bf1](https://github.com/homarr-labs/homarr/commit/c793bf1982c3a484176860cebad80c721acd597d))
* **deps:** update dependency zod-validation-error to ^3.5.3 ([#3600](https://github.com/homarr-labs/homarr/issues/3600)) ([9020440](https://github.com/homarr-labs/homarr/commit/9020440193595a5b437464957d9a3745fb200c93))
* **deps:** update mantine monorepo to ^8.1.3 ([#3560](https://github.com/homarr-labs/homarr/issues/3560)) ([1fe3450](https://github.com/homarr-labs/homarr/commit/1fe34505550f401be97c9d84c383ea70bf39b937))
* **deps:** update tanstack-query monorepo to ^5.82.0 ([#3577](https://github.com/homarr-labs/homarr/issues/3577)) ([90f6a2d](https://github.com/homarr-labs/homarr/commit/90f6a2dd014a73cd0131781b6db56a1056e563b6))
* **deps:** update tanstack-query monorepo to ^5.83.0 ([#3599](https://github.com/homarr-labs/homarr/issues/3599)) ([bd8ac62](https://github.com/homarr-labs/homarr/commit/bd8ac62c7af034525803410751bfd5ab72094f45))
* **deps:** update testcontainers-node monorepo to ^11.1.0 ([#3550](https://github.com/homarr-labs/homarr/issues/3550)) ([b0bdf28](https://github.com/homarr-labs/homarr/commit/b0bdf28016bae2ccfab24cab6df5b4846d1a8f48))
* **deps:** update testcontainers-node monorepo to ^11.2.0 ([#3556](https://github.com/homarr-labs/homarr/issues/3556)) ([824f443](https://github.com/homarr-labs/homarr/commit/824f443949e75ec3f3edacb9093faefbdec47ef4))
* **deps:** update testcontainers-node monorepo to ^11.2.1 ([#3562](https://github.com/homarr-labs/homarr/issues/3562)) ([5d394c8](https://github.com/homarr-labs/homarr/commit/5d394c849f5f47b47c595f73763e89d0a5d6247a))
* **deps:** update tiptap monorepo to v2.25.0 ([#3552](https://github.com/homarr-labs/homarr/issues/3552)) ([430dc58](https://github.com/homarr-labs/homarr/commit/430dc580682cf0885fcd0ef2b7b81a0dfdf93425))
* **deps:** update tiptap monorepo to v2.25.1 ([#3591](https://github.com/homarr-labs/homarr/issues/3591)) ([661afa7](https://github.com/homarr-labs/homarr/commit/661afa72be96c629cad4ab7f6007214b54d8ef6a))
* **deps:** update tiptap monorepo to v2.26.0 ([#3596](https://github.com/homarr-labs/homarr/issues/3596)) ([df5b6fc](https://github.com/homarr-labs/homarr/commit/df5b6fcb6a6215f5c1cf92e034e27f6e32405b3e))
* unable to change certificate path for running from source ([#3582](https://github.com/homarr-labs/homarr/issues/3582)) ([f1143e5](https://github.com/homarr-labs/homarr/commit/f1143e54a06e00a348e37b5633fad4cb6390cff7))
## [1.27.0](https://github.com/homarr-labs/homarr/compare/v1.26.0...v1.27.0) (2025-07-04)
### Features
* **integrations:** add mock integration ([#3505](https://github.com/homarr-labs/homarr/issues/3505)) ([58d5b14](https://github.com/homarr-labs/homarr/commit/58d5b14c516583e79f9079899dfe6544acdc58f7))
* **tasks:** allow management of job intervals and disabling them ([#3408](https://github.com/homarr-labs/homarr/issues/3408)) ([9398dd9](https://github.com/homarr-labs/homarr/commit/9398dd983cce00959af2a4885e92da6acfe2f57e))
### Bug Fixes
* broken lockfile ([308d235](https://github.com/homarr-labs/homarr/commit/308d235fa9db658fb7c3f5a183f01c4f48f0546f))
* **deps:** update dependency better-sqlite3 to ^12.2.0 ([#3503](https://github.com/homarr-labs/homarr/issues/3503)) ([c22a963](https://github.com/homarr-labs/homarr/commit/c22a963342c5613089b0a1a4034379cc80425602))
* **deps:** update dependency dotenv to ^16.6.1 ([#3494](https://github.com/homarr-labs/homarr/issues/3494)) ([b880de8](https://github.com/homarr-labs/homarr/commit/b880de8a794a879a0c2b38cc46cb5412eedbb44e))
* **deps:** update dependency dotenv to v17 ([#3499](https://github.com/homarr-labs/homarr/issues/3499)) ([fe87d97](https://github.com/homarr-labs/homarr/commit/fe87d978c4335a700e2859c648bf997a7c323a31))
* **deps:** update dependency ldapts to v8.0.4 ([#3509](https://github.com/homarr-labs/homarr/issues/3509)) ([8d53801](https://github.com/homarr-labs/homarr/commit/8d53801f67b5458b5eb60b4ebcd6a923a60b7e5f))
* **deps:** update dependency next-intl to v4.3.2 ([#3520](https://github.com/homarr-labs/homarr/issues/3520)) ([6fda939](https://github.com/homarr-labs/homarr/commit/6fda939a2d94120998eb1365134652eb00602937))
* **deps:** update dependency next-intl to v4.3.3 ([#3521](https://github.com/homarr-labs/homarr/issues/3521)) ([ab9165f](https://github.com/homarr-labs/homarr/commit/ab9165f2cd885069c7389d62db187c3be240fd72))
* **deps:** update dependency next-intl to v4.3.4 ([#3528](https://github.com/homarr-labs/homarr/issues/3528)) ([6005784](https://github.com/homarr-labs/homarr/commit/60057842842d393b8d9c5404d6c2ae85bc82b27c))
* **deps:** update dependency node-cron to ^4.2.0 ([#3518](https://github.com/homarr-labs/homarr/issues/3518)) ([0f94ac4](https://github.com/homarr-labs/homarr/commit/0f94ac456eeacbc03183d635ea4c57b224cf8537))
* **deps:** update dependency postcss-preset-mantine to ^1.18.0 ([#3510](https://github.com/homarr-labs/homarr/issues/3510)) ([01d0ffb](https://github.com/homarr-labs/homarr/commit/01d0ffb0d143a77c94c977873ad04f661c280eb7))
* **deps:** update dependency swagger-ui-react to ^5.25.4 ([#3523](https://github.com/homarr-labs/homarr/issues/3523)) ([6c94d1a](https://github.com/homarr-labs/homarr/commit/6c94d1a10c495850958b6399a302eab582e9d40b))
* **deps:** update dependency swagger-ui-react to ^5.26.0 ([#3527](https://github.com/homarr-labs/homarr/issues/3527)) ([9da66f5](https://github.com/homarr-labs/homarr/commit/9da66f5b1db88c196dbb6866f920f1a176305667))
* **deps:** update dependency trpc-to-openapi to ^2.3.2 ([#3502](https://github.com/homarr-labs/homarr/issues/3502)) ([2ebc9f9](https://github.com/homarr-labs/homarr/commit/2ebc9f969b5a0c753622421cb5f0a81873ad4ae9))
* **deps:** update dependency typescript-eslint to ^8.35.1 ([#3513](https://github.com/homarr-labs/homarr/issues/3513)) ([17fe293](https://github.com/homarr-labs/homarr/commit/17fe293a7c9e6dbc83bc8f585a54a8fdd8640973))
* **deps:** update dependency ws to ^8.18.3 ([#3501](https://github.com/homarr-labs/homarr/issues/3501)) ([ed077d6](https://github.com/homarr-labs/homarr/commit/ed077d63db9a197510e8e139d40813a3854f39e9))
* **deps:** update dependency zod to ^3.25.68 ([#3529](https://github.com/homarr-labs/homarr/issues/3529)) ([b375ec9](https://github.com/homarr-labs/homarr/commit/b375ec977e757bed14b6fb6a04a5b30fe481eaf9))
* **deps:** update dependency zod to ^3.25.69 ([#3531](https://github.com/homarr-labs/homarr/issues/3531)) ([915eec1](https://github.com/homarr-labs/homarr/commit/915eec1b5e6d7383d36f2e67d86472c6a2932a9b))
* **deps:** update dependency zod to ^3.25.71 ([#3534](https://github.com/homarr-labs/homarr/issues/3534)) ([95c8aad](https://github.com/homarr-labs/homarr/commit/95c8aadb0c98f116ff3e256c770636267da82fdc))
* **deps:** update dependency zod to ^3.25.72 ([#3535](https://github.com/homarr-labs/homarr/issues/3535)) ([0858a42](https://github.com/homarr-labs/homarr/commit/0858a42b8137f003fb7500f0dbccbb9413a6c470))
* **deps:** update dependency zod to ^3.25.73 ([#3543](https://github.com/homarr-labs/homarr/issues/3543)) ([7061a03](https://github.com/homarr-labs/homarr/commit/7061a03b2ef90c51754a4e39b22742c34e5ebc80))
* **deps:** update dependency zod to ^3.25.74 ([#3547](https://github.com/homarr-labs/homarr/issues/3547)) ([b057059](https://github.com/homarr-labs/homarr/commit/b057059ff1fb8fcd29893000d30ddcba09f2b775))
* **deps:** update nextjs monorepo to v15.3.5 ([#3541](https://github.com/homarr-labs/homarr/issues/3541)) ([d5181d0](https://github.com/homarr-labs/homarr/commit/d5181d0c0e1f25a119a9e91a9544a8f76e0b0901))
* **deps:** update tanstack-query monorepo to ^5.81.5 ([#3495](https://github.com/homarr-labs/homarr/issues/3495)) ([5346b96](https://github.com/homarr-labs/homarr/commit/5346b9698444a503e432ee4b0679e408e2c44d96))
* **deps:** update tiptap monorepo to v2.23.1 ([#3516](https://github.com/homarr-labs/homarr/issues/3516)) ([0a0e7f9](https://github.com/homarr-labs/homarr/commit/0a0e7f9602429003d45b99fade5f1ad121aa67f3))
* **deps:** update tiptap monorepo to v2.24.0 ([#3532](https://github.com/homarr-labs/homarr/issues/3532)) ([0e1f693](https://github.com/homarr-labs/homarr/commit/0e1f6934f9755503ce289b71549b6737171e1c57))
* **deps:** update tiptap monorepo to v2.24.1 ([#3536](https://github.com/homarr-labs/homarr/issues/3536)) ([63e2ace](https://github.com/homarr-labs/homarr/commit/63e2acefbdf09d4aa7e5a9522cfb4b0c89fb66e9))
* **deps:** update tiptap monorepo to v2.24.2 ([#3544](https://github.com/homarr-labs/homarr/issues/3544)) ([66f663a](https://github.com/homarr-labs/homarr/commit/66f663aad4b35d0cbfb1ffd60545fd0272b70990))
## [1.26.0](https://github.com/homarr-labs/homarr/compare/v1.25.0...v1.26.0) (2025-06-27)
### Features
* add ntfy integration ([#2900](https://github.com/homarr-labs/homarr/issues/2900)) ([e110a84](https://github.com/homarr-labs/homarr/commit/e110a84fdd5fa2468786e1505043d8a7dc5cfa1b))
### Bug Fixes
* **deps:** update dependency @auth/core to ^0.40.0 ([#3448](https://github.com/homarr-labs/homarr/issues/3448)) ([2c7f5de](https://github.com/homarr-labs/homarr/commit/2c7f5de19271f7c65a9979b37d9602bb84c68564))
* **deps:** update dependency @auth/drizzle-adapter to ^1.10.0 ([#3449](https://github.com/homarr-labs/homarr/issues/3449)) ([7b8dc16](https://github.com/homarr-labs/homarr/commit/7b8dc1670d0c8473524f71e1ee872e6ea7d2e0ce))
* **deps:** update dependency @tiptap/extension-highlight to v2.22.1 ([#3433](https://github.com/homarr-labs/homarr/issues/3433)) ([f6039a5](https://github.com/homarr-labs/homarr/commit/f6039a5976da70388cb32eecfbf57ac02e6fc050))
* **deps:** update dependency better-sqlite3 to ^12.1.0 ([#3462](https://github.com/homarr-labs/homarr/issues/3462)) ([fa421b3](https://github.com/homarr-labs/homarr/commit/fa421b3778c00b30b68ceadfb370803eba1a72f2))
* **deps:** update dependency better-sqlite3 to ^12.1.1 ([#3470](https://github.com/homarr-labs/homarr/issues/3470)) ([eadec21](https://github.com/homarr-labs/homarr/commit/eadec212369b8ee81eec00b3ecbd40d1c90972d7))
* **deps:** update dependency better-sqlite3 to v12 ([#3441](https://github.com/homarr-labs/homarr/issues/3441)) ([95be039](https://github.com/homarr-labs/homarr/commit/95be0391a651b59a2611ca5ffa7825c6b337a996))
* **deps:** update dependency dotenv to ^16.6.0 ([#3484](https://github.com/homarr-labs/homarr/issues/3484)) ([7477d02](https://github.com/homarr-labs/homarr/commit/7477d02cadd88608f33edf0836f7d7e164800dde))
* **deps:** update dependency drizzle-kit to ^0.31.2 ([#3455](https://github.com/homarr-labs/homarr/issues/3455)) ([8ffbbd6](https://github.com/homarr-labs/homarr/commit/8ffbbd6bf9bb1c329943ae5033029eed115b43dd))
* **deps:** update dependency drizzle-kit to ^0.31.3 ([#3482](https://github.com/homarr-labs/homarr/issues/3482)) ([7d0ac46](https://github.com/homarr-labs/homarr/commit/7d0ac46753b1cf6bee1f53297b0011e4e86fdffd))
* **deps:** update dependency drizzle-kit to ^0.31.4 ([#3490](https://github.com/homarr-labs/homarr/issues/3490)) ([670de0f](https://github.com/homarr-labs/homarr/commit/670de0f5fbb9faf950fdb38e4f4cfb57f21257c4))
* **deps:** update dependency eslint-plugin-import to ^2.32.0 ([#3430](https://github.com/homarr-labs/homarr/issues/3430)) ([a7052bb](https://github.com/homarr-labs/homarr/commit/a7052bb16f52ccb47d2c8ac205fdf01cf8e8c01e))
* **deps:** update dependency ldapts to v8.0.2 ([#3481](https://github.com/homarr-labs/homarr/issues/3481)) ([114de40](https://github.com/homarr-labs/homarr/commit/114de40634b501ecea7be8d9ae1d2a19686845c1))
* **deps:** update dependency maria2 to ^0.4.1 ([#3456](https://github.com/homarr-labs/homarr/issues/3456)) ([4e892ef](https://github.com/homarr-labs/homarr/commit/4e892efab7f456b93e993c2c3cb6068f06ea1e4f))
* **deps:** update dependency next-auth to v5.0.0-beta.29 ([#3447](https://github.com/homarr-labs/homarr/issues/3447)) ([da2d22b](https://github.com/homarr-labs/homarr/commit/da2d22b1b9b87fe7aa1cbac76909af2d2ef57d3f))
* **deps:** update dependency next-intl to v4.2.0 ([#3457](https://github.com/homarr-labs/homarr/issues/3457)) ([86d4585](https://github.com/homarr-labs/homarr/commit/86d4585035c5604a219fd0be404104f62a0be0b2))
* **deps:** update dependency next-intl to v4.3.0 ([#3458](https://github.com/homarr-labs/homarr/issues/3458)) ([ded3993](https://github.com/homarr-labs/homarr/commit/ded39930ca1d8c4868f9541c7fad18a234ceeb3f))
* **deps:** update dependency next-intl to v4.3.1 ([#3465](https://github.com/homarr-labs/homarr/issues/3465)) ([5d96744](https://github.com/homarr-labs/homarr/commit/5d9674412e6e99af1fb1fce7143ce4b6717ff29b))
* **deps:** update dependency prettier to ^3.6.0 ([#3452](https://github.com/homarr-labs/homarr/issues/3452)) ([07ce1c1](https://github.com/homarr-labs/homarr/commit/07ce1c1a2d197f981818a9b1e567102f0772271d))
* **deps:** update dependency prettier to ^3.6.1 ([#3469](https://github.com/homarr-labs/homarr/issues/3469)) ([34faa9e](https://github.com/homarr-labs/homarr/commit/34faa9e58ce061c4c99d207cfc6c56cbb7045c03))
* **deps:** update dependency prettier to ^3.6.2 ([#3489](https://github.com/homarr-labs/homarr/issues/3489)) ([60786bf](https://github.com/homarr-labs/homarr/commit/60786bf9ac81ce13b6304f5284fb6049e2d7d7dc))
* **deps:** update dependency recharts to ^2.15.4 ([#3429](https://github.com/homarr-labs/homarr/issues/3429)) ([caedac4](https://github.com/homarr-labs/homarr/commit/caedac490ed3aaf8f6a815d799ba9fafe9de18a2))
* **deps:** update dependency swagger-ui-react to ^5.25.3 ([#3479](https://github.com/homarr-labs/homarr/issues/3479)) ([1cd9fa8](https://github.com/homarr-labs/homarr/commit/1cd9fa8e82eef1f69be06855d076a364883914df))
* **deps:** update dependency typescript-eslint to ^8.35.0 ([#3460](https://github.com/homarr-labs/homarr/issues/3460)) ([1813d44](https://github.com/homarr-labs/homarr/commit/1813d4435e08f6a800ee76561e66e6371b30ec80))
* **deps:** update dependency undici to v7.11.0 ([#3486](https://github.com/homarr-labs/homarr/issues/3486)) ([01fdd79](https://github.com/homarr-labs/homarr/commit/01fdd7955906c661a620b8fac148693dc4632ebd))
* **deps:** update mantine monorepo to ^8.1.2 ([#3471](https://github.com/homarr-labs/homarr/issues/3471)) ([5e6dfb3](https://github.com/homarr-labs/homarr/commit/5e6dfb3a5c2d456fa20fbcd07f98c01d0ab7c4b7))
* **deps:** update tanstack-query monorepo to ^5.80.12 ([#3436](https://github.com/homarr-labs/homarr/issues/3436)) ([86608f5](https://github.com/homarr-labs/homarr/commit/86608f5a9ffe9ea2c57a9191175994a24eea5dc7))
* **deps:** update tanstack-query monorepo to ^5.81.0 ([#3437](https://github.com/homarr-labs/homarr/issues/3437)) ([e6f05f9](https://github.com/homarr-labs/homarr/commit/e6f05f9d94912ef5f38ad4ab222e20d433c627eb))
* **deps:** update tanstack-query monorepo to ^5.81.1 ([#3440](https://github.com/homarr-labs/homarr/issues/3440)) ([b8b71da](https://github.com/homarr-labs/homarr/commit/b8b71da291541b714e053ea1bda4e49053261936))
* **deps:** update tanstack-query monorepo to ^5.81.2 ([#3442](https://github.com/homarr-labs/homarr/issues/3442)) ([3f0e9c3](https://github.com/homarr-labs/homarr/commit/3f0e9c37385ae23370618f1a11870642a9b3d030))
* **deps:** update tanstack-query monorepo to ^5.81.4 ([#3493](https://github.com/homarr-labs/homarr/issues/3493)) ([a737331](https://github.com/homarr-labs/homarr/commit/a737331d207abfd4ab8a8869c70257b5f5d50d5a))
* **deps:** update tiptap monorepo to v2.22.1 ([#3432](https://github.com/homarr-labs/homarr/issues/3432)) ([8ccbe7f](https://github.com/homarr-labs/homarr/commit/8ccbe7ff52eff742031c528bcc1e15916ba1fe81))
* **deps:** update tiptap monorepo to v2.22.2 ([#3438](https://github.com/homarr-labs/homarr/issues/3438)) ([f677bd6](https://github.com/homarr-labs/homarr/commit/f677bd6b12630130d8cf95a4f2462e4bab1226db))
* **deps:** update tiptap monorepo to v2.22.3 ([#3443](https://github.com/homarr-labs/homarr/issues/3443)) ([add73a1](https://github.com/homarr-labs/homarr/commit/add73a1a3c19e0efd2d417463e4f4d72a9fd1c02))
* **deps:** update tiptap monorepo to v2.23.0 ([#3487](https://github.com/homarr-labs/homarr/issues/3487)) ([b027f3a](https://github.com/homarr-labs/homarr/commit/b027f3a18084743b452dd35ff8e0fa30f59acd85))
* **deps:** update trpc monorepo to ^11.4.3 ([#3491](https://github.com/homarr-labs/homarr/issues/3491)) ([d326f3b](https://github.com/homarr-labs/homarr/commit/d326f3b4b3f5fc342404a12b25380d9dd6cd924a))
* **jellyseerr:** add missing deleted media status ([#3439](https://github.com/homarr-labs/homarr/issues/3439)) ([6b474c3](https://github.com/homarr-labs/homarr/commit/6b474c3df470450598b6b9472bf9f52740b8ed21))
## [1.25.0](https://github.com/homarr-labs/homarr/compare/v1.24.0...v1.25.0) (2025-06-20)
### Features
* **integrations:** add support for self-signed-cert-in-chain request error ([#3399](https://github.com/homarr-labs/homarr/issues/3399)) ([e6cb35b](https://github.com/homarr-labs/homarr/commit/e6cb35b7c2dc45349a9cf0819aa21228aea03e26))
### Bug Fixes
* **deps:** update dependency node-cron to ^4.1.1 ([#3427](https://github.com/homarr-labs/homarr/issues/3427)) ([f6a99ff](https://github.com/homarr-labs/homarr/commit/f6a99ffedd690c01e18f445ade6343f1ca476b27))
* **deps:** update dependency swagger-ui-react to ^5.24.2 ([#3405](https://github.com/homarr-labs/homarr/issues/3405)) ([bdca920](https://github.com/homarr-labs/homarr/commit/bdca92036a3dde7f655735c305e55c5c15b63440))
* **deps:** update dependency swagger-ui-react to ^5.25.2 ([#3420](https://github.com/homarr-labs/homarr/issues/3420)) ([02cd7a5](https://github.com/homarr-labs/homarr/commit/02cd7a50dad9e954cfd569b0f1efcc4fe4fd5da4))
* **deps:** update dependency tsdav to ^2.1.5 ([#3414](https://github.com/homarr-labs/homarr/issues/3414)) ([6bcfe78](https://github.com/homarr-labs/homarr/commit/6bcfe789561c518e7ce6c78a9be95412a50f04dd))
* **deps:** update dependency typescript-eslint to ^8.34.1 ([#3409](https://github.com/homarr-labs/homarr/issues/3409)) ([637bfb8](https://github.com/homarr-labs/homarr/commit/637bfb8b47b7dc6c70163bd481c47bca9bd93d55))
* **deps:** update dependency zod to ^3.25.65 ([#3410](https://github.com/homarr-labs/homarr/issues/3410)) ([a8c9c07](https://github.com/homarr-labs/homarr/commit/a8c9c075f08bca4b86307e98156cae7167ef6ab1))
* **deps:** update dependency zod to ^3.25.67 ([#3413](https://github.com/homarr-labs/homarr/issues/3413)) ([56ca8b7](https://github.com/homarr-labs/homarr/commit/56ca8b7b0b1c0187457c085af1c7f4504d72ed9a))
* **deps:** update dependency zod-validation-error to ^3.5.1 ([#3406](https://github.com/homarr-labs/homarr/issues/3406)) ([adbef25](https://github.com/homarr-labs/homarr/commit/adbef252c61c29fbbc8c7b0e325a0e9d69bb3191))
* **deps:** update dependency zod-validation-error to ^3.5.2 ([#3417](https://github.com/homarr-labs/homarr/issues/3417)) ([430f671](https://github.com/homarr-labs/homarr/commit/430f6714345b5b90859fd3302f76fd3d14956148))
* **deps:** update mantine monorepo to ^8.1.1 ([#3404](https://github.com/homarr-labs/homarr/issues/3404)) ([f97a261](https://github.com/homarr-labs/homarr/commit/f97a2619734dcf3b326c69587fa2f4e170cb9c88))
* **deps:** update nextjs monorepo to v15.3.4 ([#3421](https://github.com/homarr-labs/homarr/issues/3421)) ([3759a98](https://github.com/homarr-labs/homarr/commit/3759a9831c6fe54b0b0eb0e101c8eb912b752b41))
* **deps:** update tanstack-query monorepo to ^5.80.10 ([#3422](https://github.com/homarr-labs/homarr/issues/3422)) ([7c58e8a](https://github.com/homarr-labs/homarr/commit/7c58e8ac9bdb2868637648b43b7965189421b295))
* **deps:** update tiptap monorepo to v2.14.1 ([#3423](https://github.com/homarr-labs/homarr/issues/3423)) ([9e4b463](https://github.com/homarr-labs/homarr/commit/9e4b4635bc582e4d92821844d58fdb3d057f7fec))
* **deps:** update tiptap monorepo to v2.22.0 ([#3425](https://github.com/homarr-labs/homarr/issues/3425)) ([ec4dde6](https://github.com/homarr-labs/homarr/commit/ec4dde64fdd4302b091a0255ec1bcdc6b2caca89))
* **deps:** update trpc monorepo to ^11.4.2 ([#3419](https://github.com/homarr-labs/homarr/issues/3419)) ([8e428e2](https://github.com/homarr-labs/homarr/commit/8e428e20a764c54c15fc44b57f85fa684cfbc00e))
* **downloads:** not updating automatically ([#3412](https://github.com/homarr-labs/homarr/issues/3412)) ([b5ef7e5](https://github.com/homarr-labs/homarr/commit/b5ef7e5194a828abfddcac5eb492910de5f1f7d8))
* **widget-integration-select:** fixed error about `<p>` being descendant of `<p>` ([#3418](https://github.com/homarr-labs/homarr/issues/3418)) ([73fff02](https://github.com/homarr-labs/homarr/commit/73fff02ad0a3a710aa39cda7dccf9206569c2879))
## [1.24.0](https://github.com/homarr-labs/homarr/compare/v1.23.0...v1.24.0) (2025-06-13)
### Features
* reduce cron job intervals ([#3235](https://github.com/homarr-labs/homarr/issues/3235)) ([f67702e](https://github.com/homarr-labs/homarr/commit/f67702e1d32954b4f6db28c02b1a25e56122d779))
### Bug Fixes
* csp prevents external videos ([#3372](https://github.com/homarr-labs/homarr/issues/3372)) ([c59cded](https://github.com/homarr-labs/homarr/commit/c59cdeda47a504069c09634dafe36a9bc80c922f))
* **deps:** update dependency @t3-oss/env-nextjs to ^0.13.7 ([#3357](https://github.com/homarr-labs/homarr/issues/3357)) ([22f0cd8](https://github.com/homarr-labs/homarr/commit/22f0cd885e6b3f0cd15daa60840554a545eb2532))
* **deps:** update dependency @t3-oss/env-nextjs to ^0.13.8 ([#3364](https://github.com/homarr-labs/homarr/issues/3364)) ([ab3ee3b](https://github.com/homarr-labs/homarr/commit/ab3ee3bac186913cbc76c1381c90b3c90a447185))
* **deps:** update dependency fast-xml-parser to ^5.2.5 ([#3353](https://github.com/homarr-labs/homarr/issues/3353)) ([9e86de6](https://github.com/homarr-labs/homarr/commit/9e86de6e1781ef2c088f83af1ba341baecb496da))
* **deps:** update dependency glob to ^11.0.3 ([#3391](https://github.com/homarr-labs/homarr/issues/3391)) ([f16efac](https://github.com/homarr-labs/homarr/commit/f16efac05a17b77303f4c41b8910d491d237add2))
* **deps:** update dependency sass to ^1.89.2 ([#3362](https://github.com/homarr-labs/homarr/issues/3362)) ([b1a559d](https://github.com/homarr-labs/homarr/commit/b1a559d80161f59114ba3098005c3334662987eb))
* **deps:** update dependency swagger-ui-react to ^5.24.1 ([#3368](https://github.com/homarr-labs/homarr/issues/3368)) ([fa52b55](https://github.com/homarr-labs/homarr/commit/fa52b5582880075cd76826641547ccbf42b56f7a))
* **deps:** update dependency tsx to v4.20.0 ([#3375](https://github.com/homarr-labs/homarr/issues/3375)) ([3d07306](https://github.com/homarr-labs/homarr/commit/3d07306e7d3bf90db7f7c02d68550a137c5554bc))
* **deps:** update dependency tsx to v4.20.1 ([#3380](https://github.com/homarr-labs/homarr/issues/3380)) ([e7652ea](https://github.com/homarr-labs/homarr/commit/e7652ea4eb1ddaf8373c02abfcdb5440bf55c236))
* **deps:** update dependency tsx to v4.20.2 ([#3387](https://github.com/homarr-labs/homarr/issues/3387)) ([79519ec](https://github.com/homarr-labs/homarr/commit/79519ec0ebf4ff101fa1cd395f974e023d1af3c3))
* **deps:** update dependency tsx to v4.20.3 ([#3393](https://github.com/homarr-labs/homarr/issues/3393)) ([c9097a9](https://github.com/homarr-labs/homarr/commit/c9097a95e2072ed619e93301f8d4644aaadce2dd))
* **deps:** update dependency typescript-eslint to ^8.34.0 ([#3359](https://github.com/homarr-labs/homarr/issues/3359)) ([f6da091](https://github.com/homarr-labs/homarr/commit/f6da0915773dd60b40475f258d90e5f3dbdb6729))
* **deps:** update dependency zod to ^3.25.56 ([#3340](https://github.com/homarr-labs/homarr/issues/3340)) ([aafd883](https://github.com/homarr-labs/homarr/commit/aafd883c3250ddf3438877d82b4b63a6a15eadda))
* **deps:** update dependency zod to ^3.25.57 ([#3367](https://github.com/homarr-labs/homarr/issues/3367)) ([e7a9797](https://github.com/homarr-labs/homarr/commit/e7a9797484e9e2cd595d31e6db4cdf63e25e3b82))
* **deps:** update dependency zod to ^3.25.58 ([#3373](https://github.com/homarr-labs/homarr/issues/3373)) ([f37fc0e](https://github.com/homarr-labs/homarr/commit/f37fc0edfee45386b3469bac11c91e4f17c83673))
* **deps:** update dependency zod to ^3.25.60 ([#3378](https://github.com/homarr-labs/homarr/issues/3378)) ([7388c90](https://github.com/homarr-labs/homarr/commit/7388c907a57c7999a48118d96a3f34d2c3656f79))
* **deps:** update dependency zod to ^3.25.61 ([#3379](https://github.com/homarr-labs/homarr/issues/3379)) ([863f935](https://github.com/homarr-labs/homarr/commit/863f935ef4c27f87a632a5e0b376fe088f4e8a04))
* **deps:** update dependency zod to ^3.25.62 ([#3382](https://github.com/homarr-labs/homarr/issues/3382)) ([0b200dc](https://github.com/homarr-labs/homarr/commit/0b200dc9e2239060ddf2670f8508f68e624e821e))
* **deps:** update dependency zod to ^3.25.63 ([#3386](https://github.com/homarr-labs/homarr/issues/3386)) ([153c2ef](https://github.com/homarr-labs/homarr/commit/153c2ef8a0098cbe2d998c98a25a0c5880f0da5d))
* **deps:** update dependency zod to ^3.25.64 ([#3392](https://github.com/homarr-labs/homarr/issues/3392)) ([5dc0ee8](https://github.com/homarr-labs/homarr/commit/5dc0ee8842dbd4bde468c5762cf5dc0d73958242))
* **deps:** update dependency zod-validation-error to ^3.5.0 ([#3388](https://github.com/homarr-labs/homarr/issues/3388)) ([747d034](https://github.com/homarr-labs/homarr/commit/747d034114bd2a3b381aa7318c59015b21811353))
* **deps:** update mantine monorepo to ^8.1.0 ([#3360](https://github.com/homarr-labs/homarr/issues/3360)) ([d862ae0](https://github.com/homarr-labs/homarr/commit/d862ae03e319d173468cd7797bd74be022cbc966))
* **deps:** update tanstack-query monorepo to ^5.80.7 ([#3385](https://github.com/homarr-labs/homarr/issues/3385)) ([c5cc8f1](https://github.com/homarr-labs/homarr/commit/c5cc8f1d375d5a582b169da706a5b433a0181c20))
* **deps:** update testcontainers-node monorepo to ^11.0.1 ([#3343](https://github.com/homarr-labs/homarr/issues/3343)) ([6ec66b1](https://github.com/homarr-labs/homarr/commit/6ec66b1fe2d5779b2267532d75d6933b3460ffd5))
* **deps:** update testcontainers-node monorepo to ^11.0.2 ([#3352](https://github.com/homarr-labs/homarr/issues/3352)) ([98c4cd1](https://github.com/homarr-labs/homarr/commit/98c4cd1e39286164c09f7d8e961488e26fa2c562))
* **deps:** update testcontainers-node monorepo to ^11.0.3 ([#3369](https://github.com/homarr-labs/homarr/issues/3369)) ([ad444e8](https://github.com/homarr-labs/homarr/commit/ad444e8fa7a186710deafd98f2aa48717de1ec80))
* **deps:** update trpc monorepo to ^11.4.0 ([#3389](https://github.com/homarr-labs/homarr/issues/3389)) ([3bf4f9e](https://github.com/homarr-labs/homarr/commit/3bf4f9e0588440d74dfaf7016acc66c1ed40843a))
* **deps:** update trpc monorepo to ^11.4.1 ([#3395](https://github.com/homarr-labs/homarr/issues/3395)) ([6d9992d](https://github.com/homarr-labs/homarr/commit/6d9992d7114f97c24e02c40dcaf11fad90608a1c))
* module not found sshcrypto.node during development ([#3349](https://github.com/homarr-labs/homarr/issues/3349)) ([1b3770a](https://github.com/homarr-labs/homarr/commit/1b3770aafbe4e903e6235dc681edac556a36e4e0))
* **pi-hole:** cached session not invalidated when changing password ([#3351](https://github.com/homarr-labs/homarr/issues/3351)) ([a9e68e1](https://github.com/homarr-labs/homarr/commit/a9e68e13d53c8e06d150ecba085680cd248d4f64))
## [1.23.0](https://github.com/homarr-labs/homarr/compare/v1.22.0...v1.23.0) (2025-06-06)
### Features
+5 -2
View File
@@ -1,4 +1,4 @@
FROM node:22.17.0-alpine AS base
FROM node:22.16.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -10,6 +10,8 @@ RUN apk add --no-cache libc6-compat curl bash
RUN apk update
COPY . .
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
@@ -17,7 +19,8 @@ COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm build
FROM base AS runner
+23 -24
View File
@@ -29,7 +29,6 @@
"@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",
@@ -49,34 +48,34 @@
"@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.1.0",
"@mantine/core": "^8.1.0",
"@mantine/dropzone": "^8.1.0",
"@mantine/hooks": "^8.1.0",
"@mantine/modals": "^8.1.0",
"@mantine/tiptap": "^8.1.0",
"@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",
"@trpc/client": "^11.4.3",
"@trpc/next": "^11.4.3",
"@trpc/react-query": "^11.4.3",
"@trpc/server": "^11.4.3",
"@tanstack/react-query": "^5.80.6",
"@tanstack/react-query-devtools": "^5.80.6",
"@tanstack/react-query-next-experimental": "^5.80.6",
"@trpc/client": "^11.3.1",
"@trpc/next": "^11.3.1",
"@trpc/react-query": "^11.3.1",
"@trpc/server": "^11.3.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^17.2.0",
"dotenv": "^16.5.0",
"flag-icons": "^7.5.0",
"glob": "^11.0.3",
"glob": "^11.0.2",
"jotai": "^2.12.5",
"mantine-react-table": "2.0.0-beta.9",
"next": "15.3.5",
"postcss-preset-mantine": "^1.18.0",
"next": "15.3.3",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.30.0",
"react": "19.1.0",
"react-dom": "19.1.0",
@@ -84,24 +83,24 @@
"react-simple-code-editor": "^0.14.1",
"sass": "^1.89.2",
"superjson": "2.2.2",
"swagger-ui-react": "^5.26.2",
"swagger-ui-react": "^5.24.1",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.25.76"
"zod": "^3.25.62"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.16.3",
"@types/node": "^22.15.31",
"@types/prismjs": "^1.26.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.2.0",
"eslint": "^9.31.0",
"concurrently": "^9.1.2",
"eslint": "^9.28.0",
"node-loader": "^2.1.0",
"prettier": "^3.6.2",
"prettier": "^3.5.3",
"typescript": "^5.8.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

@@ -17,7 +17,6 @@ import {
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
@@ -103,25 +102,12 @@ export const ColorSettingsContent = ({ board }: Props) => {
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("iconColor")}
/>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
@@ -1,12 +1,4 @@
import {
IconGrid3x3,
IconKey,
IconMessage,
IconPassword,
IconPasswordUser,
IconServer,
IconUser,
} from "@tabler/icons-react";
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -17,6 +9,4 @@ export const integrationSecretIcons = {
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
personalAccessToken: IconPasswordUser,
topic: IconMessage,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -30,8 +30,6 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const rootCertificate = getHeighestCertificate(error.data.certificate);
const handleTrustHostname = () => {
const { hostname } = new URL(url);
openConfirmModal({
@@ -74,7 +72,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
new File([error.data.certificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
@@ -112,11 +110,11 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
<>
{description}
<CertificateDetailsCard certificate={rootCertificate} />
<CertificateDetailsCard certificate={error.data.certificate} />
{error.data.reason === "hostnameMismatch" && <HostnameMismatchAlert />}
{!rootCertificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />}
{!error.data.certificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />}
{showRetryButton && (
<Button
@@ -129,7 +127,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
</Button>
)}
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
{(error.data.reason === "untrusted" && error.data.certificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<Button
variant="default"
@@ -139,7 +137,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{tError("certificate.action.trust.label")}
</Button>
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
{error.data.reason === "untrusted" && !error.data.certificate.isSelfSigned ? (
<Button
variant="default"
fullWidth
@@ -327,8 +325,3 @@ const PemContentModal = createModal<{ content: string }>(({ actions, innerProps
},
size: "lg",
});
const getHeighestCertificate = (certificate: MappedCertificate): MappedCertificate => {
if (certificate.issuerCertificate) return getHeighestCertificate(certificate.issuerCertificate);
return certificate;
};
@@ -10,20 +10,15 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
interface IntegrationCreateDropdownContentProps {
enableMockIntegration: boolean;
}
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
export const IntegrationCreateDropdownContent = () => {
const t = useI18n();
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds
.filter((kind) => enableMockIntegration || kind !== "mock")
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
}, [search, enableMockIntegration]);
return integrationKinds.filter((kind) =>
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
);
}, [search]);
const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
@@ -21,13 +21,7 @@ import { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import {
getAllSecretKindOptions,
getIconUrl,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
} from "@homarr/definitions";
import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -60,7 +54,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const form = useZodForm(formSchema, {
initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind),
url: searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "",
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
@@ -41,7 +41,6 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -115,7 +114,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
<IntegrationCreateDropdownContent />
</MenuDropdown>
</Menu>
);
+37 -1
View File
@@ -1,13 +1,16 @@
import Link from "next/link";
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { Card, Group, SimpleGrid, Space, Stack, Text, Title } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
@@ -30,6 +33,8 @@ export async function generateMetadata() {
export default async function ManagementPage() {
const statistics = await api.home.getStats();
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const suggestions = isAdmin ? await api.integration.suggestedIntegrations().catch(catchTrpcNotFound) : [];
const t = await getScopedI18n("management.page.home");
const links: LinkProps[] = [
@@ -102,6 +107,37 @@ export default async function ManagementPage() {
),
)}
</SimpleGrid>
{suggestions.length >= 1 && (
<>
<Space h="md" />
<Stack gap="sm">
<Stack gap={0}>
<Title order={3}>Suggested integrations</Title>
<Text c="dimmed">Based on your Docker containers</Text>
</Stack>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{suggestions.slice(0, 6).map((suggestion) => (
<Card key={suggestion.kind} component={Link} href="#" radius="lg">
<Group justify="space-between">
<Group>
<IntegrationAvatar kind={suggestion.kind} size="sm" />
<Stack gap={0}>
<Text size="md" fw={500}>
{getIntegrationName(suggestion.kind)}
</Text>
<Text c="dimmed" size="sm">
Show active media streams
</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
))}
</SimpleGrid>
</Stack>
</>
)}
</>
);
}
@@ -1,216 +1,89 @@
"use client";
import React, { useState, useTransition } from "react";
import { ActionIcon, Badge, Button, Card, Group, Select, Stack, Text } from "@mantine/core";
import { useMap } from "@mantine/hooks";
import { IconPlayerPlay, IconPower, IconSettings } from "@tabler/icons-react";
import React from "react";
import { ActionIcon, Badge, Card, Group, Stack, Text } from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlayerPlay } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getMantineColor, useTimeAgo } from "@homarr/common";
import { useTimeAgo } from "@homarr/common";
import type { TaskStatus } from "@homarr/cron-job-status";
import { useForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { IconPowerOff } from "@homarr/ui/icons";
import type { TranslationKeys } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
interface JobsListProps {
initialJobs: RouterOutputs["cronJobs"]["getJobs"];
}
type JobName = RouterOutputs["cronJobs"]["getJobs"][number]["name"];
interface JobState {
job: JobsListProps["initialJobs"][number];
status: TaskStatus | null;
}
export const JobsList = ({ initialJobs }: JobsListProps) => {
const [jobs] = clientApi.cronJobs.getJobs.useSuspenseQuery(undefined, {
initialData: initialJobs,
refetchOnMount: false,
});
const jobStatusMap = useMap<string, TaskStatus | null>(initialJobs.map(({ name }) => [name, null] as const));
const t = useScopedI18n("management.page.tool.tasks");
const [jobs, handlers] = useListState<JobState>(
initialJobs.map((job) => ({
job,
status: null,
})),
);
clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, {
onData: (data) => {
jobStatusMap.set(data.name, data);
const jobByName = jobs.find((job) => job.job.name === data.name);
if (!jobByName) {
return;
}
handlers.applyWhere(
(job) => job.job.name === data.name,
(job) => ({ ...job, status: data }),
);
},
});
const { mutateAsync } = clientApi.cronJobs.triggerJob.useMutation();
const handleJobTrigger = React.useCallback(
async (job: JobState) => {
if (job.status?.status === "running") {
return;
}
await mutateAsync(job.job.name);
},
[mutateAsync],
);
return (
<Stack>
{jobs.map((job) => {
const status = jobStatusMap.get(job.name);
{jobs.map((job) => (
<Card key={job.job.name} withBorder>
<Group justify={"space-between"} gap={"md"}>
<Stack gap={0}>
<Group>
<Text>{t(`job.${job.job.name}.label` as TranslationKeys)}</Text>
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
</Group>
{job.status && <TimeAgo timestamp={job.status.lastExecutionTimestamp} />}
</Stack>
return <JobCard key={job.name} job={job} status={status ?? null} />;
})}
{!job.job.preventManualExecution && (
<ActionIcon
onClick={() => handleJobTrigger(job)}
disabled={job.status?.status === "running"}
variant={"default"}
size={"xl"}
radius={"xl"}
>
<IconPlayerPlay stroke={1.5} />
</ActionIcon>
)}
</Group>
</Card>
))}
</Stack>
);
};
const cronExpressions = [
{
value: "*/5 * * * * *",
label: (t) => t("management.page.tool.tasks.interval.seconds", { interval: 5 }),
},
{
value: "*/10 * * * * *",
label: (t) => t("management.page.tool.tasks.interval.seconds", { interval: 10 }),
},
{
value: "*/20 * * * * *",
label: (t) => t("management.page.tool.tasks.interval.seconds", { interval: 20 }),
},
{
value: "* * * * *",
label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 1 }),
},
{
value: "*/5 * * * *",
label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 5 }),
},
{
value: "*/10 * * * *",
label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 10 }),
},
{
value: "*/15 * * * *",
label: (t) => t("management.page.tool.tasks.interval.minutes", { interval: 15 }),
},
{
value: "0 * * * *",
label: (t) => t("management.page.tool.tasks.interval.hours", { interval: 1 }),
},
{
value: "0 0 * * */1",
label: (t) => t("management.page.tool.tasks.interval.midnight"),
},
{
value: "0 0 * * 1",
label: (t) => t("management.page.tool.tasks.interval.weeklyMonday"),
},
] satisfies { value: string; label: (t: TranslationFunction) => string }[];
interface JobCardProps {
job: RouterOutputs["cronJobs"]["getJobs"][number];
status: TaskStatus | null;
}
const JobCard = ({ job, status }: JobCardProps) => {
const t = useI18n();
const tTasks = useScopedI18n("management.page.tool.tasks");
const triggerMutation = clientApi.cronJobs.triggerJob.useMutation();
const handleJobTrigger = React.useCallback(
async (name: JobName) => {
if (status?.status === "running") return;
await triggerMutation.mutateAsync(name);
},
[triggerMutation, status],
);
const { openModal } = useModalAction(TaskConfigurationModal);
const [isEnabled, setEnabled] = useState(job.isEnabled);
const disableMutation = clientApi.cronJobs.disableJob.useMutation();
const enableMutation = clientApi.cronJobs.enableJob.useMutation();
const [activeStatePending, startActiveTransition] = useTransition();
const handleActiveChange = () =>
startActiveTransition(async () => {
if (isEnabled) {
await disableMutation.mutateAsync(job.name, {
onSuccess() {
setEnabled(false);
},
});
} else {
await enableMutation.mutateAsync(job.name, {
onSuccess() {
setEnabled(true);
},
});
}
});
return (
<Card key={job.name} withBorder>
<Group justify={"space-between"} gap={"md"}>
<Stack gap={0}>
<Group>
<Text>{tTasks(`job.${job.name}.label`)}</Text>
<StatusBadge isEnabled={isEnabled} status={status} />
{status?.lastExecutionStatus === "error" && <Badge color="red">{tTasks("status.error")}</Badge>}
</Group>
<Group gap="xs">
{status && (
<>
<TimeAgo timestamp={status.lastExecutionTimestamp} />
<Text size="sm" c="dimmed">
•
</Text>
<Text size="sm" c="dimmed">
{cronExpressions.find((expression) => expression.value === job.cron)?.label(t) ?? job.cron}
</Text>
</>
)}
</Group>
</Stack>
<Group>
{!job.preventManualExecution && (
<ActionIcon
onClick={() => handleJobTrigger(job.name)}
disabled={status?.status === "running"}
loading={triggerMutation.isPending}
variant="default"
size="xl"
radius="xl"
>
<IconPlayerPlay color={getMantineColor("green", 6)} stroke={1.5} />
</ActionIcon>
)}
<ActionIcon onClick={handleActiveChange} loading={activeStatePending} variant="default" size="xl" radius="xl">
{isEnabled ? (
<IconPower color={getMantineColor("green", 6)} stroke={1.5} />
) : (
<IconPowerOff color={getMantineColor("gray", 6)} stroke={1.5} />
)}
</ActionIcon>
<ActionIcon
onClick={() =>
openModal(
{ job },
{
title: tTasks("settings.title", {
jobName: tTasks(`job.${job.name}.label`),
}),
},
)
}
variant={"default"}
size={"xl"}
radius={"xl"}
>
<IconSettings stroke={1.5} />
</ActionIcon>
</Group>
</Group>
</Card>
);
};
interface StatusBadgeProps {
isEnabled: boolean;
status: TaskStatus | null;
}
const StatusBadge = ({ isEnabled, status }: StatusBadgeProps) => {
const t = useScopedI18n("management.page.tool.tasks");
if (!isEnabled) return <Badge color="yellow">{t("status.disabled")}</Badge>;
if (!status) return null;
if (status.status === "running") return <Badge color="green">{t("status.running")}</Badge>;
return <Badge variant="default">{t("status.idle")}</Badge>;
};
const TimeAgo = ({ timestamp }: { timestamp: string }) => {
const timeAgo = useTimeAgo(new Date(timestamp));
@@ -220,65 +93,3 @@ const TimeAgo = ({ timestamp }: { timestamp: string }) => {
</Text>
);
};
const TaskConfigurationModal = createModal<{
job: RouterOutputs["cronJobs"]["getJobs"][number];
}>(({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: {
cron: innerProps.job.cron,
},
});
const { mutateAsync, isPending } = clientApi.cronJobs.updateJobInterval.useMutation();
const utils = clientApi.useUtils();
return (
<form
onSubmit={form.onSubmit(async (values) => {
utils.cronJobs.getJobs.setData(undefined, (data) =>
data?.map((job) =>
job.name === innerProps.job.name
? {
...job,
cron: values.cron,
}
: job,
),
);
await mutateAsync(
{
name: innerProps.job.name,
cron: values.cron,
},
{
onSuccess() {
actions.closeModal();
},
async onSettled() {
await utils.cronJobs.getJobs.invalidate();
},
},
);
})}
>
<Stack gap="sm">
<Select
label={t("management.page.tool.tasks.field.interval.label")}
{...form.getInputProps("cron")}
data={cronExpressions.map(({ value, label }) => ({ value, label: label(t) }))}
/>
<Group justify="end">
<Button variant="subtle" color="gray" disabled={isPending} onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: "",
});
-9
View File
@@ -1,9 +0,0 @@
import { createEnv } from "@homarr/env";
import { createBooleanSchema } from "@homarr/env/schemas";
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
+9 -9
View File
@@ -22,7 +22,7 @@
"dependencies": {
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-api": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -30,26 +30,26 @@
"@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",
"fastify": "^5.4.0",
"dotenv": "^16.5.0",
"superjson": "2.2.2",
"undici": "7.11.0"
"undici": "7.10.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.3",
"@types/node": "^22.15.31",
"dotenv-cli": "^8.0.0",
"esbuild": "^0.25.6",
"eslint": "^9.31.0",
"prettier": "^3.6.2",
"tsx": "4.20.3",
"esbuild": "^0.25.5",
"eslint": "^9.28.0",
"prettier": "^3.5.3",
"tsx": "4.20.1",
"typescript": "^5.8.3"
}
}
-109
View File
@@ -1,109 +0,0 @@
import { schedule, validate as validateCron } from "node-cron";
import type { IJobManager } from "@homarr/cron-job-api";
import type { jobGroup as cronJobGroup, JobGroupKeys } from "@homarr/cron-jobs";
import type { Database, InferInsertModel } from "@homarr/db";
import { eq } from "@homarr/db";
import { cronJobConfigurations } from "@homarr/db/schema";
import { logger } from "@homarr/log";
export class JobManager implements IJobManager {
constructor(
private db: Database,
private jobGroup: typeof cronJobGroup,
) {}
public async startAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.startAsync(name);
}
public async triggerAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.runManuallyAsync(name);
}
public async stopAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.stopAsync(name);
}
public async updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void> {
logger.info(`Updating cron job interval name="${name}" expression="${cron}"`);
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
if (job.cronExpression === "never") throw new Error(`Job ${name} cannot be updated as it is set to "never"`);
if (!validateCron(cron)) {
throw new Error(`Invalid cron expression: ${cron}`);
}
await this.updateConfigurationAsync(name, { cronExpression: cron });
await this.jobGroup.getTask(name)?.destroy();
this.jobGroup.setTask(
name,
schedule(cron, () => void job.executeAsync(), {
name,
}),
);
logger.info(`Cron job interval updated name="${name}" expression="${cron}"`);
}
public async disableAsync(name: JobGroupKeys): Promise<void> {
logger.info(`Disabling cron job name="${name}"`);
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
if (job.cronExpression === "never") throw new Error(`Job ${name} cannot be disabled as it is set to "never"`);
await this.updateConfigurationAsync(name, { isEnabled: false });
await this.jobGroup.stopAsync(name);
logger.info(`Cron job disabled name="${name}"`);
}
public async enableAsync(name: JobGroupKeys): Promise<void> {
logger.info(`Enabling cron job name="${name}"`);
await this.updateConfigurationAsync(name, { isEnabled: true });
await this.jobGroup.startAsync(name);
logger.info(`Cron job enabled name="${name}"`);
}
private async updateConfigurationAsync(
name: JobGroupKeys,
configuration: Omit<Partial<InferInsertModel<typeof cronJobConfigurations>>, "name">,
) {
const existingConfig = await this.db.query.cronJobConfigurations.findFirst({
where: (table, { eq }) => eq(table.name, name),
});
logger.debug(
`Updating cron job configuration name="${name}" configuration="${JSON.stringify(configuration)}" exists="${Boolean(existingConfig)}"`,
);
if (existingConfig) {
await this.db
.update(cronJobConfigurations)
// prevent updating the name, as it is the primary key
.set({ ...configuration, name: undefined })
.where(eq(cronJobConfigurations.name, name));
logger.debug(`Cron job configuration updated name="${name}" configuration="${JSON.stringify(configuration)}"`);
return;
}
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
await this.db.insert(cronJobConfigurations).values({
name,
cronExpression: configuration.cronExpression ?? job.cronExpression,
isEnabled: configuration.isEnabled ?? true,
});
logger.debug(`Cron job configuration updated name="${name}" configuration="${JSON.stringify(configuration)}"`);
}
public async getAllAsync(): Promise<
{ name: JobGroupKeys; cron: string; preventManualExecution: boolean; isEnabled: boolean }[]
> {
const configurations = await this.db.query.cronJobConfigurations.findMany();
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
const config = configurations.find((config) => config.name === name);
return {
name,
cron: config?.cronExpression ?? job.cronExpression,
preventManualExecution: job.preventManualExecution,
isEnabled: config?.isEnabled ?? true,
};
});
}
}
+2 -37
View File
@@ -1,45 +1,10 @@
// This import has to be the first import in the file so that the agent is overridden before any other modules are imported.
import "./undici-log-agent-override";
import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import fastify from "fastify";
import type { JobRouter } from "@homarr/cron-job-api";
import { jobRouter } from "@homarr/cron-job-api";
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "@homarr/cron-job-api/constants";
import { registerCronJobRunner } from "@homarr/cron-job-runner/register";
import { jobGroup } from "@homarr/cron-jobs";
import { db } from "@homarr/db";
import { logger } from "@homarr/log";
import { JobManager } from "./job-manager";
const server = fastify({
maxParamLength: 5000,
});
server.register(fastifyTRPCPlugin, {
prefix: CRON_JOB_API_PATH,
trpcOptions: {
router: jobRouter,
createContext: ({ req }) => ({
manager: new JobManager(db, jobGroup),
apiKey: req.headers[CRON_JOB_API_KEY_HEADER] as string | undefined,
}),
onError({ path, error }) {
logger.error(new Error(`Error in tasks tRPC handler path="${path}"`, { cause: error }));
},
} satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"],
});
void (async () => {
await jobGroup.initializeAsync();
registerCronJobRunner();
await jobGroup.startAllAsync();
try {
await server.listen({ port: CRON_JOB_API_PORT });
logger.info(`Tasks web server started successfully port="${CRON_JOB_API_PORT}"`);
} catch (err) {
logger.error(new Error(`Failed to start tasks web server port="${CRON_JOB_API_PORT}"`, { cause: err }));
process.exit(1);
}
})();
+6 -6
View File
@@ -25,18 +25,18 @@
"@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.2.0",
"tsx": "4.20.3",
"ws": "^8.18.3"
"dotenv": "^16.5.0",
"tsx": "4.20.1",
"ws": "^8.18.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@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",
"prettier": "^3.6.2",
"esbuild": "^0.25.5",
"eslint": "^9.28.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3"
}
}
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.28.1",
"version": "1.23.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -36,26 +36,26 @@
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.3",
"@semantic-release/npm": "^12.0.2",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.5.4",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"conventional-changelog-conventionalcommits": "^9.1.0",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^3.2.3",
"@vitest/ui": "^3.2.3",
"conventional-changelog-conventionalcommits": "^9.0.0",
"cross-env": "^7.0.3",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"semantic-release": "^24.2.7",
"testcontainers": "^11.2.1",
"prettier": "^3.5.3",
"semantic-release": "^24.2.5",
"testcontainers": "^11.0.3",
"turbo": "^2.5.4",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
"vitest": "^3.2.3"
},
"packageManager": "pnpm@10.13.1",
"packageManager": "pnpm@10.12.1",
"engines": {
"node": ">=22.17.0"
"node": ">=22.16.0"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -70,7 +70,7 @@
"tree-sitter-json"
],
"overrides": {
"proxmox-api>undici": "7.11.0"
"proxmox-api>undici": "7.10.0"
},
"allowUnusedPatches": true,
"ignoredBuiltDependencies": [
+1 -1
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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+12 -11
View File
@@ -24,7 +24,7 @@
"@homarr/auth": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-api": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -35,30 +35,31 @@
"@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",
"@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3",
"@trpc/server": "^11.4.3",
"@trpc/tanstack-react-query": "^11.4.3",
"@tanstack/react-query": "^5.80.6",
"@trpc/client": "^11.3.1",
"@trpc/react-query": "^11.3.1",
"@trpc/server": "^11.3.1",
"@trpc/tanstack-react-query": "^11.3.1",
"lodash.clonedeep": "^4.5.0",
"next": "15.3.5",
"next": "15.3.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"superjson": "2.2.2",
"trpc-to-openapi": "^2.3.2",
"zod": "^3.25.76"
"trpc-to-openapi": "^2.3.1",
"zod": "^3.25.62"
},
"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",
"prettier": "^3.6.2",
"eslint": "^9.28.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3"
}
}
+9 -42
View File
@@ -1,8 +1,7 @@
import { observable } from "@trpc/server/observable";
import z from "zod/v4";
import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { cronJobApi } from "@homarr/cron-job-api/client";
import { objectEntries } from "@homarr/common";
import { cronJobNames, cronJobs, jobNameSchema, triggerCronJobAsync } from "@homarr/cron-job-runner";
import type { TaskStatus } from "@homarr/cron-job-status";
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
import { logger } from "@homarr/log";
@@ -14,51 +13,19 @@ export const cronJobsRouter = createTRPCRouter({
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.trigger.mutate(input);
await triggerCronJobAsync(input);
}),
startJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.start.mutate(input);
}),
stopJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.stop.mutate(input);
}),
updateJobInterval: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
name: jobNameSchema,
cron: cronExpressionSchema,
}),
)
.mutation(async ({ input }) => {
await cronJobApi.updateInterval.mutate(input);
}),
disableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.disable.mutate(input);
}),
enableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.enable.mutate(input);
}),
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
return await cronJobApi.getAll.query();
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(() => {
return objectEntries(cronJobs).map(([name, options]) => ({
name,
preventManualExecution: options.preventManualExecution,
}));
}),
subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
return observable<TaskStatus>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const name of jobGroupKeys) {
for (const name of cronJobNames) {
const channel = createCronJobStatusChannel(name);
const unsubscribe = channel.subscribe((data) => {
emit.next(data);
@@ -14,16 +14,19 @@ import {
integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationKindsByCategory,
getIntegrationName,
getPermissionsWithParents,
integrationCategories,
integrationDefs,
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import type { ContainerInfo, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { env } from "@homarr/docker/env";
import { createIntegrationAsync } from "@homarr/integrations";
import { logger } from "@homarr/log";
import { byIdSchema } from "@homarr/validation/common";
@@ -130,57 +133,6 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allOfGivenCategory: publicProcedure
.input(
z.object({
category: z.enum(integrationCategories),
}),
)
.query(async ({ ctx, input }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const intergrationKinds = getIntegrationKindsByCategory(input.category);
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(integrations.kind, intergrationKinds),
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
@@ -579,8 +531,169 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
suggestedIntegrations: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
if (!env.ENABLE_DOCKER)
throw new TRPCError({
code: "NOT_FOUND",
message: "Docker is not enabled",
});
const dockerInstances = DockerSingleton.getInstances();
if (dockerInstances.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No Docker instances found",
});
}
// TODO: remove those that were already added
const containers = await Promise.all(
dockerInstances.map(({ instance, host, isSocket }) =>
instance.listContainers().then((containers) =>
containers.map((containerInfo) => ({
info: containerInfo,
host,
isSocket,
})),
),
),
).then((results) => results.flat());
// Default docker hostname consist out of first 12 characters of the container id
const homarrContainer = containers.find((container) => container.info.Id.slice(0, 12) === process.env.HOSTNAME);
const suggestions = containers
.map((container) => getSuggestionFromContainer(container, homarrContainer))
.filter((suggestion) => suggestion !== null);
const groupedSuggestions = suggestions.reduce(
(acc, suggestion) => {
acc[suggestion.kind] ??= [];
// is defined above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
acc[suggestion.kind]!.push(suggestion);
return acc;
},
{} as Partial<Record<IntegrationKind, IntegrationSuggestion[]>>,
);
return Object.entries(groupedSuggestions)
.map(([kind, suggestions]) => ({
kind: kind as IntegrationKind,
suggestions,
}))
.sort((itemA, itemB) => {
if (itemA.suggestions.length !== itemB.suggestions.length)
return itemB.suggestions.length - itemA.suggestions.length;
const createdAtA = Math.max(...itemA.suggestions.map((suggestion) => suggestion.createdAt?.getTime() ?? 0));
const createdAtB = Math.max(...itemB.suggestions.map((suggestion) => suggestion.createdAt?.getTime() ?? 0));
return createdAtB - createdAtA;
});
}),
});
interface IntegrationSuggestion {
containerId: string;
name: string;
internalUrl: string | null;
externalUrl: string | null;
kind: IntegrationKind;
createdAt?: Date;
}
interface ContainerInfoWithHost {
info: ContainerInfo;
host: string;
isSocket: boolean | undefined;
}
const getSuggestionFromContainer = (
container: ContainerInfoWithHost,
homarrContainer: ContainerInfoWithHost | undefined,
): IntegrationSuggestion | null => {
const integrationKind = getSuggestionKind(container.info);
if (!integrationKind) return null;
return {
containerId: container.info.Id,
name: getIntegrationName(integrationKind),
kind: integrationKind,
internalUrl: getInternalUrl(container, homarrContainer),
externalUrl: getExternalUrl(container),
createdAt: container.info.Created ? new Date(container.info.Created * 1000) : undefined,
};
};
const getSuggestionKind = (container: ContainerInfo): IntegrationKind | null => {
const [image, _tag] = container.Image.split(":");
if (!image) return null;
return (
objectEntries(integrationDefs).find(
([, integration]) =>
"containerImages" in integration &&
integration.containerImages.some((containerImage) => containerImage.toLowerCase() === image.toLowerCase()),
)?.[0] ?? null
);
};
const calculatePortScore = (port: Port): number => {
let score = 0;
if (port.PublicPort) score += 10; // Public ports are more likely to be used
if (port.PrivatePort % 1000 === 80) score += 5; // Default http ports are most likely suffixed with 80 like 8080 or 80
if (port.PrivatePort % 1000 === 443) score += 10; // Default https ports are most likely suffixed with 443 like 8443 or 443
return score;
};
const getPort = (container: ContainerInfoWithHost): Port | null => {
const ports = container.info.Ports.filter((port) => port.Type === "tcp");
return (
ports
.map((port) => ({ port, score: calculatePortScore(port) }))
.sort((portA, portB) => portB.score - portA.score)
.at(0)?.port ?? null
);
};
const getInternalUrl = (
container: ContainerInfoWithHost,
homarrContainer: ContainerInfoWithHost | undefined,
): string | null => {
const port = getPort(container);
if (!port) return null;
const protocol = port.PrivatePort % 1000 === 443 ? "https" : "http";
if (homarrContainer) {
const matchingNetwork = Object.entries(homarrContainer.info.NetworkSettings.Networks).find(
([_key, homarrNetwork]) =>
Object.entries(container.info.NetworkSettings.Networks).some(
([_name, network]) => homarrNetwork.NetworkID === network.NetworkID,
),
)?.[1];
if (matchingNetwork) return `${protocol}://${matchingNetwork.IPAddress}:${port.PrivatePort}`;
}
// TODO: Let user specify the internal ip-address for integrations --> use as default before falling back to host.docker.internal
if (container.host === homarrContainer?.host && container.isSocket === homarrContainer.isSocket) {
return `${protocol}://host.docker.internal:${port.PublicPort}`;
}
// TODO: Change this
return `${protocol}://localhost:${port.PublicPort}`;
};
const getExternalUrl = (container: ContainerInfoWithHost): string | null => {
const port = getPort(container);
if (!port) return null;
const protocol = port.PrivatePort % 1000 === 443 ? "https" : "http";
// TODO: Use some labels like "traefik.host" to determine the external url
// For now we just use localhost as external url
return `${protocol}://localhost:${port.PublicPort}`;
};
interface UpdateSecretInput {
integrationId: string;
value: string;
@@ -36,7 +36,6 @@ const mapError = (error: Error): MappedError => {
export interface MappedCertificate {
isSelfSigned: boolean;
issuer: string;
issuerCertificate?: MappedCertificate;
subject: string;
serialNumber: string;
validFrom: Date;
@@ -48,7 +47,6 @@ export interface MappedCertificate {
const mapCertificate = (certificate: X509Certificate, code: RequestErrorCode): MappedCertificate => ({
isSelfSigned: certificate.ca || code === "DEPTH_ZERO_SELF_SIGNED_CERT",
issuer: certificate.issuer,
issuerCertificate: certificate.issuerCertificate ? mapCertificate(certificate.issuerCertificate, code) : undefined,
subject: certificate.subject,
serialNumber: certificate.serialNumber,
validFrom: certificate.validFromDate,
@@ -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 }));
// Act
const result = await caller.ping({ url });
// Assert
expect(result.url).toBe(url);
expect("statusCode" in result).toBe(true);
});
});
+23 -21
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,27 +23,21 @@ export const appRouter = createTRPCRouter({
)
.subscription(async ({ input }) => {
await pingUrlChannel.addAsync(input.url);
const innerHandler = pingRequestHandler.handler({ url: 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 pingResult = await sendPingRequestAsync(input.url);
const unsubscribe = innerHandler.subscribe((pingResponse) => {
emit.next({
url: input.url,
...pingResponse,
});
});
return observable<{ url: string; statusCode: number } | { url: string; error: string }>((emit) => {
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 () => {
unsubscribe();
void pingUrlChannel.removeAsync(input.url);
};
},
);
return () => {
unsubscribe();
void pingUrlChannel.removeAsync(input.url);
};
});
}),
});
@@ -1,6 +1,6 @@
import { observable } from "@trpc/server/observable";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import type { HealthMonitoring } from "@homarr/integrations";
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -26,9 +26,9 @@ export const healthMonitoringRouter = createTRPCRouter({
);
}),
subscribeSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
@@ -49,14 +49,14 @@ export const healthMonitoringRouter = createTRPCRouter({
});
}),
getClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.concat(createOneIntegrationMiddleware("query", "proxmox"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.concat(createOneIntegrationMiddleware("query", "proxmox"))
.subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];
-2
View File
@@ -11,7 +11,6 @@ import { mediaTranscodingRouter } from "./media-transcoding";
import { minecraftRouter } from "./minecraft";
import { networkControllerRouter } from "./network-controller";
import { notebookRouter } from "./notebook";
import { notificationsRouter } from "./notifications";
import { optionsRouter } from "./options";
import { releasesRouter } from "./releases";
import { rssFeedRouter } from "./rssFeed";
@@ -38,5 +37,4 @@ export const widgetRouter = createTRPCRouter({
options: optionsRouter,
releases: releasesRouter,
networkController: networkControllerRouter,
notifications: notificationsRouter,
});
@@ -1,64 +0,0 @@
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 { Notification } from "@homarr/integrations";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
}),
);
}),
subscribeNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"notifications"> }>;
data: Notification[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next({
integration,
data,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});
+7 -9
View File
@@ -1,10 +1,8 @@
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
@@ -25,31 +23,31 @@ const releaseVersionFilterSchema = z.object({
export const releasesRouter = createTRPCRouter({
getLatest: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
.input(
z.object({
repositories: z.array(
z.object({
id: z.string(),
providerKey: z.string(),
identifier: z.string(),
versionFilter: releaseVersionFilterSchema.optional(),
}),
),
}),
)
.query(async ({ ctx, input }) => {
return await Promise.all(
.query(async ({ input }) => {
const result = await Promise.all(
input.repositories.map(async (repository) => {
const innerHandler = releasesRequestHandler.handler(ctx.integration, {
id: repository.id,
const innerHandler = releasesRequestHandler.handler({
providerKey: repository.providerKey,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
});
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return result;
}),
});
+8 -8
View File
@@ -23,8 +23,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.40.0",
"@auth/drizzle-adapter": "^1.10.0",
"@auth/core": "^0.39.1",
"@auth/drizzle-adapter": "^1.9.1",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -34,12 +34,12 @@
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^6.0.0",
"cookies": "^0.9.1",
"ldapts": "8.0.5",
"next": "15.3.5",
"next-auth": "5.0.0-beta.29",
"ldapts": "8.0.1",
"next": "15.3.3",
"next-auth": "5.0.0-beta.28",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "^3.25.76"
"zod": "^3.25.62"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -47,8 +47,8 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.1",
"eslint": "^9.31.0",
"prettier": "^3.6.2",
"eslint": "^9.28.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3"
}
}
@@ -3,14 +3,12 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationContextProps {
integrations: {
id: string;
name: string;
url: string;
kind: IntegrationKind;
kind: string;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
+1 -1
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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+2 -2
View File
@@ -24,13 +24,13 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"undici": "7.11.0"
"undici": "7.10.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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+3 -2
View File
@@ -15,8 +15,9 @@ import { db } from "@homarr/db";
import type { trustedCertificateHostnames } from "@homarr/db/schema";
const getCertificateFolder = () => {
if (env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
return env.NODE_ENV === "production"
? path.join("/appdata", "trusted-certificates")
: process.env.LOCAL_CERTIFICATE_PATH;
};
export const loadCustomRootCertificatesAsync = async () => {
+3 -3
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": "^16.5.0"
},
"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",
"esbuild": "^0.25.5",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+5 -6
View File
@@ -29,20 +29,19 @@
"dependencies": {
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"dayjs": "^1.11.13",
"next": "15.3.5",
"next": "15.3.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"undici": "7.11.0",
"zod": "^3.25.76",
"zod-validation-error": "^3.5.3"
"undici": "7.10.0",
"zod": "^3.25.62",
"zod-validation-error": "^3.4.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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
import type { SerializeOptions } from "cookie";
import type { CookieSerializeOptions } from "cookie";
import { parse, serialize } from "cookie";
export function parseCookies(cookieString: string) {
return parse(cookieString);
}
export function setClientCookie(name: string, value: string, options: SerializeOptions = {}) {
export function setClientCookie(name: string, value: string, options: CookieSerializeOptions = {}) {
document.cookie = serialize(name, value, options);
}
@@ -36,12 +36,7 @@ export const requestErrorMap = {
expired: ["CERT_HAS_EXPIRED"],
hostnameMismatch: ["ERR_TLS_CERT_ALTNAME_INVALID", "CERT_COMMON_NAME_INVALID"],
notYetValid: ["CERT_NOT_YET_VALID"],
untrusted: [
"DEPTH_ZERO_SELF_SIGNED_CERT",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"SELF_SIGNED_CERT_IN_CHAIN",
],
untrusted: ["DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"],
},
connection: {
hostUnreachable: "EHOSTUNREACH",
+2 -2
View File
@@ -10,11 +10,11 @@ const calculateTimeAgo = (timestamp: Date) => {
return dayjs().to(timestamp);
};
export const useTimeAgo = (timestamp: Date, updateFrequency = 1000) => {
export const useTimeAgo = (timestamp: Date) => {
const [timeAgo, setTimeAgo] = useState(calculateTimeAgo(timestamp));
useEffect(() => {
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), updateFrequency);
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), 1000); // update every second
return () => clearInterval(intervalId); // clear interval on hook unmount
}, [timestamp]);
-1
View File
@@ -1 +0,0 @@
export { createId } from "@paralleldrive/cuid2";
-1
View File
@@ -5,7 +5,6 @@ export * from "./array";
export * from "./date";
export * from "./stopwatch";
export * from "./hooks";
export * from "./id";
export * from "./url";
export * from "./number";
export * from "./error";
-20
View File
@@ -1,20 +0,0 @@
import { createTRPCClient, httpLink } from "@trpc/client";
import type { JobRouter } from ".";
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "./constants";
import { env } from "./env";
export const cronJobApi = createTRPCClient<JobRouter>({
links: [
httpLink({
url: `${getBaseUrl()}${CRON_JOB_API_PATH}`,
headers: {
[CRON_JOB_API_KEY_HEADER]: env.CRON_JOB_API_KEY,
},
}),
],
});
function getBaseUrl() {
return `http://localhost:${CRON_JOB_API_PORT}`;
}
-3
View File
@@ -1,3 +0,0 @@
export const CRON_JOB_API_PORT = 3002;
export const CRON_JOB_API_PATH = "/trpc";
export const CRON_JOB_API_KEY_HEADER = "cron-job-api-key";
-11
View File
@@ -1,11 +0,0 @@
import { z } from "zod/v4";
import { env as commonEnv } from "@homarr/common/env";
import { createEnv } from "@homarr/env";
export const env = createEnv({
server: {
CRON_JOB_API_KEY: commonEnv.NODE_ENV === "development" ? z.string().default("test") : z.string(),
},
experimental__runtimeEnv: process.env,
});
-82
View File
@@ -1,82 +0,0 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { validate } from "node-cron";
import { z } from "zod/v4";
import type { JobGroupKeys } from "@homarr/cron-jobs";
import { jobGroup } from "@homarr/cron-jobs";
import { env } from "./env";
export const jobGroupKeys = jobGroup.getKeys();
export const jobNameSchema = z.enum(jobGroup.getKeys());
export interface IJobManager {
startAsync(name: JobGroupKeys): Promise<void>;
triggerAsync(name: JobGroupKeys): Promise<void>;
stopAsync(name: JobGroupKeys): Promise<void>;
updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void>;
disableAsync(name: JobGroupKeys): Promise<void>;
enableAsync(name: JobGroupKeys): Promise<void>;
getAllAsync(): Promise<{ name: JobGroupKeys; cron: string; preventManualExecution: boolean; isEnabled: boolean }[]>;
}
const t = initTRPC
.context<{
manager: IJobManager;
apiKey?: string;
}>()
.create();
const createTrpcRouter = t.router;
const apiKeyProcedure = t.procedure.use(({ ctx, next }) => {
if (ctx.apiKey !== env.CRON_JOB_API_KEY) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Missing or invalid API key",
});
}
return next({
ctx: {
...ctx,
apiKey: undefined, // Clear the API key after checking
},
});
});
export const cronExpressionSchema = z.string().refine((expression) => validate(expression), {
error: "Invalid cron expression",
});
export const jobRouter = createTrpcRouter({
start: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.startAsync(input);
}),
trigger: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.triggerAsync(input);
}),
stop: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.stopAsync(input);
}),
updateInterval: apiKeyProcedure
.input(
z.object({
name: jobNameSchema,
cron: cronExpressionSchema,
}),
)
.mutation(async ({ input, ctx }) => {
await ctx.manager.updateIntervalAsync(input.name, input.cron);
}),
disable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.disableAsync(input);
}),
enable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.enableAsync(input);
}),
getAll: apiKeyProcedure.query(({ ctx }) => {
return ctx.manager.getAllAsync();
}),
});
export type JobRouter = typeof jobRouter;
+1
View File
@@ -0,0 +1 @@
export * from "./src";
@@ -1,14 +1,12 @@
{
"name": "@homarr/cron-job-api",
"name": "@homarr/cron-job-runner",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./constants": "./src/constants.ts",
"./client": "./src/client.ts"
".": "./index.ts",
"./register": "./src/register.ts"
},
"typesVersions": {
"*": {
@@ -27,23 +25,15 @@
"dependencies": {
"@homarr/common": "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",
"@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",
"zod": "^3.25.76"
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "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",
"@types/node-cron": "^3.0.11",
"@types/react": "19.1.8",
"eslint": "^9.31.0",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+44
View File
@@ -0,0 +1,44 @@
import { objectKeys } from "@homarr/common";
import type { JobGroupKeys } from "@homarr/cron-jobs";
import { createSubPubChannel } from "@homarr/redis";
import { zodEnumFromArray } from "@homarr/validation/enums";
export const cronJobRunnerChannel = createSubPubChannel<JobGroupKeys>("cron-job-runner", { persist: false });
export const cronJobs = {
analytics: { preventManualExecution: true },
iconsUpdater: { preventManualExecution: false },
ping: { preventManualExecution: false },
smartHomeEntityState: { preventManualExecution: false },
mediaServer: { preventManualExecution: false },
mediaOrganizer: { preventManualExecution: false },
downloads: { preventManualExecution: false },
dnsHole: { preventManualExecution: false },
mediaRequestStats: { preventManualExecution: false },
mediaRequestList: { preventManualExecution: false },
rssFeeds: { preventManualExecution: false },
indexerManager: { preventManualExecution: false },
healthMonitoring: { preventManualExecution: false },
sessionCleanup: { preventManualExecution: false },
updateChecker: { preventManualExecution: false },
mediaTranscoding: { preventManualExecution: false },
minecraftServerStatus: { preventManualExecution: false },
networkController: { preventManualExecution: false },
dockerContainers: { preventManualExecution: false },
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
/**
* Triggers a cron job to run immediately.
* This works over the Redis PubSub channel.
* @param jobName name of the job to be triggered
*/
export const triggerCronJobAsync = async (jobName: JobGroupKeys) => {
if (cronJobs[jobName].preventManualExecution) {
throw new Error(`The job "${jobName}" can not be executed manually`);
}
await cronJobRunnerChannel.publishAsync(jobName);
};
export const cronJobNames = objectKeys(cronJobs);
export const jobNameSchema = zodEnumFromArray(cronJobNames);
+12
View File
@@ -0,0 +1,12 @@
import { jobGroup } from "@homarr/cron-jobs";
import { cronJobRunnerChannel } from ".";
/**
* Registers the cron job runner to listen to the Redis PubSub channel.
*/
export const registerCronJobRunner = () => {
cronJobRunnerChannel.subscribe((jobName) => {
void jobGroup.runManuallyAsync(jobName);
});
};
File renamed without changes.
+1 -1
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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+2 -3
View File
@@ -25,15 +25,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"node-cron": "^4.2.1"
"node-cron": "^4.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.31.0",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+26 -36
View File
@@ -1,9 +1,9 @@
import { AxiosError } from "axios";
import { createTask, validate } from "node-cron";
import type { ScheduledTask } from "node-cron";
import { schedule, validate } from "node-cron";
import { Stopwatch } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { Logger } from "./logger";
import type { ValidateCron } from "./validation";
@@ -18,14 +18,13 @@ export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
interface CreateCronJobOptions {
runOnStart?: boolean;
preventManualExecution?: boolean;
expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>;
}
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
name: TName,
defaultCronExpression: string,
cronExpression: string,
options: CreateCronJobOptions,
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => {
@@ -64,30 +63,25 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
}
};
/**
* We are not using the runOnInit method as we want to run the job only once we start the cron job schedule manually.
* This allows us to always run it once we start it. Additionally, it will not run the callback if only the cron job file is imported.
*/
let scheduledTask: ScheduledTask | null = null;
if (cronExpression !== "never") {
scheduledTask = schedule(cronExpression, () => void catchingCallbackAsync(), {
name,
timezone: creatorOptions.timezone,
});
creatorOptions.logger.logDebug(
`The cron job '${name}' was created with expression ${cronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
);
}
return {
name,
cronExpression: defaultCronExpression,
async createTaskAsync() {
const configuration = await db.query.cronJobConfigurations.findFirst({
where: (cronJobConfigurations, { eq }) => eq(cronJobConfigurations.name, name),
});
if (defaultCronExpression === "never") return null;
const scheduledTask = createTask(
configuration?.cronExpression ?? defaultCronExpression,
() => void catchingCallbackAsync(),
{
name,
timezone: creatorOptions.timezone,
},
);
creatorOptions.logger.logDebug(
`The cron job '${name}' was created with expression ${defaultCronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
);
return scheduledTask;
},
cronExpression,
scheduledTask,
async onStartAsync() {
if (options.beforeStart) {
creatorOptions.logger.logDebug(`Running beforeStart for job: ${name}`);
@@ -99,10 +93,6 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`);
await catchingCallbackAsync();
},
async executeAsync() {
await catchingCallbackAsync();
},
preventManualExecution: options.preventManualExecution ?? false,
};
};
};
@@ -116,17 +106,17 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
) => {
return <TName extends TAllowedNames, TExpression extends string>(
name: TName,
defaultCronExpression: TExpression,
cronExpression: TExpression,
options: CreateCronJobOptions = { runOnStart: false },
) => {
creatorOptions.logger.logDebug(`Validating cron expression '${defaultCronExpression}' for job: ${name}`);
if (defaultCronExpression !== "never" && !validate(defaultCronExpression)) {
throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
creatorOptions.logger.logDebug(`Validating cron expression '${cronExpression}' for job: ${name}`);
if (cronExpression !== "never" && !validate(cronExpression)) {
throw new Error(`Invalid cron expression '${cronExpression}' for job '${name}'`);
}
creatorOptions.logger.logDebug(`Cron job expression '${defaultCronExpression}' for job ${name} is valid`);
creatorOptions.logger.logDebug(`Cron job expression '${cronExpression}' for job ${name} is valid`);
const returnValue = {
withCallback: createCallback<TAllowedNames, TName>(name, defaultCronExpression, options, creatorOptions),
withCallback: createCallback<TAllowedNames, TName>(name, cronExpression, options, creatorOptions),
};
// This is a type guard to check if the cron expression is valid and give the user a type hint
+5 -41
View File
@@ -1,7 +1,4 @@
import type { ScheduledTask } from "node-cron";
import { objectEntries, objectKeys } from "@homarr/common";
import { db } from "@homarr/db";
import type { JobCallback } from "./creator";
import type { Logger } from "./logger";
@@ -30,78 +27,45 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
});
}
const tasks = new Map<string, ScheduledTask>();
return {
initializeAsync: async () => {
const configurations = await db.query.cronJobConfigurations.findMany();
for (const job of jobRegistry.values()) {
const configuration = configurations.find(({ name }) => name === job.name);
if (configuration?.isEnabled === false) {
continue;
}
if (tasks.has(job.name)) {
continue;
}
const scheduledTask = await job.createTaskAsync();
if (!scheduledTask) continue;
tasks.set(job.name, scheduledTask);
}
},
startAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string);
if (!job) return;
if (!tasks.has(job.name)) return;
options.logger.logInfo(`Starting schedule cron job ${job.name}.`);
await job.onStartAsync();
await tasks.get(name as string)?.start();
await job.scheduledTask?.start();
},
startAllAsync: async () => {
for (const job of jobRegistry.values()) {
if (!tasks.has(job.name)) {
continue;
}
options.logger.logInfo(`Starting schedule of cron job ${job.name}.`);
await job.onStartAsync();
await tasks.get(job.name)?.start();
await job.scheduledTask?.start();
}
},
runManuallyAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string);
if (!job) return;
if (job.preventManualExecution) {
throw new Error(`The job "${job.name}" can not be executed manually.`);
}
options.logger.logInfo(`Running schedule cron job ${job.name} manually.`);
await tasks.get(name as string)?.execute();
await job.scheduledTask?.execute();
},
stopAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string);
if (!job) return;
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
await tasks.get(name as string)?.stop();
await job.scheduledTask?.stop();
},
stopAllAsync: async () => {
for (const job of jobRegistry.values()) {
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
await tasks.get(job.name)?.stop();
await job.scheduledTask?.stop();
}
},
getJobRegistry() {
return jobRegistry as Map<TAllowedNames, ReturnType<JobCallback<TAllowedNames, TAllowedNames>>>;
},
getTask(name: keyof TJobs) {
return tasks.get(name as string) ?? null;
},
setTask(name: keyof TJobs, task: ScheduledTask) {
tasks.set(name as string, task);
},
getKeys() {
return objectKeys(jobs);
},
+2 -1
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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
-2
View File
@@ -11,7 +11,6 @@ import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/m
import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -39,7 +38,6 @@ export const jobGroup = createCronJobGroup({
minecraftServerStatus: minecraftServerStatusJob,
dockerContainers: dockerContainersJob,
networkController: networkControllerJob,
refreshNotifications: refreshNotificationsJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
-1
View File
@@ -7,7 +7,6 @@ import { createCronJob } from "../lib";
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
runOnStart: true,
preventManualExecution: true,
}).withCallback(async () => {
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
+8 -7
View File
@@ -38,7 +38,6 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
const newIconRepositories: InferInsertModel<typeof iconRepositories>[] = [];
const newIcons: InferInsertModel<typeof icons>[] = [];
const allDbIcons = databaseIconRepositories.flatMap((group) => group.icons);
for (const repositoryIconGroup of repositoryIconGroups) {
if (!repositoryIconGroup.success) {
@@ -56,10 +55,12 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
});
}
const dbIconsInRepository = allDbIcons.filter((icon) => icon.iconRepositoryId === iconRepositoryId);
for (const icon of repositoryIconGroup.icons) {
if (dbIconsInRepository.some((dbIcon) => dbIcon.checksum === icon.checksum)) {
if (
databaseIconRepositories
.flatMap((repository) => repository.icons)
.some((dbIcon) => dbIcon.checksum === icon.checksum && dbIcon.iconRepositoryId === iconRepositoryId)
) {
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
continue;
}
@@ -75,9 +76,9 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
}
}
const deadIcons = allDbIcons.filter(
(icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`),
);
const deadIcons = databaseIconRepositories
.flatMap((repository) => repository.icons)
.filter((icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`));
const deadIconRepositories = databaseIconRepositories.filter(
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
@@ -8,8 +8,7 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
createRequestIntegrationJobHandler(
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind !== "proxmox" && kind !== "mock") {
if (kind !== "proxmox") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
@@ -1,14 +0,0 @@
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import { createCronJob } from "../../lib";
export const refreshNotificationsJob = createCronJob("refreshNotifications", EVERY_5_MINUTES).withCallback(
createRequestIntegrationJobHandler(notificationsRequestHandler.handler, {
widgetKinds: ["notifications"],
getInput: {
notifications: (options) => options,
},
}),
);
+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,72 +0,0 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { eq } from "../..";
import type { Database } from "../..";
import { items } from "../../schema";
export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
const existingItems = await db.query.items.findMany({
where: (items, { eq }) => eq(items.kind, "releases"),
});
const integrationKinds = getIntegrationKindsByCategory("releasesProvider");
const providerIntegrations = await db.query.integrations.findMany({
where: (integrations, { inArray }) => inArray(integrations.kind, integrationKinds),
columns: {
id: true,
kind: true,
},
});
const providerIntegrationMap = new Map<IntegrationKind, string>(
providerIntegrations.map((integration) => [integration.kind, integration.id]),
);
const updates: {
id: string;
options: object;
}[] = [];
for (const item of existingItems) {
const options = SuperJSON.parse<object>(item.options);
if (!("repositories" in options)) continue;
if (!Array.isArray(options.repositories)) continue;
if (options.repositories.length === 0) continue;
if (!options.repositories.some((repository) => "providerKey" in repository)) continue;
const updatedRepositories = options.repositories.map(
({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
// Ensure providerKey is camelCase
const provider = providerKey.charAt(0).toLowerCase() + providerKey.slice(1);
return {
id: createId(),
providerIntegrationId: providerIntegrationMap.get(provider as IntegrationKind) ?? null,
...otherFields,
};
},
);
updates.push({
id: item.id,
options: {
...options,
repositories: updatedRepositories,
},
});
}
for (const update of updates) {
await db
.update(items)
.set({
options: SuperJSON.stringify(update.options),
})
.where(eq(items.id, update.id));
}
console.log(`Migrated release widget providers to integrations count="${updates.length}"`);
}
-6
View File
@@ -1,6 +0,0 @@
import type { Database } from "../..";
import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widget_provider_to_options";
export const applyCustomMigrationsAsync = async (db: Database) => {
await migrateReleaseWidgetProviderToOptionsAsync(db);
};
@@ -1,12 +0,0 @@
import { applyCustomMigrationsAsync } from ".";
import { database } from "../../driver";
applyCustomMigrationsAsync(database)
.then(() => {
console.log("Custom migrations applied successfully");
process.exit(0);
})
.catch((err) => {
console.log("Failed to apply custom migrations\n\t", err);
process.exit(1);
});
@@ -1,6 +0,0 @@
CREATE TABLE `cron_job_configuration` (
`name` varchar(256) NOT NULL,
`cron_expression` varchar(32) NOT NULL,
`is_enabled` boolean NOT NULL DEFAULT true,
CONSTRAINT `cron_job_configuration_name` PRIMARY KEY(`name`)
);
File diff suppressed because it is too large. Load diff
@@ -232,13 +232,6 @@
"when": 1746821770071,
"tag": "0032_add_trusted_certificate_hostnames",
"breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1750013953833,
"tag": "0033_add_cron_job_configuration",
"breakpoints": true
}
]
}
-2
View File
@@ -5,7 +5,6 @@ import mysql from "mysql2";
import type { Database } from "../..";
import { env } from "../../env";
import * as mysqlSchema from "../../schema/mysql";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
@@ -31,7 +30,6 @@ const migrateAsync = async () => {
await migrate(db, { migrationsFolder });
await seedDataAsync(db as unknown as Database);
await applyCustomMigrationsAsync(db as unknown as Database);
};
migrateAsync()
+2 -60
View File
@@ -1,11 +1,5 @@
import { objectKeys } from "@homarr/common";
import {
createDocumentationLink,
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
integrationKinds,
} from "@homarr/definitions";
import { createDocumentationLink, everyoneGroup } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
@@ -15,8 +9,7 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import { integrations, onboarding, searchEngines } from "../schema";
import type { Integration } from "../schema";
import { onboarding, searchEngines } from "../schema";
import { groups } from "../schema/mysql";
export const seedDataAsync = async (db: Database) => {
@@ -24,7 +17,6 @@ export const seedDataAsync = async (db: Database) => {
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -139,53 +131,3 @@ const seedServerSettingsAsync = async (db: Database) => {
console.log(`Updated serverSetting through seed key=${settingsKey}`);
}
};
const seedDefaultIntegrationsAsync = async (db: Database) => {
const defaultIntegrations = integrationKinds.reduce<Integration[]>((acc, kind) => {
const name = getIntegrationName(kind);
const defaultUrl = getIntegrationDefaultUrl(kind);
if (defaultUrl !== undefined) {
acc.push({
id: "new",
name: `${name} Default`,
url: defaultUrl,
kind,
});
}
return acc;
}, []);
if (defaultIntegrations.length === 0) {
console.warn("No default integrations found to seed");
return;
}
let createdCount = 0;
await Promise.all(
defaultIntegrations.map(async (integration) => {
const existingKind = await db.$count(integrations, eq(integrations.kind, integration.kind));
if (existingKind > 0) {
console.log(`Skipping seeding of default ${integration.kind} integration as one already exists`);
return;
}
const newIntegration = {
...integration,
id: createId(),
};
await db.insert(integrations).values(newIntegration);
createdCount++;
}),
);
if (createdCount === 0) {
console.log("No default integrations were created as they already exist");
return;
}
console.log(`Created ${createdCount} default integrations through seeding process`);
};
@@ -1,5 +0,0 @@
CREATE TABLE `cron_job_configuration` (
`name` text PRIMARY KEY NOT NULL,
`cron_expression` text NOT NULL,
`is_enabled` integer DEFAULT true NOT NULL
);
File diff suppressed because it is too large. Load diff
@@ -232,13 +232,6 @@
"when": 1746821779051,
"tag": "0032_add_trusted_certificate_hostnames",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1750014001941,
"tag": "0033_add_cron_job_configuration",
"breakpoints": true
}
]
}
-2
View File
@@ -4,7 +4,6 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { env } from "../../env";
import * as sqliteSchema from "../../schema/sqlite";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
@@ -17,7 +16,6 @@ const migrateAsync = async () => {
migrate(db, { migrationsFolder });
await seedDataAsync(db);
await applyCustomMigrationsAsync(db);
};
migrateAsync()
+13 -15
View File
@@ -23,13 +23,12 @@
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"migration:custom": "pnpm with-env tsx ./migrations/custom/run-custom.ts",
"migration:mysql:drop": "pnpm with-env drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:mysql:generate": "pnpm with-env drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed && pnpm run migration:custom",
"migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed",
"migration:sqlite:drop": "pnpm with-env drizzle-kit drop --config ./configs/sqlite.config.ts",
"migration:sqlite:generate": "pnpm with-env drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed && pnpm run migration:custom",
"migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed",
"push:mysql": "pnpm with-env drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "pnpm with-env drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
@@ -39,22 +38,21 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.40.0",
"@auth/core": "^0.39.1",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^8.1.3",
"@mantine/core": "^8.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^11.2.1",
"better-sqlite3": "^12.2.0",
"dotenv": "^17.2.0",
"drizzle-kit": "^0.31.4",
"@testcontainers/mysql": "^11.0.3",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.1",
"drizzle-orm": "^0.44.2",
"drizzle-zod": "^0.7.1",
"mysql2": "3.14.2",
"superjson": "2.2.2"
"mysql2": "3.14.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -62,10 +60,10 @@
"@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",
"prettier": "^3.6.2",
"tsx": "4.20.3",
"esbuild": "^0.25.5",
"eslint": "^9.28.0",
"prettier": "^3.5.3",
"tsx": "4.20.1",
"typescript": "^5.8.3"
}
}
-1
View File
@@ -40,7 +40,6 @@ export const {
itemLayouts,
sectionLayouts,
trustedCertificateHostnames,
cronJobConfigurations,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
-6
View File
@@ -508,12 +508,6 @@ export const trustedCertificateHostnames = mysqlTable(
}),
);
export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
isEnabled: boolean().default(true).notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
-6
View File
@@ -493,12 +493,6 @@ export const trustedCertificateHostnames = sqliteTable(
}),
);
export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
name: text().notNull().primaryKey(),
cronExpression: text().notNull(),
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
+3 -3
View File
@@ -25,14 +25,14 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"fast-xml-parser": "^5.2.5",
"zod": "^3.25.76"
"zod": "^3.25.62"
},
"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",
"tsx": "4.20.3",
"eslint": "^9.28.0",
"tsx": "4.20.1",
"typescript": "^5.8.3"
}
}
@@ -46,7 +46,6 @@ export type HomarrDocumentationPath =
| "/docs/tags/analytics"
| "/docs/tags/api"
| "/docs/tags/apps"
| "/docs/tags/background"
| "/docs/tags/banner"
| "/docs/tags/blocking"
| "/docs/tags/boards"
@@ -90,7 +89,6 @@ export type HomarrDocumentationPath =
| "/docs/tags/integrations"
| "/docs/tags/interface"
| "/docs/tags/jellyserr"
| "/docs/tags/jobs"
| "/docs/tags/layout"
| "/docs/tags/ldap"
| "/docs/tags/links"
@@ -100,7 +98,6 @@ export type HomarrDocumentationPath =
| "/docs/tags/media"
| "/docs/tags/minecraft"
| "/docs/tags/monitoring"
| "/docs/tags/network"
| "/docs/tags/news"
| "/docs/tags/notebook"
| "/docs/tags/notes"
@@ -133,13 +130,11 @@ export type HomarrDocumentationPath =
| "/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"
@@ -195,7 +190,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/kubernetes"
| "/docs/integrations/media-requester"
| "/docs/integrations/media-server"
| "/docs/integrations/network"
| "/docs/integrations/servarr"
| "/docs/integrations/torrent"
| "/docs/integrations/usenet"
@@ -207,7 +201,6 @@ export type HomarrDocumentationPath =
| "/docs/management/media"
| "/docs/management/search-engines"
| "/docs/management/settings"
| "/docs/management/tasks"
| "/docs/management/users"
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
@@ -221,7 +214,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/media-requests"
| "/docs/widgets/media-server"
| "/docs/widgets/minecraft-server-status"
| "/docs/widgets/network-controller"
| "/docs/widgets/notebook"
| "/docs/widgets/releases"
| "/docs/widgets/rss"
+109 -89
View File
@@ -7,8 +7,6 @@ export const integrationSecretKindObject = {
password: { isPublic: false },
tokenId: { isPublic: true },
realm: { isPublic: true },
personalAccessToken: { isPublic: false },
topic: { isPublic: true },
} satisfies Record<string, { isPublic: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -18,7 +16,7 @@ interface integrationDefinition {
iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>;
defaultUrl?: string; // optional default URL for the integration
containerImages?: string[]; // Optional, used for container images to suggest in the UI
}
export const integrationDefs = {
@@ -27,30 +25,45 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
category: ["downloadClient", "usenet"],
containerImages: ["linuxserver/sabnzbd", "lscr.io/linuxserver/sabnzbd", "ghcr.io/linuxserver/sabnzbd"],
},
nzbGet: {
name: "NZBGet",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
category: ["downloadClient", "usenet"],
containerImages: [
"nzbgetcom/nzbget",
"ghcr.io/nzbgetcom/nzbget",
"linuxserver/nzbget",
"lscr.io/linuxserver/nzbget",
"ghcr.io/linuxserver/nzbget",
],
},
deluge: {
name: "Deluge",
secretKinds: [["password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
category: ["downloadClient", "torrent"],
containerImages: ["linuxserver/deluge", "lscr.io/linuxserver/deluge", "ghcr.io/linuxserver/deluge"],
},
transmission: {
name: "Transmission",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
category: ["downloadClient", "torrent"],
containerImages: [
"linuxserver/transmission",
"lscr.io/linuxserver/transmission",
"ghcr.io/linuxserver/transmission",
],
},
qBittorrent: {
name: "qBittorrent",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
containerImages: ["linuxserver/qbittorrent", "lscr.io/linuxserver/qbittorrent", "ghcr.io/linuxserver/qbittorrent"],
},
aria2: {
name: "Aria2",
@@ -63,78 +76,144 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar"],
containerImages: [
"hotio/sonarr",
"ghcr.io/hotio/sonarr",
"linuxserver/sonarr",
"lscr.io/linuxserver/sonarr",
"ghcr.io/linuxserver/sonarr",
],
},
radarr: {
name: "Radarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar"],
containerImages: [
"hotio/radarr",
"ghcr.io/hotio/radarr",
"linuxserver/radarr",
"lscr.io/linuxserver/radarr",
"ghcr.io/linuxserver/radarr",
],
},
lidarr: {
name: "Lidarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
category: ["calendar"],
containerImages: [
"hotio/lidarr",
"ghcr.io/hotio/lidarr",
"linuxserver/lidarr",
"lscr.io/linuxserver/lidarr",
"ghcr.io/linuxserver/lidarr",
],
},
readarr: {
name: "Readarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
category: ["calendar"],
containerImages: [
"hotio/readarr",
"ghcr.io/hotio/readarr",
"linuxserver/readarr",
"lscr.io/linuxserver/readarr",
"ghcr.io/linuxserver/readarr",
],
},
prowlarr: {
name: "Prowlarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
category: ["indexerManager"],
containerImages: [
"hotio/prowlarr",
"ghcr.io/hotio/prowlarr",
"linuxserver/prowlarr",
"lscr.io/linuxserver/prowlarr",
"ghcr.io/linuxserver/prowlarr",
],
},
jellyfin: {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService"],
containerImages: [
"jellyfin/jellyfin",
"ghcr.io/jellyfin/jellyfin",
"linuxserver/jellyfin",
"lscr.io/linuxserver/jellyfin",
"ghcr.io/linuxserver/jellyfin",
],
},
emby: {
name: "Emby",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService"],
containerImages: [
"emby/embyserver",
"emby/embyserver_arm64v8",
"emby/embyserver_arm32v7",
"linuxserver/emby",
"lscr.io/linuxserver/emby",
"ghcr.io/linuxserver/emby",
],
},
plex: {
name: "Plex",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService"],
containerImages: ["plexinc/pms-docker", "linuxserver/plex", "lscr.io/linuxserver/plex", "ghcr.io/linuxserver/plex"],
},
jellyseerr: {
name: "Jellyseerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
containerImages: ["fallenbagel/jellyseerr", "ghcr.io/fallenbagel/jellyseerr"],
},
overseerr: {
name: "Overseerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
containerImages: [
"sct/overseerr",
"linuxserver/overseerr",
"lscr.io/linuxserver/overseerr",
"ghcr.io/linuxserver/overseerr",
],
},
piHole: {
name: "Pi-hole",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
category: ["dnsHole"],
containerImages: ["pihole/pihole"],
},
adGuardHome: {
name: "AdGuard Home",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
category: ["dnsHole"],
containerImages: ["adguard/adguardhome"],
},
homeAssistant: {
name: "Home Assistant",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
category: ["smartHomeServer"],
containerImages: [
"homeassistant/home-assistant",
"linuxserver/home-assistant",
"lscr.io/linuxserver/home-assistant",
"ghcr.io/linuxserver/home-assistant",
],
},
openmediavault: {
name: "OpenMediaVault",
@@ -147,12 +226,14 @@ export const integrationDefs = {
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
category: ["healthMonitoring"],
containerImages: ["mauricenino/dashdot", "ghcr.io/mauricenino/dashdot"],
},
tdarr: {
name: "Tdarr",
secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
category: ["mediaTranscoding"],
containerImages: ["haveagitgat/tdarr", "ghcr.io/haveagitgat/tdarr"],
},
proxmox: {
name: "Proxmox",
@@ -171,65 +252,15 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
},
github: {
name: "Github",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.github.com",
},
dockerHub: {
name: "Docker Hub",
secretKinds: [[], ["username", "personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
category: ["releasesProvider"],
defaultUrl: "https://hub.docker.com",
},
gitlab: {
name: "Gitlab",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg",
category: ["releasesProvider"],
defaultUrl: "https://gitlab.com",
},
npm: {
name: "NPM",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg",
category: ["releasesProvider"],
defaultUrl: "https://registry.npmjs.org",
},
codeberg: {
name: "Codeberg",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg",
category: ["releasesProvider"],
defaultUrl: "https://codeberg.org",
},
ntfy: {
name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"],
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/vitest.svg",
category: [
"calendar",
"dnsHole",
"downloadClient",
"healthMonitoring",
"indexerManager",
"mediaRequest",
"mediaService",
"mediaTranscoding",
"networkController",
"notifications",
"smartHomeServer",
containerImages: [
"jacobalberty/unifi",
"ghcr.io/jacobalberty/unifi",
"linuxserver/unifi-controller",
"lscr.io/linuxserver/unifi-controller",
"ghcr.io/linuxserver/unifi-controller",
"linuxserver/unifi-network-application",
"lscr.io/linuxserver/unifi-network-application",
"ghcr.io/linuxserver/unifi-network-application",
],
},
} as const satisfies Record<string, integrationDefinition>;
@@ -246,11 +277,6 @@ export const getDefaultSecretKinds = (integration: IntegrationKind): Integration
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> =>
integrationDefs[integration].secretKinds;
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
const definition = integrationDefs[integration];
return "defaultUrl" in definition ? definition.defaultUrl : undefined;
};
/**
* Get all integration kinds that share a category, typed only by the kinds belonging to the category
* @param category Category to filter by, belonging to IntegrationCategory
@@ -276,25 +302,19 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
export type IntegrationKind = keyof typeof integrationDefs;
export const integrationCategories = [
"dnsHole",
"mediaService",
"calendar",
"mediaSearch",
"mediaRequest",
"downloadClient",
"usenet",
"torrent",
"miscellaneous",
"smartHomeServer",
"indexerManager",
"healthMonitoring",
"search",
"mediaTranscoding",
"networkController",
"releasesProvider",
"notifications",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
export type IntegrationCategory =
| "dnsHole"
| "mediaService"
| "calendar"
| "mediaSearch"
| "mediaRequest"
| "downloadClient"
| "usenet"
| "torrent"
| "miscellaneous"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring"
| "search"
| "mediaTranscoding"
| "networkController";
-1
View File
@@ -25,6 +25,5 @@ export const widgetKinds = [
"healthMonitoring",
"releases",
"dockerContainers",
"notifications",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+2 -2
View File
@@ -32,8 +32,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.42",
"eslint": "^9.31.0",
"@types/dockerode": "^3.3.40",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { env } from "./env";
export interface DockerInstance {
host: string;
instance: Docker;
isSocket?: boolean;
}
export class DockerSingleton {
@@ -14,7 +15,7 @@ export class DockerSingleton {
const hostVariable = env.DOCKER_HOSTNAMES;
const portVariable = env.DOCKER_PORTS;
if (hostVariable === undefined || portVariable === undefined) {
return [{ host: "socket", instance: new Docker() }];
return [{ host: "socket", instance: new Docker(), isSocket: true }];
}
const hostnames = hostVariable.split(",");
const ports = portVariable.split(",");
+2 -2
View File
@@ -24,13 +24,13 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.8",
"zod": "^3.25.76"
"zod": "^3.25.62"
},
"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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+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",
"zod": "^3.25.76"
"@mantine/form": "^8.1.0",
"zod": "^3.25.62"
},
"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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+3 -3
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",
"@mantine/core": "^8.1.0",
"react": "19.1.0",
"zod": "^3.25.76"
"zod": "^3.25.62"
},
"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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+1 -1
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",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
+5 -7
View File
@@ -28,7 +28,6 @@
"@ctrl/deluge": "^7.1.0",
"@ctrl/qbittorrent": "^9.6.0",
"@ctrl/transmission": "^7.2.0",
"@gitbeaker/rest": "^42.5.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -39,14 +38,13 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
"maria2": "^0.4.1",
"maria2": "^0.4.0",
"node-ical": "^0.20.1",
"octokit": "^5.0.3",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.5",
"undici": "7.11.0",
"tsdav": "^2.1.4",
"undici": "7.10.0",
"xml2js": "^0.6.2",
"zod": "^3.25.76"
"zod": "^3.25.62"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -54,7 +52,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "^2.5.1",
"@types/xml2js": "^0.4.14",
"eslint": "^9.31.0",
"eslint": "^9.28.0",
"typescript": "^5.8.3"
}
}
-14
View File
@@ -4,9 +4,7 @@ import type { Integration as DbIntegration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DockerHubIntegration } from "../docker-hub/docker-hub-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
@@ -14,8 +12,6 @@ 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 { 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";
@@ -24,10 +20,7 @@ import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
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 { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
@@ -99,13 +92,6 @@ export const integrationCreators = {
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
github: GithubIntegration,
dockerHub: DockerHubIntegration,
gitlab: GitlabIntegration,
npm: NPMIntegration,
codeberg: CodebergIntegration,
ntfy: NTFYIntegration,
mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -1,143 +0,0 @@
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 {
DetailsProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas";
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({});
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("/version"), {
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 Codeberg integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const details = await this.getDetailsAsync(owner, name);
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
{ 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: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
},
};
} else {
const formattedReleases = data.map((tag) => ({
latestRelease: tag.tag_name,
latestReleaseAt: tag.published_at,
releaseUrl: tag.url,
releaseDescription: tag.body,
isPreRelease: tag.prerelease,
}));
return getLatestRelease(formattedReleases, repository, details);
}
}
protected async getDetailsAsync(owner: string, name: string): Promise<DetailsProviderResponse | undefined> {
const response = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
{
headers,
},
);
});
if (!response.ok) {
localLogger.warn(`Failed to get details response for ${owner}/${name} with Codeberg integration`, {
owner,
name,
error: response.statusText,
});
return undefined;
}
const responseJson = await response.json();
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
if (!success) {
localLogger.warn(`Failed to parse details response for ${owner}/${name} with Codeberg integration`, {
owner,
name,
error,
});
return undefined;
}
return {
projectUrl: data.html_url,
projectDescription: data.description,
isFork: data.fork,
isArchived: data.archived,
createdAt: data.created_at,
starsCount: data.stars_count,
openIssues: data.open_issues_count,
forksCount: data.forks_count,
};
}
}
@@ -1,22 +0,0 @@
import { z } from "zod";
export const releasesResponseSchema = z.array(
z.object({
tag_name: z.string(),
published_at: z.string().transform((value) => new Date(value)),
url: z.string(),
body: z.string(),
prerelease: z.boolean(),
}),
);
export const detailsResponseSchema = z.object({
html_url: z.string(),
description: z.string(),
fork: z.boolean(),
archived: z.boolean(),
created_at: z.string().transform((value) => new Date(value)),
stars_count: z.number(),
open_issues_count: z.number(),
forks_count: z.number(),
});
@@ -12,10 +12,9 @@ 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 { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import type { HealthMonitoring } from "../types";
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
export class DashDotIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/info"));
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -27,7 +26,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
};
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync();
@@ -1,190 +0,0 @@
import type { fetch, RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { logger } from "@homarr/log";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
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,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas";
const localLogger = logger.child({ module: "DockerHubIntegration" });
export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration {
private readonly sessionStore: SessionStore<string>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) return await callback({});
const storedSession = await this.sessionStore.getAsync();
if (storedSession) {
localLogger.debug("Using stored session for request", { integrationId: this.integration.id });
const response = await callback({
Authorization: `Bearer ${storedSession}`,
});
if (response.status !== 401) {
return response;
}
localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id });
}
const accessToken = await this.getSessionAsync();
await this.sessionStore.setAsync(accessToken);
return await callback({
Authorization: `Bearer ${accessToken}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken");
if (hasAuth) {
localLogger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id });
await this.getSessionAsync(input.fetchAsync);
} else {
localLogger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id });
const response = await input.fetchAsync(this.url("/v2/repositories/library"));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const relativeUrl = this.getRelativeUrl(repository.identifier);
if (relativeUrl === "/") {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name' or 'name', for ${repository.identifier} on DockerHub`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const details = await this.getDetailsAsync(relativeUrl);
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100`), {
headers,
});
});
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson);
if (!releasesResult.success) {
return {
id: repository.id,
error: {
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message,
},
};
} else {
return getLatestRelease(releasesResult.data.results, repository, details);
}
}
private async getDetailsAsync(relativeUrl: `/${string}`): Promise<DetailsProviderResponse | undefined> {
const response = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), {
headers,
});
});
if (!response.ok) {
localLogger.warn(`Failed to get details response for ${relativeUrl} with DockerHub integration`, {
relativeUrl,
error: response.statusText,
});
return undefined;
}
const responseJson = await response.json();
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
if (!success) {
localLogger.warn(`Failed to parse details response for ${relativeUrl} with DockerHub integration`, {
relativeUrl,
error,
});
return undefined;
}
return {
projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`,
projectDescription: data.description,
createdAt: data.date_registered,
starsCount: data.star_count,
};
}
private getRelativeUrl(identifier: string): `/${string}` {
if (identifier.indexOf("/") > 0) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "/";
}
return `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`;
} else {
return `/v2/repositories/library/${encodeURIComponent(identifier)}`;
}
}
private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise<string> {
const response = await fetchAsync(this.url("/v2/auth/token"), {
method: "POST",
body: JSON.stringify({
identifier: this.getSecretValue("username"),
secret: this.getSecretValue("personalAccessToken"),
}),
});
if (!response.ok) throw new ResponseError(response);
const data = await response.json();
const result = await accessTokenResponseSchema.parseAsync(data);
if (!result.access_token) {
throw new ResponseError({ status: 401, url: response.url });
}
localLogger.info("Received session successfully", { integrationId: this.integration.id });
return result.access_token;
}
}
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more