Compare commits

...
Author SHA1 Message Date
Thomas Camlong 8203ca0c63 fix: add lazyConnect to Redis client to prevent build-time connection errors 2026-05-22 09:31:29 +02:00
Thomas Camlong 5e5492818a test: add tests for Redis lazy-init and startup retry logic 2026-05-22 09:06:40 +02:00
Thomas Camlong a6ec5f789d fix: resolve Redis null client crashes on startup
After #5600 merged tasks/websocket into the Next.js process,
`createRedisConnection()` returns `null` because `CI=true` is baked into
the bundle at build time. This causes `TypeError: Cannot read properties
of null` on every `.publish()` / `.get()` call during cron job startup.

- Remove the CI/DISABLE_REDIS_LOGS null guard from createRedisConnection
- Lazy-init all Redis clients to avoid build-time evaluation
- Add a Redis readiness retry loop before starting cron jobs
2026-05-21 16:33:43 +02:00
ThomasandManuel dade477b32 feat(seed): add demo mode with pre-seeded user (#5739)
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
2026-05-21 16:08:46 +02:00
Thomas 79f88dc3b8 ci: add --affected filtering and fix Turbo cache invalidation (#5740)
* ci: add --affected filtering and fix Turbo cache invalidation

- Remove globalDependencies **/.env which invalidated all caches on any .env change
- Move 40+ env vars from globalEnv to build-specific env (lint/format/typecheck don't need DB_URL etc.)
- Add per-task inputs with test file exclusions so test changes don't trigger build/typecheck
- Enable affectedUsingTaskInputs for task-level --affected detection
- Add --affected to all turbo commands in CI (lint, format, typecheck, build)
- Add fetch-depth: 0 for proper git history comparison
- Add Docker Buildx + GHA layer caching for E2E builds

* ci: add Cloudflare remote cache for Turborepo

Configure turborepo-remote-cache-cloudflare as the remote cache backend.
This commit can be reverted independently to disable remote caching.

* fix(ci): set TURBO_SCM_BASE for --affected to work in PRs

Turbo defaults to comparing against `main`, but PRs target `dev`.
Without explicit base/head refs, --affected falls back to running
all tasks. Use the PR base SHA for pull_requests, the before SHA
for pushes, and HEAD~1 as a last resort.

* perf(typecheck): exclude bundled .cjs/.css from tsc

websocket (835K lines) and tasks (755K lines) had bundled output
files being typechecked due to `include: ["."]` in their tsconfig.
Full typecheck drops from ~70s to ~26s.

* perf(turbo): scope topo task inputs to package.json only

The topo virtual task had no explicit inputs, defaulting to all files.
This caused any source change to cascade cache invalidation through
all dependent packages via ^topo. Since topo is only for dependency
ordering, its hash should only change when dependencies change.

* fix(turbo): disable remote cache signature verification

The cache lives on a private CF worker, signature verification
just adds noise without security benefit.

* fix(turbo): remove runtime env vars from build cache key

These env vars (DB_*, AUTH_*, DOCKER_*, etc.) are all runtime
configuration provided by users when running the Docker container.
They don't influence the build output since Next.js standalone mode
reads process.env at runtime and SKIP_ENV_VALIDATION=true skips
validation during build. Listing them in build.env caused unnecessary
cache busting in local dev.

Also restores coverage report comments per review feedback.

* fix(turbo): include test files in typecheck cache inputs

Test files must be typechecked — excluding them from typecheck inputs
caused changed tests to skip type validation.
2026-05-21 14:11:27 +02:00
homarr-renovate[bot] 4a12f8be61 chore(deps): update dependency hono@<4.6.5 to >=4.12.19 (#5762)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-21 11:33:05 +00:00
homarr-renovate[bot] 044ad9edf9 fix(deps): update dependency better-sqlite3 to ^12.10.0 (#5727) 2026-05-21 07:16:58 +02:00
homarr-renovate[bot] 57d2c6a7e1 chore(deps): update dependency eslint to ^10.4.0 (#5761)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-20 16:59:38 +00:00
homarr-renovate[bot]andMeier Lukas 46f6301fdd chore(deps): update turbo monorepo to ^2.9.14 (#5754)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-20 17:05:07 +02:00
homarr-renovate[bot]andMeier Lukas c3f300d87d chore(deps): update dependency turbo to v2.9.14 [security] (#5760)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-20 16:54:18 +02:00
homarr-renovate[bot] 1de558dd51 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.13 (#5749)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-20 13:46:33 +00:00
homarr-renovate[bot] 2711f3db13 chore(deps): update dependency turbo to v2.9.14 [security] (#5759)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-20 10:57:42 +00:00
homarr-renovate[bot] ff3933b335 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.60.4 (#5752)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-20 07:50:23 +00:00
Thomas fa737638a8 fix: exclude .env files from Docker build context (#5738) 2026-05-20 09:05:02 +02:00
homarr-renovate[bot] 97b7c417ab chore(deps): update dependency isomorphic-dompurify to ^3.13.0 (#5750)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-19 12:46:32 +00:00
homarr-renovate[bot] 58d8005d38 chore(deps): update dependency tsdav to ^2.2.2 (#5748)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-19 05:08:44 +00:00
homarr-renovate[bot] 507ac5181f chore(deps): update dependency @playwright/test to ^1.60.0 (#5746)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-19 00:14:25 +00:00
homarr-renovate[bot] b0cdb81533 chore(deps): update tiptap monorepo to v3.23.4 (#5743)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-18 22:10:37 +00:00
homarr-renovate[bot] fc0367a32f chore(deps): update dependency hono@<4.6.5 to >=4.12.18 (#5745)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-18 20:31:45 +00:00
Meier Lukas 7cd30b640c fix(docker): improve icon matching (#5693)
* fix(docker): improve icon matching

* fix: unused import
2026-05-18 21:45:33 +02:00
Meier LukasandThomas a9855fe8d3 chore(deps): remove deprecated million lint (#5671)
* chore(deps): remove deprecated million lint

* fix: install now missing playwright dependency

* fix: broken lockfile

* fix: broken lockfile

---------

Co-authored-by: Thomas <thomas@ajnart.fr>
2026-05-18 21:42:02 +02:00
StrandedTurtleandMeier Lukas 7a1272146c fix(tracearr): support absolute image urls (#5654)
* Update base URL handling for tracearr integration

* fix: remove unnecessary prefix from bcrypt hashing

* refactor: add is-absolute-url check

* test: add unit tests

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-18 21:39:01 +02:00
homarr-renovate[bot] db61b6f390 chore(deps): update dependency next-intl to v4.12.0 (#5734)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-18 19:02:14 +00:00
homarr-renovate[bot] 887c294191 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.16.1 (#5742)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-18 16:31:32 +00:00
homarr-renovate[bot] 2f2a6291a2 chore(deps): update dependency linkifyjs@<4.3.2 to >=4.3.3 (#5732)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-18 04:30:21 +00:00
homarr-renovate[bot] 1b3353118d chore(deps): update tiptap monorepo to v3.23.2 (#5731)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-17 18:06:09 +00:00
homarr-renovate[bot] e03f56ac31 chore(deps): update dependency ws to ^8.20.1 (#5729)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-17 16:10:10 +00:00
homarr-renovate[bot] d571a7f75e chore(deps): update dependency swagger-ui-react to ^5.32.6 (#5726)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-17 11:13:32 +00:00
homarr-renovate[bot] e3b765b9bd chore(deps): update dependency fast-xml-parser to ^5.8.0 (#5725)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-17 06:51:41 +00:00
homarr-renovate[bot] 4da313826d chore(deps): update dependency @types/node to ^24.12.4 (#5724)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-16 22:53:08 +00:00
homarr-renovate[bot] 948ed7a7fa chore(deps): update vitest monorepo to ^4.1.6 (#5723)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-16 16:08:51 +00:00
homarr-renovate[bot] f7fc64ebc4 chore(deps): update tanstack-query monorepo to ^5.100.10 (#5722)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-16 15:04:21 +00:00
homarr-renovate[bot] b7f51d6f5c chore(deps): update dependency next-intl to v4.11.2 (#5721)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-16 11:58:47 +00:00
homarr-renovate[bot] e185eeb6d7 chore(deps): update dependency yaml to v2.9.0 (#5720)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-16 10:25:24 +00:00
homarr-renovate[bot] 90d703b870 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.12 (#5719)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-16 08:54:57 +00:00
75640f21e0 chore: merge current beta into dev (#5718)
* ci: support releases on next branch (#5599)

Co-authored-by: Copilot <copilot@github.com>

* perf(memory): combine 3 node processes into one (#5600)

Co-authored-by: Copilot <copilot@github.com>

* ci: add beta release workflow (#5601)

Co-authored-by: Copilot <copilot@github.com>

* chore(release): 1.60.1-beta.1 [skip ci]

* chore: retrigger release v1.60.1-beta.1

* chore(release): 1.60.1-beta.1 [skip ci]

* ci: disable package-manager cache

* chore(release): 1.60.1-beta.1 [skip ci]

* perf: (next branch) reduce idle memory on startup (#5637)

* chore(release): 1.60.1-beta.2 [skip ci]

* chore(release): 1.61.1-beta.1 [skip ci]

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-05-15 19:35:55 +02:00
Meier Lukas 4aa43ae27a Merge branch 'dev' into beta [skip-ci] 2026-05-15 19:25:38 +02:00
homarr-update-contributors[bot] 6b67671e29 chore: update contributors (#5717)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-05-15 13:59:07 +00:00
homarr-renovate[bot] 220f361b69 chore(deps): update turbo monorepo to ^2.9.12 (#5712)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-14 10:24:19 +00:00
homarr-renovate[bot] a1d6457ced chore(deps): update semantic-release monorepo (#5709)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-14 07:50:20 +00:00
homarr-renovate[bot] f7ade3c676 chore(deps): update dependency @tabler/icons-react to ^3.44.0 (#5711)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-14 04:15:07 +00:00
homarr-renovate[bot] f656d9ca2d chore(deps): update turbo monorepo to ^2.9.11 (#5710)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-14 00:07:48 +00:00
homarr-renovate[bot] cefd90385d chore(deps): update dependency next-intl to v4.11.1 (#5707)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-13 15:51:14 +00:00
homarr-renovate[bot] e5021b1d78 chore(deps): update tiptap monorepo to v3.23.1 (#5708)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-13 12:16:00 +00:00
homarr-renovate[bot] f0bd5132a3 chore(deps): update dependency brace-expansion@>=2.0.0 <=2.0.1 to >=5.0.6 (#5706)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-13 09:52:26 +00:00
homarr-renovate[bot] d356fa41a0 chore(deps): update dependency brace-expansion@>=1.0.0 <=1.1.11 to >=5.0.6 (#5705)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-13 06:55:25 +00:00
homarr-renovate[bot] a9e9ac1cfa chore(deps): update dependency @types/node to ^24.12.3 (#5704)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-12 23:09:48 +00:00
homarr-renovate[bot] 1b3e720701 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.11 (#5701)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-12 21:28:14 +00:00
homarr-renovate[bot] 2461363d4f chore(deps): update dependency @next/eslint-plugin-next to v16.2.6 (#5703)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-12 20:02:16 +00:00
homarr-renovate[bot] f29e74b6f4 chore(deps): update turbo monorepo to ^2.9.10 (#5702)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-12 17:19:38 +00:00
homarr-renovate[bot] 89dfe82fe9 chore(deps): update dependency @tabler/icons-react to ^3.43.0 (#5700)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-11 23:02:13 +00:00
homarr-renovate[bot] 48a0f75c31 chore(deps): update react monorepo to v19.2.6 (#5698)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-11 21:25:44 +00:00
homarr-renovate[bot] 37103cf414 chore(deps): update dependency next to v16.2.6 [security] (#5699)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-11 19:56:51 +00:00
homarr-renovate[bot] dec34916b2 chore(deps): update nextjs monorepo to v16.2.5 (#5697)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-11 21:03:28 +02:00
homarr-renovate[bot] 64a4c196c0 chore(deps): update dependency tsdav to ^2.2.1 (#5696)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-11 17:33:08 +00:00
homarr-renovate[bot] 41d596a1c9 chore(deps): update pnpm to v10.33.4 (#5695)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-11 15:01:42 +00:00
homarr-renovate[bot] bac0bc7b84 chore(deps): update dependency jotai to ^2.20.0 (#5694)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-11 14:00:57 +00:00
homarr-renovate[bot] 1ac6774821 chore(deps): update pnpm to v10.33.3 (#5689)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-10 15:00:34 +00:00
homarr-renovate[bot] 7adb21ca59 chore(deps): update dependency @tabler/icons-react to ^3.42.0 (#5691)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-10 13:27:39 +00:00
homarr-renovate[bot] 4daf2729e5 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.60.3 (#5688)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-10 11:59:24 +00:00
homarr-renovate[bot] 1942c32128 chore(deps): update turbo monorepo to ^2.9.9 (#5690)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-10 10:24:31 +00:00
homarr-renovate[bot] b061a59433 chore(deps): update dependency fast-xml-parser to ^5.7.3 (#5692)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-10 09:01:25 +00:00
Meier Lukas ab867a9e50 chore: remove temporary ts-expect-error 2026-05-10 10:40:29 +02:00
homarr-crowdin[bot] 968338aa97 chore(lang): updated translations from crowdin (#5685)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-05-10 10:38:59 +02:00
Releases Homarr b34102a973 chore(release): 1.61.1-beta.1 [skip ci] 2026-05-08 21:55:01 +00:00
homarr-releases[bot] 378d1e1f1f chore(release): release v1.61.1-beta 2026-05-08 21:53:57 +00:00
Meier Lukas 3a8abe4415 chore(docs): update sitemap 2026-05-08 23:48:31 +02:00
Meier Lukas 6feb37dfd4 Merge branch 'main' into next 2026-05-08 23:46:21 +02:00
Meier Lukas 8042a18dc1 Merge branch 'main' into dev 2026-05-08 22:20:35 +02:00
homarr-releases[bot] c78bf05fc3 chore: update bug report template (#5682)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-05-08 20:17:34 +00:00
Releases Homarr 4635c51fe1 chore(release): 1.61.0 [skip ci] 2026-05-08 20:06:52 +00:00
homarr-releases[bot] 5cc0f97aa2 chore(release): automatic release v1.61.0 2026-05-08 20:05:49 +00:00
homarr-renovate[bot] b581f9b11a chore(deps): update dependency @extractus/feed-extractor to v7.2.1 (#5670)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-08 19:26:12 +00:00
Meier Lukas 756e8d1724 feat(qbittorrent): add api-key support (#5677) 2026-05-08 20:08:37 +02:00
homarr-renovate[bot] 7c64de5cbc chore(deps): update turbo monorepo to ^2.9.8 (#5669)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-08 17:25:39 +00:00
Ashton BattyandMeier Lukas 56af6bcaf0 feat(docker): Podman compatibility, multi-socket support, per-host error isolation (#5425)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-08 17:31:56 +02:00
homarr-renovate[bot] 84b269a80d chore(deps): update tanstack-query monorepo to ^5.100.9 (#5678)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-08 15:30:26 +00:00
Borislaw SotnikowandMeier Lukas 4d30a2e8c4 feat(installations): support secrets file for all environment variables (#5125)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-08 17:11:08 +02:00
Meier Lukas 2d15b3d407 chore(deps): update dependency zod to v4.4.3 (#5675) 2026-05-08 17:10:22 +02:00
Manuel 44a2973b1a revert(deps): downgrade pnpm to v10 (#5673) 2026-05-08 16:03:12 +02:00
Manuel 00a055283e fix(qbittorrent): support version 5.2 (#5672) 2026-05-08 15:44:04 +02:00
Manuel a474dcd961 chore: exclude renovate managed files from codeowners (#5676) 2026-05-08 15:37:41 +02:00
homarr-update-contributors[bot] 69fe180968 chore: update contributors (#5674)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-05-08 13:25:53 +00:00
036f6aa185 chore(deps): update dependency recharts to v3 (#4302)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-08 15:02:52 +02:00
homarr-crowdin[bot]andMeier Lukas 12a1bd7e2e chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-08 13:54:36 +02:00
homarr-renovate[bot] c4ec45a9d0 chore(deps): update pnpm to v11.0.4 (#5668)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-08 03:04:44 +00:00
homarr-renovate[bot]andMeier Lukas b8e0123893 chore(deps): update pnpm to v11 (#5660)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-07 22:46:55 +02:00
homarr-renovate[bot] df953771a7 chore(deps): update dependency axios@&gt;&#x3d;1.0.0 &lt;1.8.2 to >=1.16.0 (#5665)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-07 20:22:00 +00:00
44bd16d677 fix(calendar): keep working integrations when one fails (#5629)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-07 22:02:21 +02:00
sharpsalatandsharpsalat 277a509f49 feat: add pwa support (#5530)
* feat: add pwa support

* fix: deepsource warning

* fix: format warning

* fix: lint error

---------

Co-authored-by: sharpsalat <deniska.xrenov23@yandex.ru>
2026-05-07 21:53:17 +02:00
homarr-crowdin[bot]andMeier Lukas a9ad991892 chore(lang): updated translations from crowdin (#5623)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-07 21:04:10 +02:00
Meier Lukas 3183914a15 fix(apps): creation on the fly broken (#5605) 2026-05-07 20:59:12 +02:00
Meier Lukas 62be81ea60 fix(lang): portuguese is linked to brazilian portuguese on crowdin (#5622) 2026-05-07 20:54:11 +02:00
Meier Lukas ffc292b22a ci: increase timeout of asset extraction due job cancellation
https://github.com/homarr-labs/homarr/actions/runs/25509168979/attempts/1
2026-05-07 18:48:13 +02:00
Releases Homarr c83fdb2a6e chore(release): 1.60.1-beta.2 [skip ci] 2026-05-07 16:39:41 +00:00
ThomasandMeier Lukas b5c316b825 feat(spotlight): rework !bang search UX (#4813)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-07 18:38:40 +02:00
homarr-releases[bot] 81fdd90e7a chore(release): release v1.60.1-beta 2026-05-07 16:38:37 +00:00
KyleandThomas 831b6221fd feat: add auto/system appearance mode + fix dark mode system resources charts (#5492)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-05-07 16:28:37 +00:00
homarr-update-contributors[bot] 37edfb2de2 chore: update integrations list readme (#5662)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-05-07 16:11:16 +00:00
43cc68bdd4 feat: add Umami Analytics integration and widget (#5362)
Co-authored-by: Josh G <febrile42@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-05-07 17:50:58 +02:00
Meier Lukas 0937555d49 feat(entity-state): add data attributes for custom-css styling (#5655) 2026-05-07 16:54:57 +02:00
homarr-renovate[bot] 663fe980bf chore(deps): update dependency yaml to v2.8.4 (#5659)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-07 09:39:58 +00:00
homarr-renovate[bot] 9e2c487389 chore(deps): update dependency isomorphic-dompurify to ^3.12.0 (#5658)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-07 06:19:31 +00:00
homarr-renovate[bot] 698e551790 chore(deps): update tanstack-query monorepo to ^5.100.8 (#5657)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-07 06:18:42 +00:00
homarr-renovate[bot] 71e4526ad5 chore(deps): update dependency zod to ^4.4.2 (#5656)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 22:04:29 +00:00
Thomas 5ee84f8cf4 perf: (next branch) reduce idle memory on startup (#5637) 2026-05-06 21:39:26 +00:00
Meier Lukas b07a4a0f3e fix(update-checker): hide prereleases on non-prerelease versions (#5645) 2026-05-06 21:23:14 +02:00
homarr-renovate[bot] 62a476a949 chore(deps): update dependency eslint to ^10.3.0 (#5652)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 16:35:25 +00:00
homarr-renovate[bot] 6671ac48f9 chore(deps): update tanstack-query monorepo to ^5.100.7 (#5651)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 09:30:45 +00:00
homarr-renovate[bot] 3c7a7615ba chore(deps): update dependency nanoid@&gt;&#x3d;4.0.0 &lt;5.0.9 to >=5.1.11 (#5648)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 06:13:36 +00:00
homarr-renovate[bot] 2f301bb56a chore(deps): update turbo monorepo to ^2.9.7 (#5649)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 03:01:59 +00:00
homarr-renovate[bot] e06abbc176 chore(deps): update dependency nanoid@&gt;&#x3d;4.0.0 &lt;5.0.9 to >=5.1.10 (#5646)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 21:10:38 +00:00
homarr-renovate[bot] 04daeba089 chore(deps): update dependency jsdom to ^29.1.1 (#5643)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 10:55:08 +00:00
homarr-renovate[bot] 7c83d2cb6e chore(deps): update dependency hono@&lt;4.6.5 to >=4.12.16 (#5642)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 10:54:29 +00:00
homarr-renovate[bot] 2829d145af chore(deps): update dependency zod to ^4.4.1 (#5641)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 02:56:52 +00:00
homarr-renovate[bot] 44bf36fff9 chore(deps): update dependency timezones-ical-library to ^2.2.0 (#5640)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 00:01:35 +00:00
homarr-renovate[bot] acd11c3fc1 chore(deps): update dependency isomorphic-dompurify to ^3.11.0 (#5638)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-04 16:53:37 +00:00
homarr-renovate[bot] b9c701a71b chore(deps): update dependency next-intl to v4.11.0 (#5635)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 19:08:04 +00:00
homarr-renovate[bot] e52ecd64ee chore(deps): update dependency next-intl to v4.10.1 (#5632)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 16:59:56 +00:00
homarr-renovate[bot] af77f729ec chore(deps): update tanstack-query monorepo to ^5.100.6 (#5633)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 16:59:30 +00:00
homarr-renovate[bot] 7c39e4de13 chore(deps): update dependency next-intl to v4.10.0 (#5631)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 15:56:23 +00:00
homarr-renovate[bot] 3e918e5d3d chore(deps): update dependency tsdav to ^2.2.0 (#5627)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 14:57:50 +00:00
homarr-renovate[bot] 9cd45f7832 chore(deps): update trpc monorepo to ^11.17.0 (#5630)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 13:19:32 +00:00
homarr-renovate[bot] 90ca3770a1 chore(deps): update tiptap monorepo to v3.22.5 (#5628)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 10:12:22 +00:00
homarr-renovate[bot]andMeier Lukas a183339063 chore(deps): update dependency eslint to v10 (#5038)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-02 21:41:16 +02:00
homarr-renovate[bot] 0a4e4c3b70 chore(deps): update dependency next-intl to v4.9.2 (#5625)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 19:07:29 +00:00
homarr-renovate[bot] b52e664d0f chore(deps): update dependency swagger-ui-react to ^5.32.5 (#5621)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 13:21:15 +00:00
homarr-renovate[bot] 9f4634a95d chore(deps): update dependency mysql2 to v3.22.3 (#5619)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 11:25:52 +00:00
homarr-renovate[bot] a6f6c2e945 chore(deps): update dependency jsdom to ^29.1.0 (#5620)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 02:53:35 +00:00
homarr-crowdin[bot]andMeier Lukas b6d23e7546 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-02 01:32:16 +00:00
Meier Lukas b0f2c94674 chore: update bug report template 2026-05-01 22:51:17 +02:00
homarr-releases[bot] 9cf0070442 chore: update bug report template (#5612)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-05-01 20:23:03 +00:00
Meier LukasandCopilot 647afbe2cb ci: add beta release workflow (#5601)
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 22:20:58 +02:00
174 changed files with 27441 additions and 9531 deletions

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
---
description: Comprehensive Homarr codebase architecture and conventions reference
alwaysApply: true
---
# Homarr Codebase Context
Homarr is an open-source, self-hosted dashboard for managing homelab services. It integrates with 50+ self-hosted apps (media servers, download clients, DNS, NAS, etc.) and provides a drag-and-drop widget-based UI.
## Tech Stack
- **Monorepo**: pnpm workspaces + Turborepo
- **Framework**: Next.js (App Router, `output: "standalone"`) — T3 Stack
- **Language**: TypeScript throughout
- **ORM**: Drizzle (supports SQLite via better-sqlite3, MySQL, PostgreSQL)
- **API**: tRPC (HTTP + WebSocket subscriptions) with superjson + OpenAPI bridge via `trpc-to-openapi`
- **Auth**: NextAuth v5 (database sessions, Credentials/LDAP/OIDC providers)
- **UI**: Mantine v7 (not Tailwind) + Tabler icons
- **State**: Jotai (atoms), TanStack Query (server state via tRPC)
- **Realtime**: WebSocket server (`ws`) for tRPC subscriptions, backed by Redis pub/sub
- **Cron**: `node-cron` in a standalone Fastify service (`apps/tasks`)
- **i18n**: next-intl with `[locale]` dynamic segment (prefix mode: `"never"`, locale from cookie)
- **Testing**: Vitest + jsdom, Playwright for E2E
- **Package manager**: pnpm 10.33.2, Node >= 24.15.0
## Repository Structure
```
homarr/
├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000)
│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002)
│ └── websocket/ # Standalone tRPC WebSocket server (port 3001)
├── packages/
│ ├── api/ # tRPC appRouter, procedures, OpenAPI
│ ├── auth/ # NextAuth config, providers, session, API keys
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
│ ├── core/ # Env validation, DB/Redis driver factories, logging
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
│ ├── widgets/ # All 39 dashboard widgets (definitions + components)
│ ├── integrations/ # Integration classes (HTTP clients to external apps)
│ ├── redis/ # Redis pub/sub channels, caching abstractions
│ ├── translation/ # next-intl setup, locale configs, lang JSON files
│ ├── ui/ # Shared Mantine components, theme, hooks
│ ├── validation/ # Shared zod schemas for API/forms
│ ├── common/ # Shared utilities, IDs, errors
│ ├── cron-jobs/ # Cron job implementations (25+ jobs)
│ ├── cron-jobs-core/ # Cron scheduling primitives
│ ├── cron-job-api/ # tRPC router for job management (start/stop/trigger)
│ ├── cron-job-status/ # Cron status via Redis
│ ├── boards/ # Board context, edit mode, cache updater
│ ├── modals/ # Modal primitives on Mantine
│ ├── modals-collection/ # Feature modals (apps, boards, docker, etc.)
│ ├── form/ # useZodForm (Mantine + zod resolver)
│ ├── forms-collection/# Reusable form UIs (new app, icon picker, upload)
│ ├── spotlight/ # Command palette / search with multiple modes
│ ├── request-handler/ # Server request handlers (feeds, integrations)
│ ├── notifications/ # Mantine notifications wrapper
│ ├── docker/ # Dockerode-based Docker access
│ ├── icons/ # Icon DB/repo integration
│ ├── image-proxy/ # Image proxy + caching
│ ├── ping/ # Reachability / ping utilities
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── eslint/ # Shared ESLint configs
│ ├── prettier/ # Shared Prettier config
│ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build
```
## Three Runtime Services
| Service | Port | Role |
|---------|------|------|
| Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify |
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
## Package Dependency Layers
1. **Foundation**: `core` (env, DB drivers, Redis client, logging) → no `@homarr` deps
2. **Domain**: `definitions` → `common` → `core`
3. **Data**: `db` → `core`, `definitions`, `common`
4. **Auth**: `auth` → `db`, `definitions`, `core`, `validation`
5. **Cross-cutting**: `translation`, `validation`, `redis`, `server-settings`
6. **Feature backends**: `integrations`, `request-handler`, `cron-jobs`, `docker`, `ping`
7. **API surface**: `api` → pulls in auth, db, redis, integrations, etc.
8. **UI stack**: `ui`, `modals`, `form`, `notifications`, `boards`, `spotlight`
9. **Composed features**: `widgets`, `modals-collection`, `forms-collection`
## Key Patterns
### tRPC
- **Server caller** (RSC): `import { api } from "@homarr/api/server"` → cached caller with auth context
- **Client hooks**: `import { clientApi } from "@homarr/api/client"` → React Query hooks
- **Router structure**: `packages/api/src/root.ts` defines top-level namespaces (`user`, `board`, `widget`, `integration`, `docker`, `kubernetes`, etc.)
- **Procedures**: `publicProcedure`, `protectedProcedure`, `permissionRequiredProcedure`, `onboardingProcedure`
- **WebSocket subscriptions**: Client uses `wsLink` when `type === "subscription"`, connects to port 3001
### Multi-Database Support
Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts`
Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-postgres`). Migrations are per-engine in `packages/db/migrations/{sqlite,mysql,postgresql}/`.
### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic`
- Fetches data via tRPC client hooks (backed by cron job caches)
### Integration System
Integrations are defined in `packages/definitions/src/integration.ts` (`integrationDefs`) and instantiated via `createIntegrationAsync` in `packages/integrations/src/base/creator.ts`. Each extends abstract `Integration` class with `testConnectionAsync` and typed methods.
### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression
- Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions
- Job management (start/stop/trigger) via `cronJobApi` HTTP client to port 3002
### Modals
Created with `createModal(component).withOptions({ defaultTitle })` from `@homarr/modals`. Opened via `useModalAction(SomeModal)`. Feature modals live in `packages/modals-collection/`.
### Forms
`useZodForm` from `@homarr/form` wraps Mantine `useForm` with zod validation. Shared form UIs in `packages/forms-collection/`.
### Routing (App Router)
- `apps/nextjs/src/app/[locale]/` — all UI routes under locale segment
- Route groups: `(home)`, `(content)`, `(board)` — don't affect URLs
- Key routes: `boards/[name]`, `manage/...` (admin), `auth/login`, `init` (onboarding), `widgets/[kind]`
- Locale driven by cookie (no URL prefix)
### Auth Providers
Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `oidc`. Database sessions with httpOnly cookies. API key auth for programmatic access.
## Environment Variables
Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret
- `SECRET_ENCRYPTION_KEY`: AES-256-CBC for integration secrets
- `AUTH_PROVIDERS`: Comma-separated (`credentials`, `ldap`, `oidc`)
- `CRON_JOB_API_KEY`: Shared key between Next.js and tasks service
- `REDIS_*`: Redis connection (host, port, password)
- `ENABLE_KUBERNETES`: Optional Kubernetes integration
## Development Commands
- `pnpm dev` — runs all three services in parallel via Turborepo
- `pnpm docker:dev` — starts Redis + MySQL + PostgreSQL containers
- `pnpm db:push` — push schema to SQLite (dev)
- `pnpm db:studio` — Drizzle Studio
- `pnpm test` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E
- `pnpm lint` / `pnpm format` — ESLint + Prettier
- `pnpm typecheck` — tsc --noEmit across all packages
## Conventions
- Workspace packages use `@homarr/` scope
- All versions managed via pnpm `catalog:` in workspace
- Path alias `~/*` → `./src/*` in Next.js app
- Mantine for all UI (no Tailwind) — primary color is red, `autoContrast: true`
- Icons from `@tabler/icons-react`
- Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
+5 -1
View File
@@ -9,4 +9,8 @@ README.md
.git
dev
.build
e2e
e2e
apps/nextjs/.next
.env
.env.*
!.env.example
+14
View File
@@ -41,6 +41,20 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
TURBO_TELEMETRY_DISABLED=1
# Turborepo remote cache (optional, for faster CI/local builds)
# Uncomment and fill in to enable remote caching locally.
# CI uses GitHub secrets for these values automatically.
# TURBO_API=https://your-cache-worker.workers.dev
# TURBO_TEAM=team_your_team
# TURBO_TOKEN=your-turbo-token
# TURBO_REMOTE_CACHE_SIGNATURE_KEY=your-signature-key
# Comma-separated paths to Docker-compatible Unix sockets.
# Useful for connecting to multiple runtimes (Docker + Podman) simultaneously.
# When unset and no TCP hosts configured, defaults to /var/run/docker.sock.
# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock
# DOCKER_SOCKET_PATHS=
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
+3
View File
@@ -33,6 +33,9 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.61.0
- 1.60.1-beta.1
- 1.60.0
- 1.59.3
- 1.59.2
- 1.59.1
+24 -5
View File
@@ -15,6 +15,13 @@ concurrency:
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs:
lint:
@@ -22,6 +29,8 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
@@ -31,31 +40,35 @@ jobs:
run: cp .env.example .env
- name: Lint
run: pnpm lint && pnpm lint:ws
run: pnpm turbo lint --affected && pnpm lint:ws
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm format
run: pnpm turbo format --affected --continue -- --cache --cache-location node_modules/.cache/.prettiercache
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
@@ -78,11 +91,12 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v7
@@ -90,8 +104,11 @@ jobs:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-e2e
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
@@ -105,10 +122,12 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm build
run: pnpm turbo build --affected
@@ -193,7 +193,7 @@ jobs:
name: Extract amd64 asset from docker image
needs: [release, build-amd64]
runs-on: ubuntu-latest
timeout-minutes: 2
timeout-minutes: 5
steps:
- name: Extract amd64
if: needs.release.outputs.skipped == 'false'
@@ -209,7 +209,7 @@ jobs:
name: Extract arm64 asset from docker image
needs: [release, build-arm64]
runs-on: ubuntu-24.04-arm
timeout-minutes: 2
timeout-minutes: 5
steps:
- name: Extract arm64
if: needs.release.outputs.skipped == 'false'
+1 -1
View File
@@ -71,4 +71,4 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
.opencode
+3858 -3832
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -3,7 +3,11 @@
# Exempt Renovate‑managed files (no owners)
package.json
**/package.json
package-lock.json
**/package-lock.json
pnpm-lock.yaml
**/pnpm-lock.yaml
pnpm-workspace.yaml
Dockerfile
docker-compose.yml
+17
View File
@@ -10,4 +10,21 @@ export default [
...baseConfig,
...reactConfig,
...nextjsConfig,
{
files: ["public/sw.js"],
languageOptions: {
globals: {
self: "readonly",
clients: "readonly",
skipWaiting: "readonly",
importScripts: "readonly",
caches: "readonly",
fetch: "readonly",
},
},
rules: {
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
},
},
];
+11 -5
View File
@@ -6,7 +6,6 @@ import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
// Package path does not work... so we need to use relative path
@@ -32,6 +31,8 @@ const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
webpackMemoryOptimizations: true,
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: {
@@ -42,9 +43,18 @@ const nextConfig: NextConfig = {
},
],
},
// skipcq: JS-0116
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
async headers() {
return [
{
source: "/sw.js",
headers: [
{ key: "Content-Type", value: "application/javascript; charset=utf-8" },
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
],
},
{
source: "/(.*)", // Apply CSP to all routes
headers: [
@@ -74,8 +84,4 @@ const nextConfig: NextConfig = {
},
};
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
export default withNextIntl(nextConfig);
-1
View File
@@ -56,7 +56,6 @@
"@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@million/lint": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
+7
View File
@@ -0,0 +1,7 @@
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
@@ -8,13 +8,12 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
@@ -35,7 +34,7 @@ export const CustomMantineProvider = ({
export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession();
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
const updateCookieValue = (value: MantineColorScheme) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
@@ -60,7 +59,6 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });
+4 -1
View File
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
@@ -25,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai";
@@ -129,7 +131,7 @@ export default async function Layout(props: {
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
}}
suppressHydrationWarning
>
@@ -141,6 +143,7 @@ export default async function Layout(props: {
<body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider>
<Notifications />
<ServiceWorkerRegistration />
{props.children}
</StackedProvider>
</body>
@@ -1,7 +1,7 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
@@ -34,6 +34,7 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
};
const appearanceIcons = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
@@ -58,6 +58,18 @@ const createColumns = (
return <ContainerStateBadge state={cell.row.original.state} />;
},
},
{
accessorKey: "host",
header: t("docker.field.host.label"),
size: 140,
Cell({ row }) {
return (
<Text truncate="end" title={row.original.host}>
{row.original.host}
</Text>
);
},
},
{
accessorKey: "image",
header: t("docker.field.containerImage.label"),
@@ -26,6 +26,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
ddgBangsEnabled: variables.ddgBangsEnabled,
});
showSuccessNotification({
message: t("user.action.changeSearchPreferences.notification.success.message"),
@@ -41,6 +42,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
},
});
@@ -64,6 +66,10 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
@@ -0,0 +1,94 @@
"use client";
import React from "react";
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
const schemeIcons: Record<ColorScheme, React.ElementType> = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
interface ColorSchemeComboboxProps {
value: ColorScheme;
onChange: (value: ColorScheme) => void;
width?: string;
}
export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const handleOnOptionSubmit = React.useCallback(
(val: string) => {
if (!val) return;
onChange(val as ColorScheme);
combobox.closeDropdown();
},
[onChange, combobox],
);
const handleOnClick = React.useCallback(() => {
combobox.toggleDropdown();
}, [combobox]);
return (
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={handleOnClick}
variant="filled"
w={width}
>
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{colorSchemes.map((scheme) => (
<Combobox.Option value={scheme} key={scheme}>
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
const OptionItem = ({
scheme,
currentScheme,
tOptions,
showCheck,
}: {
scheme: ColorScheme;
currentScheme: ColorScheme;
tOptions: (key: ColorScheme) => string;
showCheck?: boolean;
}) => {
const Icon = schemeIcons[scheme];
return (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<Icon size={16} stroke={1.5} />
<Text>{tOptions(scheme)}</Text>
</Group>
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
</Group>
);
};
@@ -1,49 +1,17 @@
"use client";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { useMantineColorScheme } from "@mantine/core";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { ColorSchemeCombobox } from "./color-scheme-combobox";
interface CurrentColorSchemeComboboxProps {
w?: string;
}
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme();
return (
<SelectWithCustomItems
value={colorScheme}
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tOptions(scheme),
}))}
SelectOption={ColorSchemeCustomOption}
w={w}
/>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
};
@@ -0,0 +1,14 @@
"use client";
import { useEffect } from "react";
export const ServiceWorkerRegistration = () => {
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" }).catch((error) => {
console.error("Service worker registration failed", error);
});
}, []);
return null;
};
@@ -5,16 +5,7 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconMoon,
IconSettings,
IconSun,
IconTool,
} from "@tabler/icons-react";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -24,6 +15,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
@@ -34,13 +26,9 @@ interface UserAvatarMenuProps {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
const session = useSession();
const router = useRouter();
@@ -67,14 +55,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
</Menu.Item>
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
+12
View File
@@ -0,0 +1,12 @@
// Prevent flash of white background when color scheme is "auto" and OS prefers dark.
// CSS media queries are evaluated synchronously during the browser's initial paint,
// before any JavaScript executes, so this eliminates the white→dark flicker.
html[data-mantine-color-scheme="auto"] {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
html[data-mantine-color-scheme="auto"] {
background-color: #242424;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { handshakeAsync } from "@homarr/redis";
import { waitForRedisAsync } from "./main";
const { mockLogger } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("./overrides", () => ({}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => mockLogger,
}));
vi.mock("@homarr/redis", () => ({
handshakeAsync: vi.fn(),
}));
vi.mock("@homarr/cron-jobs", () => ({
jobGroup: {
initializeAsync: vi.fn().mockResolvedValue(undefined),
startAllAsync: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("./on-start", () => ({
onStartAsync: vi.fn().mockResolvedValue(undefined),
}));
describe("waitForRedisAsync", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("succeeds on first attempt", async () => {
vi.mocked(handshakeAsync).mockResolvedValueOnce(undefined);
await waitForRedisAsync();
expect(handshakeAsync).toHaveBeenCalledOnce();
expect(mockLogger.info).toHaveBeenCalledWith("Redis connection established");
});
test("retries on failure then succeeds", async () => {
vi.mocked(handshakeAsync)
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockResolvedValueOnce(undefined);
const promise = waitForRedisAsync();
await vi.advanceTimersByTimeAsync(500);
await vi.advanceTimersByTimeAsync(500);
await promise;
expect(handshakeAsync).toHaveBeenCalledTimes(3);
expect(mockLogger.warn).toHaveBeenCalledTimes(2);
expect(mockLogger.info).toHaveBeenCalledWith("Redis connection established");
});
test("logs error and proceeds after max retries exhausted", async () => {
vi.mocked(handshakeAsync).mockRejectedValue(new Error("ECONNREFUSED"));
const promise = waitForRedisAsync();
for (let index = 0; index < 10; index++) {
await vi.advanceTimersByTimeAsync(500);
}
await promise;
expect(handshakeAsync).toHaveBeenCalledTimes(10);
expect(mockLogger.error).toHaveBeenCalledWith("Redis did not become ready after retries, proceeding anyway");
expect(mockLogger.info).not.toHaveBeenCalled();
});
test("warns with attempt count on each retry", async () => {
vi.mocked(handshakeAsync).mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce(undefined);
const promise = waitForRedisAsync();
await vi.advanceTimersByTimeAsync(500);
await promise;
expect(mockLogger.warn).toHaveBeenCalledWith("Redis not ready, retrying (1/10)...");
});
test("does not retry after success", async () => {
vi.mocked(handshakeAsync).mockResolvedValueOnce(undefined);
await waitForRedisAsync();
expect(handshakeAsync).toHaveBeenCalledOnce();
expect(mockLogger.warn).not.toHaveBeenCalled();
expect(mockLogger.error).not.toHaveBeenCalled();
});
});
+22
View File
@@ -1,11 +1,33 @@
// This import has to be the first import in the file so that the agent is overridden before any other modules are imported.
import "./overrides";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { jobGroup } from "@homarr/cron-jobs";
import { handshakeAsync } from "@homarr/redis";
import { onStartAsync } from "./on-start";
const logger = createLogger({ module: "tasks" });
const MAX_RETRIES = 10;
const RETRY_DELAY_MS = 500;
export const waitForRedisAsync = async () => {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await handshakeAsync();
logger.info("Redis connection established");
return;
} catch {
logger.warn(`Redis not ready, retrying (${attempt}/${MAX_RETRIES})...`);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
}
logger.error("Redis did not become ready after retries, proceeding anyway");
};
void (async () => {
await waitForRedisAsync();
await onStartAsync();
await jobGroup.initializeAsync();
await jobGroup.startAllAsync();
+1 -1
View File
@@ -9,5 +9,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
"exclude": ["node_modules", "*.cjs", "*.css"]
}
+1 -1
View File
@@ -9,5 +9,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
"exclude": ["node_modules", "*.cjs", "*.css"]
}
+3 -3
View File
@@ -2,12 +2,12 @@
"name": "homarr-prebuilt-debian",
"private": true,
"dependencies": {
"better-sqlite3": "^12.9.0"
"better-sqlite3": "^12.10.0"
},
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@10.33.4",
"engines": {
"node": ">=24.15.0",
"pnpm": ">=10.33.2"
"pnpm": ">=10.33.4"
},
"pnpm": {
"onlyBuiltDependencies": [
+6 -6
View File
@@ -9,17 +9,17 @@ importers:
.:
dependencies:
better-sqlite3:
specifier: ^12.9.0
version: 12.9.0
specifier: ^12.10.0
version: 12.10.0
packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.9.0:
resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
better-sqlite3@12.10.0:
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@@ -148,7 +148,7 @@ snapshots:
base64-js@1.5.1: {}
better-sqlite3@12.9.0:
better-sqlite3@12.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
+7
View File
@@ -372,6 +372,13 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
<br/>
<p align="center">Umami</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/unifi-controller" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png" alt="Unifi Controller" width="90" height="90" />
<br/>
+1 -1
View File
@@ -1,4 +1,4 @@
import { chromium } from "playwright";
import { chromium } from "@playwright/test";
import { GenericContainer } from "testcontainers";
import { describe, expect, test } from "vitest";
+1 -1
View File
@@ -1,4 +1,4 @@
import { chromium } from "playwright";
import { chromium } from "@playwright/test";
import { describe, test } from "vitest";
import { OnboardingActions } from "./shared/actions/onboarding-actions";
+1 -1
View File
@@ -1,5 +1,5 @@
import { createId } from "@paralleldrive/cuid2";
import type { Page } from "playwright";
import type { Page } from "@playwright/test";
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
import type { SqliteDatabase } from "../e2e-db";
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test";
import { eq } from "drizzle-orm";
import type { Page } from "playwright";
import { expect } from "vitest";
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
+14 -12
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.60.1-beta.1",
"version": "1.61.1-beta.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -36,6 +36,7 @@
"prettier": "@homarr/prettier-config",
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:",
"@semantic-release/commit-analyzer": "catalog:",
"@semantic-release/git": "catalog:",
@@ -59,10 +60,10 @@
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
},
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@10.33.4",
"engines": {
"node": ">=24.15.0",
"pnpm": ">=10.33.2"
"pnpm": ">=10.33.4"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -79,23 +80,23 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.2",
"@babel/runtime@<7.26.10": ">=7.29.2",
"axios@>=1.0.0 <1.8.2": ">=1.15.2",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.5",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.5",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.15",
"linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.9",
"hono@<4.6.5": ">=4.12.19",
"linkifyjs@<4.3.2": ">=4.3.3",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.5",
"rollup@>=4.0.0 <4.22.4": ">=4.60.2",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.5",
"vite@>=5.0.0 <=5.4.18": ">=8.0.10"
"vite@>=5.0.0 <=5.4.18": ">=8.0.13"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
@@ -104,6 +105,7 @@
"allowUnusedPatches": true,
"ignoredBuiltDependencies": [
"@scarf/scarf",
"@swc/core",
"core-js-pure",
"protobufjs"
]
+1
View File
@@ -26,6 +26,7 @@ export const appRouter = createTRPCRouter({
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
});
@@ -0,0 +1,17 @@
import { z } from "zod/v4";
import { searchDuckDuckGoBangsAsync } from "../../services/duckduckgo-bangs";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bangsRouter = createTRPCRouter({
search: publicProcedure
.input(
z.object({
query: z.string(),
limit: z.number().int().min(1).max(50).default(20),
}),
)
.query(async ({ input }) => {
return await searchDuckDuckGoBangsAsync({ query: input.query, limit: input.limit });
}),
});
@@ -138,6 +138,7 @@ const getContainerOrThrowAsync = async (id: string) => {
interface DockerContainer {
name: string;
id: string;
host: string;
state: ContainerState;
image: string;
ports: Port[] | undefined;
+31 -1
View File
@@ -21,6 +21,7 @@ import {
userChangePasswordApiSchema,
userChangeSearchPreferencesSchema,
userCreateSchema,
userDdgBangsSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userInitSchema,
@@ -110,7 +111,7 @@ export const userRouter = createTRPCRouter({
const queryResult = (await trx.delete(schema.invites).where(inviteWhere)) as
| QueryResult
| MySqlRawQueryResult;
let count = 0;
let count: number;
if (Array.isArray(queryResult)) {
count = queryResult[0].affectedRows;
} else {
@@ -276,6 +277,7 @@ export const userRouter = createTRPCRouter({
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -301,6 +303,7 @@ export const userRouter = createTRPCRouter({
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
},
where: eq(users.id, input.userId),
});
@@ -541,6 +544,33 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/ddg-bangs",
tags: ["users"],
protect: true,
deprecated: true,
},
})
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
ddgBangs: input.ddgBangs,
})
.where(eq(users.id, input.id));
}),
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
.output(z.void())
@@ -15,10 +15,13 @@ export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSch
export const changeSearchPreferencesAsync = async (
db: Database,
session: Session,
input: Modify<z.infer<typeof changeSearchPreferencesInputSchema>, { openInNewTab: boolean | undefined }>,
input: Modify<
z.infer<typeof changeSearchPreferencesInputSchema>,
{ openInNewTab: boolean | undefined; ddgBangsEnabled: boolean | undefined }
>,
) => {
const user = session.user;
// Only admins can change other users passwords
// Only admins can change other users search preferences
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
@@ -45,6 +48,7 @@ export const changeSearchPreferencesAsync = async (
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
ddgBangs: input.ddgBangsEnabled,
})
.where(eq(users.id, input.userId));
};
+23 -1
View File
@@ -2,6 +2,8 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Modify } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -12,6 +14,8 @@ import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.input(
@@ -24,7 +28,7 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
return await Promise.all(
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
@@ -40,6 +44,24 @@ export const calendarRouter = createTRPCRouter({
};
}),
);
return settled.flatMap((result, index) => {
if (result.status === "fulfilled") {
return [result.value];
}
const integration = ctx.integrations[index];
logger.warn(
new ErrorWithMetadata(
"Calendar integration request failed; skipping events for this integration",
{
integrationId: integration?.id,
integrationKind: integration?.kind,
},
{ cause: result.reason },
),
);
return [];
});
}),
subscribeToEvents: publicProcedure
.input(
+1
View File
@@ -30,4 +30,5 @@ export const widgetRouter = createTRPCRouter({
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
});
+205
View File
@@ -0,0 +1,205 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync } from "@homarr/integrations";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
umamiMultiEventRequestHandler,
umamiRequestHandler,
umamiTopPagesRequestHandler,
umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
try {
const instance = await createIntegrationAsync(ctx.integration);
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getVisitorStats: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
eventName: z.string().optional(),
}),
)
.concat(createManyIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata("Failed to load event names", { websiteId: input.websiteId }, { cause: error }),
);
return [];
}
}),
getTopPages: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load top pages",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
{ cause: error },
),
);
return [];
}
}),
getTopReferrers: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load top referrers",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
{ cause: error },
),
);
return [];
}
}),
getMultiEventTimeSeries: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
eventNames: z.array(z.string()),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const sortedNames = [...input.eventNames].sort();
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load multi-event time series",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, eventNames: JSON.stringify(input.eventNames) },
{ cause: error },
),
);
return [];
}
}),
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata("Failed to load active visitors", { websiteId: input.websiteId }, { cause: error }),
);
return 0;
}
}),
subscribeActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.subscription(({ ctx, input }) => {
return observable<number>((emit) => {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const unsubscribe = innerHandler.subscribe((count) => {
emit.next(count);
});
return () => {
unsubscribe();
};
});
}),
});
@@ -0,0 +1,55 @@
import { duckDuckGoBangsRequestHandler } from "@homarr/request-handler/duckduckgo-bangs";
import type { DuckDuckGoBang } from "@homarr/request-handler/duckduckgo-bangs";
// DuckDuckGo bang keys are intentionally short:
// - `t`: token (e.g. "yt"), `s`: display name, `u`: URL template (contains `{{{s}}}`)
// - `d`: domain, `c`: category, `sc`: subcategory, `r`: rank (optional)
const normalizeBangToken = (token: string) => token.toLowerCase().trim();
/**
* Binary search to find the first index where bang.t >= tokenPrefix.
* This is O(log n) vs O(n) for findIndex, which matters because DuckDuckGo
* has ~13,000+ bangs. Combined with the pre-sorted data, this allows
* efficient prefix matching by finding the start position and iterating
* only through consecutive matches.
*/
const lowerBound = (arr: DuckDuckGoBang[], tokenPrefix: string) => {
let low = 0;
let high = arr.length;
while (low < high) {
const mid = (low + high) >> 1;
const midBang = arr[mid];
// Must use the same ordering as the source list sort (localeCompare),
// otherwise binary search can miss tokens with symbols like "&" or "_".
if (!midBang || midBang.t.localeCompare(tokenPrefix) >= 0) {
high = mid;
continue;
}
low = mid + 1;
}
return low;
};
export const searchDuckDuckGoBangsAsync = async (input: {
query: string;
limit: number;
}): Promise<DuckDuckGoBang[]> => {
const queryTokenPrefix = normalizeBangToken(input.query);
if (!queryTokenPrefix) return [];
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({});
const startIndex = lowerBound(allBangs, queryTokenPrefix);
const matches: DuckDuckGoBang[] = [];
for (let index = startIndex; index < allBangs.length; index++) {
const bang = allBangs[index];
if (!bang) break;
if (!bang.t.startsWith(queryTokenPrefix)) break;
matches.push(bang);
if (matches.length >= input.limit) break;
}
return matches;
};
+1 -1
View File
@@ -36,7 +36,7 @@ export const createSessionAsync = async (
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "dark",
colorScheme: "auto",
},
} as Session;
};
+23
View File
@@ -5,3 +5,26 @@ export const capitalize = <T extends string>(str: T) => {
export const isNullOrWhitespace = (value: string | null): value is null => {
return value == null || value.trim() === "";
};
export const bestMatch = <TItem extends Record<string, unknown>>(
search: string,
options: TItem[],
by: (item: TItem) => string,
) => {
if (options.length === 0) return null;
const normalizedSearch = search.toLowerCase().trim();
return options.reduce<TItem | null>((best, current) => {
const currentMatchIndex = by(current).toLowerCase().indexOf(normalizedSearch);
if (currentMatchIndex === -1) return best;
if (best === null) return current;
const bestMatchIndex = by(best).toLowerCase().indexOf(normalizedSearch);
if (currentMatchIndex < bestMatchIndex) return current;
if (by(current).length < by(best).length && currentMatchIndex === bestMatchIndex) return current;
return best;
}, null);
};
+40 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import { capitalize } from "../string";
import { bestMatch, capitalize } from "../string";
const capitalizeTestCases = [
["hello", "Hello"],
@@ -12,8 +12,45 @@ const capitalizeTestCases = [
describe("capitalize should capitalize the first letter of a string", () => {
capitalizeTestCases.forEach(([input, expected]) => {
it(`should capitalize ${input} to ${expected}`, () => {
test(`should capitalize ${input} to ${expected}`, () => {
expect(capitalize(input)).toEqual(expected);
});
});
});
describe("bestMatch should find the best match in an array of options", () => {
test("should find exact match for 'nginx'", () => {
const options = [
{ name: "my-nginx" },
{ name: "nginx-proxy" },
{ name: "nginx" },
{ name: "redis" },
{ name: "postgres" },
];
const result = bestMatch("nginx", options, ({ name }) => name);
expect(result).toEqual({ name: "nginx" });
});
test("should find closest match for 'nginx' when exact match is not available", () => {
const options = [{ name: "redis" }, { name: "nginx-proxy" }, { name: "my-nginx" }, { name: "postgres" }];
const result = bestMatch("nginx", options, ({ name }) => name);
expect(result).toEqual({ name: "nginx-proxy" });
});
test("should return null if no match is found", () => {
const options = [{ name: "redis" }, { name: "postgres" }];
const result = bestMatch("nginx", options, ({ name }) => name);
expect(result).toBeNull();
});
test("should return null if options array is empty", () => {
const options: { name: string }[] = [];
const result = bestMatch("nginx", options, ({ name }) => name);
expect(result).toBeNull();
});
});
+19 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getPortFromUrl } from "../url";
import { getPortFromUrl, isAbsoluteUrl } from "../url.js";
describe("getPortFromUrl", () => {
test.each([
@@ -38,3 +38,21 @@ describe("getPortFromUrl", () => {
expect(act).toThrowError("Unsupported protocol: ftp:");
});
});
describe("isAbsoluteUrl", () => {
test.each([
["http://example.com", true],
["https://example.com", true],
["ftp://example.com", true],
["file:///path/to/file", true],
["mailto:johndoe@example.com", true],
["/relative/path", false],
["relative/path", false],
])("should return %s for URL: %s", (urlString, expected) => {
// Act
const result = isAbsoluteUrl(urlString);
// Assert
expect(result).toBe(expected);
});
});
+6
View File
@@ -38,3 +38,9 @@ export const getPortFromUrl = (url: URL): number => {
throw new Error(`Unsupported protocol: ${url.protocol}`);
};
const absoluteUrlRegex = /^[a-z]+:(\/\/)?/;
export const isAbsoluteUrl = (urlOrPath: string): boolean => {
return absoluteUrlRegex.test(urlOrPath.toLowerCase());
};
@@ -5,6 +5,7 @@ import { redisEnv } from "./env";
const defaultRedisOptions = {
connectionName: "homarr",
lazyConnect: true,
} satisfies RedisOptions;
export type { Redis as RedisClient } from "ioredis";
+2
View File
@@ -20,6 +20,7 @@ import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -55,6 +56,7 @@ const getJobGroup = () => {
weather: weatherJob,
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
});
};
@@ -0,0 +1,14 @@
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami";
import { createCronJob } from "../../lib";
export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, {
widgetKinds: ["umami"],
getInput: {
umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []),
},
}),
);
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `ddg_bangs` boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -267,6 +267,13 @@
"when": 1775399941430,
"tag": "0037_remove_salt",
"breakpoints": true
},
{
"idx": 38,
"version": "5",
"when": 1775400000000,
"tag": "0038_add_user_ddg_bangs",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "ddg_bangs" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -29,6 +29,13 @@
"when": 1775399963041,
"tag": "0003_remove_salt",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1775400000000,
"tag": "0004_add_user_ddg_bangs",
"breakpoints": true
}
]
}
+90 -2
View File
@@ -1,21 +1,35 @@
import { createId, objectKeys } from "@homarr/common";
import {
createDocumentationLink,
credentialsAdminGroup,
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
import { getMaxGroupPositionAsync } from "../queries";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import { groups, integrations, onboarding, searchEngines } from "../schema";
import {
boards,
groupMembers,
groupPermissions,
groups,
integrations,
layouts,
onboarding,
searchEngines,
sections,
users,
} from "../schema";
import type { Integration } from "../schema";
export const seedDataAsync = async (db: Database) => {
@@ -24,6 +38,10 @@ export const seedDataAsync = async (db: Database) => {
await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
if (process.env.DEMO_MODE === "true") {
await seedDemoUserAsync(db);
}
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -143,8 +161,9 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
const defaultIntegrations = integrationKinds.reduce<Integration[]>((acc, kind) => {
const name = getIntegrationName(kind);
const defaultUrl = getIntegrationDefaultUrl(kind);
const hasNoAuthOption = integrationDefs[kind].secretKinds.some((kinds) => kinds.length === 0);
if (defaultUrl !== undefined) {
if (defaultUrl !== undefined && hasNoAuthOption) {
acc.push({
id: "new",
name: `${name} Default`,
@@ -189,3 +208,72 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
console.log(`Created ${createdCount} default integrations through seeding process`);
};
const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"),
});
if (existingUser) {
console.log("Skipping seeding of demo user as it already exists");
return;
}
const userId = createId();
await db.insert(users).values({
id: userId,
name: "demo",
email: "demo@example.com",
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
});
const maxPosition = await getMaxGroupPositionAsync(db);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
await db.insert(groupMembers).values({
groupId,
userId,
});
await db.update(onboarding).set({
step: "finish",
previousStep: "settings",
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "default",
isPublic: false,
creatorId: userId,
});
await db.insert(sections).values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
await db.insert(layouts).values({
id: createId(),
name: "Base",
columnCount: 12,
breakpoint: 0,
boardId,
});
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log(
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
);
};
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `ddg_bangs` integer DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -253,6 +253,13 @@
"when": 1775399918831,
"tag": "0035_remove_salt",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1775400000000,
"tag": "0036_add_user_ddg_bangs",
"breakpoints": true
}
]
}
+1
View File
@@ -76,6 +76,7 @@ export const users = mysqlTable("user", {
onDelete: "set null",
}),
openSearchInNewTab: boolean().default(false).notNull(),
ddgBangs: boolean().default(true).notNull(),
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
+1
View File
@@ -75,6 +75,7 @@ export const users = pgTable("user", {
onDelete: "set null",
}),
openSearchInNewTab: boolean().default(false).notNull(),
ddgBangs: boolean().default(true).notNull(),
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
+1
View File
@@ -58,6 +58,7 @@ export const users = sqliteTable("user", {
onDelete: "set null",
}),
openSearchInNewTab: int({ mode: "boolean" }).default(true).notNull(),
ddgBangs: int({ mode: "boolean" }).default(true).notNull(),
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
@@ -93,6 +93,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/roles"
| "/docs/tags/search"
| "/docs/tags/search-engines"
| "/docs/tags/secrets"
| "/docs/tags/security"
| "/docs/tags/self-signed"
| "/docs/tags/seo"
@@ -198,6 +199,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/tracearr"
| "/docs/integrations/transmission"
| "/docs/integrations/truenas"
| "/docs/integrations/umami"
| "/docs/integrations/unifi-controller"
| "/docs/integrations/unraid"
| "/docs/management/api"
@@ -244,6 +246,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/system-resources"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/umami"
| "/docs/widgets/video"
| "/docs/widgets/weather"
| ""
+10 -1
View File
@@ -61,7 +61,7 @@ export const integrationDefs = {
},
qBittorrent: {
name: "qBittorrent",
secretKinds: [["username", "password"]],
secretKinds: [["apiKey"], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"),
@@ -370,6 +370,14 @@ export const integrationDefs = {
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
},
umami: {
name: "Umami",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg",
category: ["analytics"],
defaultUrl: "https://api.umami.is/v1",
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
},
// 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",
@@ -461,6 +469,7 @@ export const integrationCategories = [
"notes",
"mediaMonitoring",
"speedtest",
"analytics",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+1 -1
View File
@@ -1,2 +1,2 @@
export const colorSchemes = ["light", "dark"] as const;
export const colorSchemes = ["auto", "light", "dark"] as const;
export type ColorScheme = (typeof colorSchemes)[number];
+1
View File
@@ -37,5 +37,6 @@ export const widgetKinds = [
"immich-albumCarousel",
"tracearr",
"speedtestTracker",
"umami",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+3 -1
View File
@@ -4,9 +4,11 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost,host.docker.internal, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.string().optional(),
// Comma separated paths to Docker-compatible Unix sockets (e.g., /var/run/docker.sock,/run/user/1000/podman/podman.sock)
DOCKER_SOCKET_PATHS: z.string().optional(),
ENABLE_DOCKER: createBooleanSchema(true),
ENABLE_KUBERNETES: createBooleanSchema(false),
},
+32 -20
View File
@@ -11,31 +11,43 @@ export class DockerSingleton {
private static instances: DockerInstance[] | null = null;
private createInstances() {
const socketPaths = env.DOCKER_SOCKET_PATHS;
const hostVariable = env.DOCKER_HOSTNAMES;
const portVariable = env.DOCKER_PORTS;
if (hostVariable === undefined || portVariable === undefined) {
// Socket instances from DOCKER_SOCKET_PATHS
const socketInstances: DockerInstance[] = socketPaths
? socketPaths.split(",").map((socketPath) => ({
host: socketPath,
instance: new Docker({ socketPath }),
}))
: [];
// TCP instances from existing DOCKER_HOSTNAMES/DOCKER_PORTS
let tcpInstances: DockerInstance[] = [];
if (hostVariable !== undefined && portVariable !== undefined) {
const hostnames = hostVariable.split(",");
const ports = portVariable.split(",");
if (hostnames.length !== ports.length) {
throw new Error("The number of hosts and ports must match");
}
tcpInstances = hostnames.map((host, i) => ({
host: `${host}:${ports[i]}`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
instance: new Docker({ host, port: parseInt(ports[i]!, 10) }),
}));
}
const instances = [...socketInstances, ...tcpInstances];
// Default: local socket if nothing else configured
if (instances.length === 0) {
return [{ host: "socket", instance: new Docker() }];
}
const hostnames = hostVariable.split(",");
const ports = portVariable.split(",");
if (hostnames.length !== ports.length) {
throw new Error("The number of hosts and ports must match");
}
return hostnames.map((host, i) => {
// Check above ensures that ports[i] is not undefined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const port = ports[i]!;
return {
host: `${host}:${port}`,
instance: new Docker({
host,
port: parseInt(port, 10),
}),
};
});
return instances;
}
public static findInstance(host: string): DockerInstance | undefined {
+110
View File
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DockerSingleton } from "../singleton";
const mockEnv = vi.hoisted(() => ({
DOCKER_SOCKET_PATHS: undefined as string | undefined,
DOCKER_HOSTNAMES: undefined as string | undefined,
DOCKER_PORTS: undefined as string | undefined,
ENABLE_DOCKER: true,
ENABLE_KUBERNETES: false,
}));
vi.mock("dockerode", () => {
return {
default: class MockDocker {
constructor(public opts?: Record<string, unknown>) {}
},
};
});
vi.mock("../env", () => ({
env: mockEnv,
}));
describe("DockerSingleton", () => {
beforeEach(() => {
// Reset singleton cache between tests
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(DockerSingleton as any).instances = null;
// Reset env to defaults
mockEnv.DOCKER_SOCKET_PATHS = undefined;
mockEnv.DOCKER_HOSTNAMES = undefined;
mockEnv.DOCKER_PORTS = undefined;
});
test("should return default socket when no env vars set", () => {
const instances = DockerSingleton.getInstances();
expect(instances).toHaveLength(1);
expect(instances[0]?.host).toBe("socket");
});
test("should return TCP instances only when DOCKER_HOSTNAMES and DOCKER_PORTS set", () => {
mockEnv.DOCKER_HOSTNAMES = "remote-host";
mockEnv.DOCKER_PORTS = "2375";
const instances = DockerSingleton.getInstances();
expect(instances).toHaveLength(1);
expect(instances[0]?.host).toBe("remote-host:2375");
});
test("should return multiple TCP instances", () => {
mockEnv.DOCKER_HOSTNAMES = "host1,host2";
mockEnv.DOCKER_PORTS = "2375,2376";
const instances = DockerSingleton.getInstances();
expect(instances).toHaveLength(2);
expect(instances[0]?.host).toBe("host1:2375");
expect(instances[1]?.host).toBe("host2:2376");
});
test("should throw when hostname and port counts do not match", () => {
mockEnv.DOCKER_HOSTNAMES = "host1,host2";
mockEnv.DOCKER_PORTS = "2375";
expect(() => DockerSingleton.getInstances()).toThrow("The number of hosts and ports must match");
});
test("should return socket instances when DOCKER_SOCKET_PATHS set", () => {
mockEnv.DOCKER_SOCKET_PATHS = "/var/run/docker.sock";
const instances = DockerSingleton.getInstances();
expect(instances).toHaveLength(1);
expect(instances[0]?.host).toBe("/var/run/docker.sock");
});
test("should return multiple socket instances", () => {
mockEnv.DOCKER_SOCKET_PATHS = "/var/run/docker.sock,/run/user/1000/podman/podman.sock";
const instances = DockerSingleton.getInstances();
expect(instances).toHaveLength(2);
expect(instances[0]?.host).toBe("/var/run/docker.sock");
expect(instances[1]?.host).toBe("/run/user/1000/podman/podman.sock");
});
test("should combine socket and TCP instances", () => {
mockEnv.DOCKER_SOCKET_PATHS = "/var/run/docker.sock,/run/user/1000/podman/podman.sock";
mockEnv.DOCKER_HOSTNAMES = "remote-host";
mockEnv.DOCKER_PORTS = "2375";
const instances = DockerSingleton.getInstances();
expect(instances).toHaveLength(3);
expect(instances[0]?.host).toBe("/var/run/docker.sock");
expect(instances[1]?.host).toBe("/run/user/1000/podman/podman.sock");
expect(instances[2]?.host).toBe("remote-host:2375");
});
test("should cache instances on subsequent calls", () => {
const first = DockerSingleton.getInstances();
const second = DockerSingleton.getInstances();
expect(first).toBe(second);
});
});
@@ -77,7 +77,7 @@ export class AnchorIntegration extends Integration {
});
if (!response.ok) {
let errorBody: string | null = null;
let errorBody: string | null;
try {
errorBody = await response.text();
} catch {
@@ -46,6 +46,7 @@ import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -121,6 +122,7 @@ export const integrationCreators = {
searchCh: SearchChIntegration,
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -90,10 +90,18 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
}
private async getClientAsync(dispatcher?: Dispatcher) {
const credentials = this.hasSecretValue("apiKey")
? {
apiKey: this.getSecretValue("apiKey"),
}
: {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
};
return new QBittorrent({
...credentials,
baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
});
}
+2
View File
@@ -33,6 +33,7 @@ export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { UmamiIntegration } from "./umami/umami-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -68,6 +69,7 @@ export type {
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
@@ -1,3 +1,4 @@
import { isAbsoluteUrl } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ImageProxy } from "@homarr/image-proxy";
@@ -85,10 +86,10 @@ export class TracearrIntegration extends Integration {
data: await Promise.all(
json.data.map(async (stream) => {
if (stream.userAvatarUrl) {
stream.userAvatarUrl = await this.proxyImageAsync(imageProxy, stream.userAvatarUrl, stream.id, "avatar");
stream.userAvatarUrl = await this.proxyImageAsync(imageProxy, stream.userAvatarUrl);
}
if (stream.posterUrl) {
stream.posterUrl = await this.proxyImageAsync(imageProxy, stream.posterUrl, stream.id, "poster");
stream.posterUrl = await this.proxyImageAsync(imageProxy, stream.posterUrl);
}
return stream;
}),
@@ -120,12 +121,7 @@ export class TracearrIntegration extends Integration {
...json,
data: await Promise.all(
json.data.map(async (violation) => {
violation.user.avatarUrl = await this.proxyImageAsync(
imageProxy,
violation.user.avatarUrl,
violation.user.id,
"avatar",
);
violation.user.avatarUrl = await this.proxyImageAsync(imageProxy, violation.user.avatarUrl);
return violation;
}),
),
@@ -156,12 +152,7 @@ export class TracearrIntegration extends Integration {
...json,
data: await Promise.all(
json.data.map(async (session) => {
session.user.avatarUrl = await this.proxyImageAsync(
imageProxy,
session.user.avatarUrl,
session.user.id,
"avatar",
);
session.user.avatarUrl = await this.proxyImageAsync(imageProxy, session.user.avatarUrl);
return session;
}),
),
@@ -194,23 +185,12 @@ export class TracearrIntegration extends Integration {
};
}
private async proxyImageAsync(
imageProxy: ImageProxy,
url: string | null | undefined,
uniqueId: string,
discriminator: string,
): Promise<string | null> {
if (!url) return null;
private async proxyImageAsync(imageProxy: ImageProxy, urlOrPath: string | null | undefined): Promise<string | null> {
if (!urlOrPath) return null;
try {
// ImageProxy doesn't support fallback urls so we need to remove them
const cleanUrl = url.replace(/&fallback=[^&]+/, "").replace(/\?fallback=[^&]+&?/, "?");
// Build the full URL, then inject _uid as the FIRST query param.
// This is critical because ImageProxy uses bcrypt which truncates at 72 bytes.
// Without this, all long avatar/poster URLs from the same session hash identically
// since they share the same first 72+ bytes.
const baseUrl = this.url(cleanUrl as `/${string}`).toString();
const prefix = `_uid=${discriminator}_${uniqueId}&`;
const fullUrl = baseUrl.includes("?") ? baseUrl.replace("?", `?${prefix}`) : `${baseUrl}?${prefix}`;
const cleanUrl = urlOrPath.replace(/&fallback=[^&]+/, "").replace(/\?fallback=[^&]+&?/, "?");
const fullUrl = isAbsoluteUrl(urlOrPath) ? cleanUrl : this.url(cleanUrl as `/${string}`).toString();
return await imageProxy.createImageAsync(fullUrl, this.getAuthHeaders());
} catch {
+1
View File
@@ -16,3 +16,4 @@ export * from "./anchor/anchor-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./umami/umami-types";
@@ -0,0 +1,450 @@
import dayjs from "dayjs";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
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 {
UmamiEventSeries,
UmamiMetricItem,
UmamiPageviewDataPoint,
UmamiPageviews,
UmamiStats,
UmamiVisitorStats,
UmamiWebsite,
} from "./umami-types";
import {
umamiActiveVisitorsSchema,
umamiAuthResponseSchema,
umamiMetricItemSchema,
umamiPageviewDataPointSchema,
umamiPageviewsSchema,
umamiStatsSchema,
umamiWebsiteSchema,
} from "./umami-types";
// Umami event type ID for custom events (type 1 = page view, type 2 = custom event)
const UMAMI_CUSTOM_EVENT_TYPE = 2;
const logger = createLogger({ module: "umami-integration" });
export class UmamiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await input.fetchAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getWebsitesAsync(): Promise<UmamiWebsite[]> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
const json = await response.json();
// Response envelope differs by hosting:
// - Umami self-hosted returns the array directly: [website, ...]
// - Umami Cloud wraps it: { data: [website, ...] }
// Same divergence applies to the /events list responses below.
return Array.isArray(json)
? umamiWebsiteSchema.array().parse(json)
: umamiWebsiteSchema.array().parse((json as { data: unknown }).data);
}
// Server-side timeFrame resolution is intentional. Keeping `timeFrame` (a
// stable string like "7d") in the cache key means all users viewing the same
// dashboard share one Redis cache entry per (integration, website, timeFrame)
// for the 5-minute TTL. Client-supplied startAt/endAt would include a
// per-second `now` and fragment the cache one-per-user-per-second.
//
// Window bounds mirror Umami UI preset semantics, verified against the
// Umami Cloud dashboard for the same site and timeFrame: each "Last N"
// preset includes the in-progress period as one of N + 1 buckets, snapped
// to bucket boundaries.
// - "24h": current hour + 24 prior hours (25-bucket hour grid)
// - "7d": current day + 7 prior days ( 8-bucket day grid)
// - "30d": current day + 30 prior days (31-bucket day grid)
// - "today" / "month" / "lastMonth": calendar-aligned windows.
//
// Caveat: dayjs() uses the server's local timezone. Callers needing user-local
// boundaries (e.g. "today") may see off-by-one behaviour when the server
// runs in UTC but the user is in a different timezone.
private computeTimeRange(timeFrame: string): { startAt: number; endAt: number; unit: string } {
const startOfToday = dayjs().startOf("day");
const endOfToday = dayjs().endOf("day");
switch (timeFrame) {
case "today":
return { startAt: startOfToday.valueOf(), endAt: endOfToday.valueOf(), unit: "hour" };
case "7d":
return {
startAt: startOfToday.subtract(7, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "30d":
return {
startAt: startOfToday.subtract(30, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "month":
return {
startAt: dayjs().startOf("month").valueOf(),
endAt: dayjs().endOf("month").valueOf(),
unit: "day",
};
case "lastMonth": {
const lastMonth = dayjs().subtract(1, "month");
return {
startAt: lastMonth.startOf("month").valueOf(),
endAt: lastMonth.endOf("month").valueOf(),
unit: "day",
};
}
case "24h":
default:
return {
startAt: dayjs().subtract(24, "hour").startOf("hour").valueOf(),
endAt: dayjs().endOf("hour").valueOf(),
unit: "hour",
};
}
}
public async getVisitorStatsAsync(
websiteId: string,
timeFrame = "24h",
eventName?: string,
): Promise<UmamiVisitorStats> {
const authHeaders = await this.getAuthHeadersAsync();
const website = await this.getWebsiteByIdAsync(websiteId, authHeaders);
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const [stats, pageviews, eventMetrics, eventTimeSeries] = await Promise.all([
this.getWebsiteStatsAsync(website.id, startAt, endAt, authHeaders),
this.getWebsitePageviewsAsync(website.id, startAt, endAt, authHeaders, unit),
eventName ? this.getWebsiteEventMetricsAsync(website.id, startAt, endAt, authHeaders) : Promise.resolve(null),
eventName
? this.getWebsiteEventTimeSeriesAsync(website.id, startAt, endAt, unit, eventName, authHeaders)
: Promise.resolve(null),
]);
const eventCount = eventName && eventMetrics ? (eventMetrics.find(({ x }) => x === eventName)?.y ?? 0) : undefined;
const eventByTimestamp = new Map<number, number>(
(eventTimeSeries ?? []).map(({ x, y }) => [this.parseUmamiDate(x), y]),
);
const dataPoints = pageviews.sessions.map((point) => ({
timestamp: point.x,
visitors: point.y,
...(eventTimeSeries ? { events: eventByTimestamp.get(this.parseUmamiDate(point.x)) ?? 0 } : {}),
}));
return {
domain: website.domain,
websiteName: website.name,
websiteId: website.id,
totalVisitors: stats.visitors,
totalPageviews: stats.pageviews,
totalVisits: stats.visits,
bounceRate: stats.visits > 0 ? Math.round((stats.bounces / stats.visits) * 100) : 0,
avgDuration: stats.visits > 0 ? Math.round(stats.totaltime / stats.visits) : 0,
dataPoints,
timeFrame,
eventCount,
};
}
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
const authHeaders = await this.getAuthHeadersAsync();
const now = Date.now();
const startAt = dayjs().subtract(30, "day").valueOf();
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: now.toString(),
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load event names", { status: response.status, websiteId });
return [];
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
const names = new Set<string>();
for (const item of rawArray) {
const record = item as Record<string, unknown>;
if (typeof record.eventName === "string" && record.eventName) {
names.add(record.eventName);
}
}
return [...names].sort();
}
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url(`/websites/${websiteId}/active`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load active visitors", { status: response.status, websiteId });
return 0;
}
return umamiActiveVisitorsSchema.parse(await response.json()).visitors;
}
/**
* Umami self-hosted returns "YYYY-MM-DD HH:MM:SS"; Umami Cloud returns ISO 8601.
* Normalise both to epoch ms so timestamps from /pageviews and /events join.
*/
private parseUmamiDate(dateString: string): number {
return new Date(dateString.includes("T") ? dateString : `${dateString.replace(" ", "T")}Z`).getTime();
}
private async getWebsiteByIdAsync(websiteId: string, authHeaders: Record<string, string>): Promise<UmamiWebsite> {
const url = this.url(`/websites/${websiteId}`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
return umamiWebsiteSchema.parse(await response.json());
}
public async getTopPagesAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "path", authHeaders, limit);
}
public async getTopReferrersAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "referrer", authHeaders, limit);
}
public async getMultiEventTimeSeriesAsync(
websiteId: string,
timeFrame: string,
eventNames: string[],
): Promise<UmamiEventSeries[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const results = await Promise.all(
eventNames.map(async (eventName) => {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
return { eventName, dataPoints: dataPoints ?? [] };
}),
);
return results;
}
private async getWebsiteEventMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiMetricItem[]> {
return await this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "event", authHeaders);
}
private async getWebsiteMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
type: "event" | "path" | "referrer",
authHeaders: Record<string, string>,
limit?: number,
): Promise<UmamiMetricItem[]> {
const params: Record<string, string> = {
startAt: startAt.toString(),
endAt: endAt.toString(),
type,
};
if (limit !== undefined) params.limit = limit.toString();
const url = this.url(`/websites/${websiteId}/metrics`, params);
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load website metrics", { status: response.status, websiteId, type });
return [];
}
const json = await response.json();
if (Array.isArray(json)) return umamiMetricItemSchema.array().parse(json);
logger.warn("Unexpected metrics response shape", { websiteId, type });
return [];
}
private async getWebsiteEventTimeSeriesAsync(
websiteId: string,
startAt: number,
endAt: number,
unit: string,
eventName: string,
authHeaders: Record<string, string>,
): Promise<UmamiPageviewDataPoint[] | undefined> {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
eventName,
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
return undefined;
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
if (rawArray.length === 0) return undefined;
const firstItem = rawArray[0] as Record<string, unknown>;
// Two response shapes coexist:
// - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count }
// - Umami Cloud returns raw event records: { createdAt, eventName, eventType }
// We aggregate the raw Cloud records ourselves into the same bucket grid so
// the result joins cleanly with /pageviews timestamps.
// Aggregated { x, y } format — parse directly (self-hosted instances)
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
// Raw event records — aggregate by time bucket (Umami Cloud)
if ("createdAt" in firstItem) {
const pad = (number: number) => String(number).padStart(2, "0");
// Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS"
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") {
return `${dateString} ${pad(date.getUTCHours())}:00:00`;
}
if (unit === "month") {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
}
return `${dateString} 00:00:00`;
};
const buckets = new Map<string, number>();
for (const item of rawArray) {
const event = item as { eventType?: number; eventName?: string; createdAt: string };
if (event.eventType !== UMAMI_CUSTOM_EVENT_TYPE || event.eventName !== eventName) continue;
const bucket = truncate(event.createdAt);
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
}
if (buckets.size === 0) return undefined;
return (
Array.from(buckets.entries())
.sort(([itemA], [itemB]) => itemA.localeCompare(itemB))
// eslint-disable-next-line id-length
.map(([x, y]) => ({ x, y }))
);
}
return undefined;
}
private async getWebsiteStatsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiStats> {
const url = this.url(`/websites/${websiteId}/stats`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiStatsSchema.parse(await response.json());
}
private async getWebsitePageviewsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
unit = "hour",
): Promise<UmamiPageviews> {
const url = this.url(`/websites/${websiteId}/pageviews`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiPageviewsSchema.parse(await response.json());
}
private async getAuthHeadersAsync(): Promise<Record<string, string>> {
if (this.hasSecretValue("apiKey")) {
return { "x-umami-api-key": this.getSecretValue("apiKey") };
}
const token = await this.getJwtTokenAsync();
return { Authorization: `Bearer ${token}` };
}
private async getJwtTokenAsync(): Promise<string> {
const url = this.url("/auth/login");
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) throw new ResponseError(response);
return umamiAuthResponseSchema.parse(await response.json()).token;
}
}
@@ -0,0 +1,84 @@
import { z } from "zod/v4";
export const umamiWebsiteSchema = z.object({
id: z.string(),
name: z.string(),
domain: z.string(),
});
export type UmamiWebsite = z.infer<typeof umamiWebsiteSchema>;
export const umamiAuthResponseSchema = z.object({
token: z.string(),
});
export type UmamiAuthResponse = z.infer<typeof umamiAuthResponseSchema>;
export const umamiStatsSchema = z.object({
pageviews: z.number(),
visitors: z.number(),
visits: z.number(),
bounces: z.number(),
totaltime: z.number(),
});
export type UmamiStats = z.infer<typeof umamiStatsSchema>;
export const umamiPageviewDataPointSchema = z.object({
x: z.string(),
y: z.number(),
});
export type UmamiPageviewDataPoint = z.infer<typeof umamiPageviewDataPointSchema>;
export const umamiPageviewsSchema = z.object({
pageviews: z.array(umamiPageviewDataPointSchema),
sessions: z.array(umamiPageviewDataPointSchema),
});
export type UmamiPageviews = z.infer<typeof umamiPageviewsSchema>;
export const umamiMetricItemSchema = z.object({
// x can be null for direct traffic (no referrer) in the referrers endpoint
x: z
.string()
.nullable()
.transform((value) => value ?? ""),
y: z.number(),
});
export type UmamiMetricItem = z.infer<typeof umamiMetricItemSchema>;
export const umamiEventRecordSchema = z.object({
id: z.string(),
eventName: z.string(),
createdAt: z.string(),
});
export type UmamiEventRecord = z.infer<typeof umamiEventRecordSchema>;
export const umamiActiveVisitorsSchema = z.object({
visitors: z.number(),
});
export type UmamiActiveVisitors = z.infer<typeof umamiActiveVisitorsSchema>;
// Internal aggregated type returned by the integration — not a direct API response
export interface UmamiEventSeries {
eventName: string;
dataPoints: UmamiPageviewDataPoint[];
}
export interface UmamiVisitorStats {
domain: string;
websiteId: string;
websiteName: string;
totalVisitors: number;
totalPageviews: number;
totalVisits: number;
bounceRate: number;
avgDuration: number;
dataPoints: { timestamp: string; visitors: number; events?: number }[];
timeFrame: string;
eventCount?: number;
}
+1 -1
View File
@@ -24,7 +24,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
oldId: id,
id: createId(),
name: user.name.toLowerCase(),
colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme,
colorScheme: settings?.colorScheme === "environment" ? "auto" : settings?.colorScheme,
firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
provider: "credentials",
pingIconsEnabled: settings?.replacePingWithIcons,
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
import { createRedisConnection } from "./connection";
const mockRedisClient = {
subscribe: vi.fn().mockResolvedValue("OK"),
unsubscribe: vi.fn().mockResolvedValue("OK"),
on: vi.fn(),
};
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ debug: vi.fn(), warn: vi.fn() }),
}));
vi.mock("./connection", () => ({
createRedisConnection: vi.fn(() => mockRedisClient),
}));
describe("ChannelSubscriptionTracker", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset static state between tests
// @ts-expect-error accessing private static for test reset
ChannelSubscriptionTracker.subscriptions = new Map();
// @ts-expect-error accessing private static for test reset
ChannelSubscriptionTracker._redis = undefined;
// @ts-expect-error accessing private static for test reset
ChannelSubscriptionTracker.listenerActive = false;
});
test("redis client is lazily created on first subscribe", () => {
vi.mocked(createRedisConnection).mockClear();
ChannelSubscriptionTracker.subscribe("channel-1", vi.fn());
expect(createRedisConnection).toHaveBeenCalledOnce();
});
test("redis client is reused across multiple subscribes", () => {
vi.mocked(createRedisConnection).mockClear();
ChannelSubscriptionTracker.subscribe("channel-1", vi.fn());
ChannelSubscriptionTracker.subscribe("channel-2", vi.fn());
expect(createRedisConnection).toHaveBeenCalledOnce();
});
test("subscribes to redis channel on first callback for a channel", () => {
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
expect(mockRedisClient.subscribe).toHaveBeenCalledWith("test-channel");
});
test("does not re-subscribe when adding second callback to same channel", () => {
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
mockRedisClient.subscribe.mockClear();
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
expect(mockRedisClient.subscribe).not.toHaveBeenCalled();
});
test("unsubscribe function removes the callback", () => {
const unsubscribe = ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
unsubscribe();
expect(mockRedisClient.unsubscribe).toHaveBeenCalledWith("test-channel");
});
test("does not unsubscribe from redis if other callbacks remain", () => {
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
const unsubscribe = ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
unsubscribe();
expect(mockRedisClient.unsubscribe).not.toHaveBeenCalled();
});
test("activates message listener only once", () => {
ChannelSubscriptionTracker.subscribe("ch-1", vi.fn());
ChannelSubscriptionTracker.subscribe("ch-2", vi.fn());
expect(mockRedisClient.on).toHaveBeenCalledTimes(1);
expect(mockRedisClient.on).toHaveBeenCalledWith("message", expect.any(Function));
});
test("message listener dispatches to correct channel callbacks", () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
ChannelSubscriptionTracker.subscribe("channel-a", callback1);
ChannelSubscriptionTracker.subscribe("channel-b", callback2);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const messageHandler = mockRedisClient.on.mock.calls[0]![1] as (channel: string, message: string) => void;
messageHandler("channel-a", "test-message");
expect(callback1).toHaveBeenCalledWith("test-message");
expect(callback2).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
import type { MaybePromise } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import { createRedisConnection } from "./connection";
@@ -18,7 +19,10 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
*/
export class ChannelSubscriptionTracker {
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
private static redis = createRedisConnection();
private static _redis: RedisClient | undefined;
private static get redis() {
return (this._redis ??= createRedisConnection());
}
private static listenerActive = false;
/**
+147
View File
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
createCacheChannel,
createGetSetChannel,
createListChannel,
createSubPubChannel,
handshakeAsync,
} from "./channel";
import { createRedisConnection } from "./connection";
const mockRedisClient = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue("OK"),
del: vi.fn().mockResolvedValue(1),
publish: vi.fn().mockResolvedValue(1),
lrange: vi.fn().mockResolvedValue([]),
lrem: vi.fn().mockResolvedValue(0),
lpush: vi.fn().mockResolvedValue(1),
ltrim: vi.fn().mockResolvedValue("OK"),
llen: vi.fn().mockResolvedValue(0),
hello: vi.fn().mockResolvedValue({}),
subscribe: vi.fn().mockResolvedValue("OK"),
on: vi.fn(),
};
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }),
}));
vi.mock("./connection", () => ({
createRedisConnection: vi.fn(() => mockRedisClient),
}));
vi.mock("./channel-subscription-tracker", () => ({
ChannelSubscriptionTracker: {
subscribe: vi.fn(() => vi.fn()),
},
}));
describe("channel lazy initialization", () => {
beforeEach(() => {
vi.mocked(createRedisConnection).mockClear();
});
test("getSetClient is lazily created on first channel operation", async () => {
vi.mocked(createRedisConnection).mockClear();
const channel = createGetSetChannel<string>("test-lazy");
expect(createRedisConnection).not.toHaveBeenCalled();
await channel.getAsync();
expect(createRedisConnection).toHaveBeenCalled();
});
test("publisher is lazily created on first publish", async () => {
const channel = createSubPubChannel<string>("test-pub", { persist: false });
const callsBefore = vi.mocked(createRedisConnection).mock.calls.length;
await channel.publishAsync("hello");
expect(vi.mocked(createRedisConnection).mock.calls.length).toBeGreaterThan(callsBefore);
});
test("handshakeAsync creates a client and calls hello", async () => {
await handshakeAsync();
expect(mockRedisClient.hello).toHaveBeenCalledOnce();
});
});
describe("createSubPubChannel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("publishAsync persists data and publishes", async () => {
const channel = createSubPubChannel<{ value: number }>("test-channel");
await channel.publishAsync({ value: 42 });
expect(mockRedisClient.set).toHaveBeenCalledOnce();
expect(mockRedisClient.publish).toHaveBeenCalledOnce();
});
test("publishAsync with persist:false skips set", async () => {
const channel = createSubPubChannel<string>("no-persist", { persist: false });
await channel.publishAsync("data");
expect(mockRedisClient.set).not.toHaveBeenCalled();
expect(mockRedisClient.publish).toHaveBeenCalledOnce();
});
test("getLastDataAsync returns null when no data", async () => {
const channel = createSubPubChannel<string>("empty");
const result = await channel.getLastDataAsync();
expect(result).toBeNull();
});
});
describe("createListChannel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("addAsync pushes to list", async () => {
const channel = createListChannel<string>("test-list");
await channel.addAsync("item1");
expect(mockRedisClient.lpush).toHaveBeenCalledOnce();
});
test("clearAsync deletes the list", async () => {
const channel = createListChannel<string>("test-list");
await channel.clearAsync();
expect(mockRedisClient.del).toHaveBeenCalledOnce();
});
});
describe("createCacheChannel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("invalidateAsync deletes the cache key", async () => {
const channel = createCacheChannel<string>("test-cache");
await channel.invalidateAsync();
expect(mockRedisClient.del).toHaveBeenCalledWith("cache:test-cache");
});
test("getAsync returns null when no cached data", async () => {
const channel = createCacheChannel<string>("test-cache");
const result = await channel.getAsync();
expect(result).toBeNull();
});
});
+58 -54
View File
@@ -2,6 +2,7 @@ import superjson from "superjson";
import { createId, hashObjectBase64 } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import type { WidgetKind } from "@homarr/definitions";
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
@@ -9,8 +10,11 @@ import { createRedisConnection } from "./connection";
const logger = createLogger({ module: "redisChannel" });
const publisher = createRedisConnection();
const lastDataClient = createRedisConnection();
let _publisher: RedisClient | undefined;
const getPublisher = () => (_publisher ??= createRedisConnection());
let _lastDataClient: RedisClient | undefined;
const getLastDataClient = () => (_lastDataClient ??= createRedisConnection());
/**
* Creates a new pub/sub channel.
@@ -27,34 +31,33 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
*/
subscribe: (callback: (data: TData) => void) => {
if (persist) {
void lastDataClient.get(lastChannelName).then((data) => {
if (data) {
callback(superjson.parse(data));
}
});
void getLastDataClient()
.get(lastChannelName)
.then((data) => {
if (data) {
callback(superjson.parse(data));
}
});
}
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
callback(superjson.parse(message));
});
},
/**
* Publish data to the channel with last data saved.
* @param data data to be published
*/
publishAsync: async (data: TData) => {
if (persist) {
await lastDataClient.set(lastChannelName, superjson.stringify(data));
await getLastDataClient().set(lastChannelName, superjson.stringify(data));
}
await publisher.publish(channelName, superjson.stringify(data));
await getPublisher().publish(channelName, superjson.stringify(data));
},
getLastDataAsync: async () => {
const data = await lastDataClient.get(lastChannelName);
const data = await getLastDataClient().get(lastChannelName);
return data ? superjson.parse<TData>(data) : null;
},
};
};
const getSetClient = createRedisConnection();
let _getSetClient: RedisClient | undefined;
const getSetClient = () => (_getSetClient ??= createRedisConnection());
/**
* Creates a new redis channel for a list
@@ -69,7 +72,7 @@ export const createListChannel = <TItem>(name: string) => {
* @returns an array of all items
*/
getAllAsync: async () => {
const items = await getSetClient.lrange(listChannelName, 0, -1);
const items = await getSetClient().lrange(listChannelName, 0, -1);
return items.map((item) => superjson.parse<TItem>(item));
},
/**
@@ -77,20 +80,20 @@ export const createListChannel = <TItem>(name: string) => {
* @param item item to remove
*/
removeAsync: async (item: TItem) => {
await getSetClient.lrem(listChannelName, 0, superjson.stringify(item));
await getSetClient().lrem(listChannelName, 0, superjson.stringify(item));
},
/**
* Clear all items from the channels list
*/
clearAsync: async () => {
await getSetClient.del(listChannelName);
await getSetClient().del(listChannelName);
},
/**
* Add an item to the channels list
* @param item item to add
*/
addAsync: async (item: TItem) => {
await getSetClient.lpush(listChannelName, superjson.stringify(item));
await getSetClient().lpush(listChannelName, superjson.stringify(item));
},
};
};
@@ -106,7 +109,7 @@ export const createGetSetChannel = <TData>(name: string) => {
* @returns data or null if not found
*/
getAsync: async () => {
const data = await getSetClient.get(name);
const data = await getSetClient().get(name);
return data ? superjson.parse<TData>(data) : null;
},
/**
@@ -114,13 +117,13 @@ export const createGetSetChannel = <TData>(name: string) => {
* @param data data to be stored in the channel
*/
setAsync: async (data: TData) => {
await getSetClient.set(name, superjson.stringify(data));
await getSetClient().set(name, superjson.stringify(data));
},
/**
* Remove data from the channel
*/
removeAsync: async () => {
await getSetClient.del(name);
await getSetClient().del(name);
},
};
};
@@ -140,7 +143,7 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
* @returns data or null if not found or expired
*/
getAsync: async () => {
const data = await getSetClient.get(cacheChannelName);
const data = await getSetClient().get(cacheChannelName);
if (!data) return null;
const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
@@ -156,13 +159,13 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
* @returns data or new data if not present or expired
*/
consumeAsync: async (callback: () => Promise<TData>) => {
const data = await getSetClient.get(cacheChannelName);
const data = await getSetClient().get(cacheChannelName);
const getNewDataAsync = async () => {
logger.debug(`Cache miss for channel '${cacheChannelName}'`);
const newData = await callback();
const result = { data: newData, timestamp: new Date() };
await getSetClient.set(cacheChannelName, superjson.stringify(result));
await getSetClient().set(cacheChannelName, superjson.stringify(result));
logger.debug(`Cache updated for channel '${cacheChannelName}'`);
return result;
};
@@ -187,14 +190,14 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
* Invalidate the cache channels data.
*/
invalidateAsync: async () => {
await getSetClient.del(cacheChannelName);
await getSetClient().del(cacheChannelName);
},
/**
* Set the data in the cache channel.
* @param data data to be stored in the cache channel
*/
setAsync: async (data: TData) => {
await getSetClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() }));
await getSetClient().set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() }));
},
};
};
@@ -236,12 +239,12 @@ export const createChannelEventHistory = <TData>(channelName: string, maxElement
});
},
pushAsync: async (data: TData, options = { publish: false }) => {
if (options.publish) await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await getSetClient.ltrim(channelName, 0, maxElements);
if (options.publish) await getPublisher().publish(channelName, superjson.stringify(data));
await getSetClient().lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await getSetClient().ltrim(channelName, 0, maxElements);
},
clearAsync: async () => {
await getSetClient.del(channelName);
await getSetClient().del(channelName);
},
/**
* Returns a slice of the available data in the channel.
@@ -250,17 +253,17 @@ export const createChannelEventHistory = <TData>(channelName: string, maxElement
* @param endIndex End index of the slice, negative values are counted from the end, defaults at end of range.
*/
getSliceAsync: async (startIndex = 0, endIndex = -1) => {
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
const range = await getSetClient().lrange(channelName, startIndex, endIndex);
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
},
getSliceUntilTimeAsync: async (time: Date) => {
const itemsInCollection = await getSetClient.lrange(channelName, 0, -1);
const itemsInCollection = await getSetClient().lrange(channelName, 0, -1);
return itemsInCollection
.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item))
.filter((item) => item.timestamp < time);
},
getLengthAsync: async () => {
return await getSetClient.llen(channelName);
return await getSetClient().llen(channelName);
},
name: channelName,
};
@@ -271,11 +274,11 @@ export const createChannelEventHistory = <TData>(channelName: string, maxElement
*/
export const createChannelEventHistoryOld = <TData>(channelName: string, maxElements = 15) => {
const popElementsOverMaxAsync = async () => {
const length = await getSetClient.llen(channelName);
const length = await getSetClient().llen(channelName);
if (length <= maxElements) {
return;
}
await getSetClient.ltrim(channelName, 0, maxElements - 1);
await getSetClient().ltrim(channelName, 0, maxElements - 1);
};
return {
@@ -285,33 +288,33 @@ export const createChannelEventHistoryOld = <TData>(channelName: string, maxElem
});
},
publishAndPushAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await getPublisher().publish(channelName, superjson.stringify(data));
await getSetClient().lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
},
pushAsync: async (data: TData) => {
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await getSetClient().lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
},
clearAsync: async () => {
await getSetClient.del(channelName);
await getSetClient().del(channelName);
},
getLastAsync: async () => {
const length = await getSetClient.llen(channelName);
const data = await getSetClient.lrange(channelName, length - 1, length);
const length = await getSetClient().llen(channelName);
const data = await getSetClient().lrange(channelName, length - 1, length);
if (data.length !== 1) return null;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!);
},
getSliceAsync: async (startIndex: number, endIndex: number) => {
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
const range = await getSetClient().lrange(channelName, startIndex, endIndex);
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
},
getSliceUntilTimeAsync: async (time: Date) => {
const length = await getSetClient.llen(channelName);
const length = await getSetClient().llen(channelName);
const items: TData[] = [];
const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1);
const itemsInCollection = await getSetClient().lrange(channelName, 0, length - 1);
for (let i = 0; i < length - 1; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -324,7 +327,7 @@ export const createChannelEventHistoryOld = <TData>(channelName: string, maxElem
return items;
},
getLengthAsync: async () => {
return await getSetClient.llen(channelName);
return await getSetClient().llen(channelName);
},
name: channelName,
};
@@ -338,14 +341,14 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
});
},
publishAndUpdateLastStateAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
await getPublisher().publish(channelName, superjson.stringify(data));
await getSetClient().set(channelName, superjson.stringify({ data, timestamp: new Date() }));
},
setAsync: async (data: TData) => {
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
await getSetClient().set(channelName, superjson.stringify({ data, timestamp: new Date() }));
},
getAsync: async () => {
const data = await getSetClient.get(channelName);
const data = await getSetClient().get(channelName);
if (!data) return null;
return superjson.parse<{ data: TData; timestamp: Date }>(data);
@@ -354,7 +357,8 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
};
};
const queueClient = createRedisConnection();
let _queueClient: RedisClient | undefined;
const getQueueClient = () => (_queueClient ??= createRedisConnection());
type WithId<TItem> = TItem & { _id: string };
@@ -366,11 +370,11 @@ type WithId<TItem> = TItem & { _id: string };
export const createQueueChannel = <TItem>(name: string) => {
const queueChannelName = `queue:${name}`;
const getDataAsync = async () => {
const data = await queueClient.get(queueChannelName);
const data = await getQueueClient().get(queueChannelName);
return data ? superjson.parse<WithId<TItem>[]>(data) : [];
};
const setDataAsync = async (data: WithId<TItem>[]) => {
await queueClient.set(queueChannelName, superjson.stringify(data));
await getQueueClient().set(queueChannelName, superjson.stringify(data));
};
return {
@@ -417,5 +421,5 @@ export const createQueueChannel = <TItem>(name: string) => {
};
export const handshakeAsync = async () => {
await getSetClient.hello();
await getSetClient().hello();
};
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, test, vi } from "vitest";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import { createRedisConnection } from "./connection";
const mockRedisInstance = { ping: vi.fn() };
vi.mock("@homarr/core/infrastructure/redis", () => ({
createRedisClient: vi.fn(() => mockRedisInstance),
}));
describe("createRedisConnection", () => {
test("returns a redis client regardless of CI env", () => {
const original = process.env.CI;
process.env.CI = "true";
const client = createRedisConnection();
expect(client).toBe(mockRedisInstance);
expect(client).not.toBeNull();
process.env.CI = original;
});
test("returns a redis client when DISABLE_REDIS_LOGS is set", () => {
const original = process.env.DISABLE_REDIS_LOGS;
process.env.DISABLE_REDIS_LOGS = "true";
const client = createRedisConnection();
expect(client).toBe(mockRedisInstance);
expect(client).not.toBeNull();
process.env.DISABLE_REDIS_LOGS = original;
});
test("always delegates to createRedisClient", () => {
vi.mocked(createRedisClient).mockClear();
createRedisConnection();
expect(createRedisClient).toHaveBeenCalledOnce();
});
});
+1 -13
View File
@@ -1,15 +1,3 @@
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
/**
* Creates a new Redis connection
* @returns redis client
*/
export const createRedisConnection = () => {
if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) {
// Return null if we are in CI as we don't want to connect to Redis
return null as unknown as RedisClient;
}
return createRedisClient();
};
export const createRedisConnection = () => createRedisClient();
+34 -18
View File
@@ -2,6 +2,9 @@ import dayjs from "dayjs";
import type { ContainerInfo, ContainerStats } from "dockerode";
import type Dockerode from "dockerode";
import { bestMatch } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import type { ContainerState } from "@homarr/docker";
@@ -9,6 +12,8 @@ import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
@@ -18,19 +23,34 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
cacheDuration: dayjs.duration(20, "seconds"),
});
const dockerInstances = DockerSingleton.getInstances();
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
async function getContainersWithStatsAsync() {
const containers = await Promise.all(
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const instanceContainers = await instance.listContainers({ all: true });
return instanceContainers
.filter((container) => !(dockerLabels.hide in container.Labels))
.map((container) => ({ ...container, instance: host }));
}),
).then((res) => res.flat());
);
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value;
logger.warn(
new ErrorWithMetadata(
"Failed to list containers from Docker host",
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
host: dockerInstances[index]!.host,
},
{ cause: result.reason },
),
);
return [];
});
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
const dbIcons =
@@ -44,8 +64,6 @@ async function getContainersWithStatsAsync() {
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
if (!instance) return null;
// Get stats, falling back to an empty stats object if fetch fails
// calculateCpuUsage and calculateMemoryUsage will return 0 for invalid/missing stats
const stats = await instance
.getContainer(container.Id)
.stats({ stream: false, "one-shot": true })
@@ -63,13 +81,9 @@ async function getContainersWithStatsAsync() {
return {
id: container.Id,
name: container.Names[0]?.split("/")[1] ?? "Unknown",
host: container.instance,
state: container.State as ContainerState,
iconUrl:
dbIcons.find((icon) => {
const extractedImage = extractImage(container);
if (!extractedImage) return false;
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
})?.url ?? null,
iconUrl: bestMatch(extractImage(container), dbIcons, (icon) => icon.name)?.url ?? null,
cpuUsage,
memoryUsage,
image: container.Image,
@@ -80,9 +94,10 @@ async function getContainersWithStatsAsync() {
return (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
}
function calculateCpuUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers)
if (!stats.cpu_stats.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage.total_usage) {
export function calculateCpuUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!stats.cpu_stats?.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage?.total_usage) {
return 0;
}
@@ -95,9 +110,10 @@ function calculateCpuUsage(stats: ContainerStats): number {
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
}
function calculateMemoryUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers)
if (!stats.memory_stats.usage) {
export function calculateMemoryUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!stats.memory_stats?.usage) {
return 0;
}
@@ -0,0 +1,79 @@
import dayjs from "dayjs";
import { z } from "zod";
import { env } from "@homarr/common/env";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
export type DuckDuckGoBang = z.infer<typeof duckDuckGoBangSchema>;
const DUCKDUCKGO_BANGS_URL = "https://duckduckgo.com/bang.js";
/**
* DuckDuckGo `bang.js` uses short keys (to reduce the payload size):
* - `t`: bang token (e.g. `"yt"`)
* - `s`: display name
* - `d`: domain (optional)
* - `u`: URL template (contains `{{{s}}}`)
* - `c`: category (optional)
* - `sc`: subcategory (optional)
* - `r`: rank (optional)
*/
export const duckDuckGoBangSchema = z.object({
t: z.string(),
s: z.string(),
d: z.string().optional(),
u: z.string(),
c: z.string().optional(),
sc: z.string().optional(),
r: z.number().optional(),
});
const duckDuckGoBangsResponseSchema = z.array(duckDuckGoBangSchema);
const normalizeBangToken = (token: string) => token.toLowerCase().trim();
const compareBangTokenAsc = (left: DuckDuckGoBang, right: DuckDuckGoBang) => left.t.localeCompare(right.t);
export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({
queryKey: "duckduckgo-bangs",
cacheDuration: dayjs.duration(1, "day"),
async requestAsync(_) {
if (env.NO_EXTERNAL_CONNECTION) {
return [];
}
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(DUCKDUCKGO_BANGS_URL, {
signal,
headers: {
accept: "application/json,text/plain,*/*",
},
});
});
if (!res.ok) {
throw new ResponseError(res);
}
const json: unknown = await res.json();
const bangs = await duckDuckGoBangsResponseSchema.parseAsync(json);
const normalized = bangs
.map((bang) => ({
...bang,
t: normalizeBangToken(bang.t),
}))
.filter((bang) => bang.t.length > 0);
normalized.sort(compareBangTokenAsc);
return normalized;
},
createRedisChannel() {
return createChannelWithLatestAndEvents<DuckDuckGoBang[]>("homarr:duckduckgo-bangs");
},
});
@@ -0,0 +1,121 @@
import type { ContainerStats } from "dockerode";
import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { calculateCpuUsage, calculateMemoryUsage } from "../docker";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/db")>();
return {
...actual,
db: createDb(),
};
});
// Helper to create a partial stats object, cast to ContainerStats.
// This mirrors what Podman and other Docker-compatible runtimes may return
// at runtime, even though @types/dockerode declares all fields as required.
const createStats = (overrides: Record<string, unknown> = {}) => overrides as unknown as ContainerStats;
describe("calculateCpuUsage", () => {
test("should return 0 when cpu_stats is undefined (Podman)", () => {
const stats = createStats({ cpu_stats: undefined });
expect(calculateCpuUsage(stats)).toBe(0);
});
test("should return 0 when online_cpus is 0", () => {
const stats = createStats({
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
});
expect(calculateCpuUsage(stats)).toBe(0);
});
test("should return 0 when online_cpus is undefined", () => {
const stats = createStats({
cpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 },
});
expect(calculateCpuUsage(stats)).toBe(0);
});
test("should return 0 when cpu_usage is undefined", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, system_cpu_usage: 5000 },
});
expect(calculateCpuUsage(stats)).toBe(0);
});
test("should return 0 when total_usage is 0", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 0 }, system_cpu_usage: 5000 },
});
expect(calculateCpuUsage(stats)).toBe(0);
});
test("should return 0 when system_cpu_usage is 0", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1000 }, system_cpu_usage: 0 },
});
expect(calculateCpuUsage(stats)).toBe(0);
});
test("should calculate correct CPU percentage for valid stats", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000 },
});
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
});
test("should handle fractional CPU usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 2, cpu_usage: { total_usage: 500 }, system_cpu_usage: 100000 },
});
// (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1);
});
});
describe("calculateMemoryUsage", () => {
test("should return 0 when memory_stats is undefined (Podman)", () => {
const stats = createStats({ memory_stats: undefined });
expect(calculateMemoryUsage(stats)).toBe(0);
});
test("should return 0 when memory_stats.usage is 0", () => {
const stats = createStats({ memory_stats: { usage: 0 } });
expect(calculateMemoryUsage(stats)).toBe(0);
});
test("should return 0 when memory_stats.usage is undefined", () => {
const stats = createStats({ memory_stats: {} });
expect(calculateMemoryUsage(stats)).toBe(0);
});
test("should subtract cache from usage", () => {
const stats = createStats({
memory_stats: { usage: 1000, stats: { cache: 200 } },
});
expect(calculateMemoryUsage(stats)).toBe(800);
});
test("should use total_inactive_file when cache is absent", () => {
const stats = createStats({
memory_stats: { usage: 1000, stats: { total_inactive_file: 300 } },
});
expect(calculateMemoryUsage(stats)).toBe(700);
});
test("should use inactive_file as last fallback", () => {
const stats = createStats({
memory_stats: { usage: 1000, stats: { inactive_file: 150 } },
});
expect(calculateMemoryUsage(stats)).toBe(850);
});
test("should return raw usage when memory_stats.stats is undefined", () => {
const stats = createStats({ memory_stats: { usage: 512 } });
expect(calculateMemoryUsage(stats)).toBe(512);
});
});
@@ -0,0 +1,138 @@
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import { Octokit } from "octokit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getAvailableUpdatesAsync } from "../update-checker";
const mockEnv = vi.hoisted(() => ({
NO_EXTERNAL_CONNECTION: false,
}));
vi.mock("@homarr/common/env", () => ({
env: mockEnv,
}));
vi.mock("octokit", () => {
const mockOctokit = {
rest: {
repos: {
listReleases: vi.fn(),
},
},
};
return {
Octokit: class {
rest = mockOctokit.rest;
},
};
});
describe("getAvailableUpdatesAsync", () => {
beforeEach(() => {
mockEnv.NO_EXTERNAL_CONNECTION = false;
});
test("should return available non pre-release updates", async () => {
// Arrange
const currentVersion = "v1.2.3";
const releases = fakeReleasesWithPrereleases();
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]);
expect(listReleasesSpy).toHaveBeenCalledWith({
owner: "homarr-labs",
repo: "homarr",
});
});
test("should return all available updates", async () => {
// Arrange
const currentVersion = "v1.2.4-beta.2";
const releases = fakeReleasesWithPrereleases();
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual([
"v1.3.1-beta.1",
"v1.3.0",
"v1.3.0-beta.1",
"v1.2.4",
"v1.2.4-beta.3",
]);
expect(listReleasesSpy).toHaveBeenCalledWith({
owner: "homarr-labs",
repo: "homarr",
query: undefined,
});
});
test("should skip releases with invalid semver tags", async () => {
// Arrange
const currentVersion = "v1.2.3";
const releases = fakeReleases(["v1.2.4", false], ["invalid-tag", false], ["v1.3.0", false]);
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]);
});
test("should throw error for invalid current version", async () => {
// Arrange
const currentVersion = "invalid-version";
// Act
const act = () => getAvailableUpdatesAsync(currentVersion);
// Assert
await expect(act()).rejects.toThrow("non semantic current version");
});
test("should return empty array when NO_EXTERNAL_CONNECTION=true", async () => {
// Arrange
mockEnv.NO_EXTERNAL_CONNECTION = true;
const currentVersion = "v1.0.0";
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result).toEqual([]);
});
});
const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease);
const fakeRelease = ([tagName, isPrerelease]: [string, boolean]) =>
({
tag_name: tagName,
html_url: `https://github.com/homarr-labs/homarr/releases/tag/${tagName}`,
name: tagName,
body_html: undefined,
prerelease: isPrerelease,
}) as RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"][number];
const fakeReleasesWithPrereleases = () =>
fakeReleases(
["v1.1.0", false],
["v1.2.2", false],
["v1.2.3", false],
["v1.2.4-beta.1", true],
["v1.2.4-beta.2", true],
["v1.2.4-beta.3", true],
["v1.2.4", false],
["v1.3.0-beta.1", true],
["v1.3.0", false],
["v1.3.1-beta.1", true],
);
+84
View File
@@ -0,0 +1,84 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const umamiRequestHandler = createCachedIntegrationRequestHandler<
UmamiVisitorStats,
"umami",
{ websiteId: string; timeFrame: string; eventName?: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiVisitorStats",
});
export const umamiEventNamesRequestHandler = createCachedIntegrationRequestHandler<
string[],
"umami",
{ websiteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getEventNamesAsync(input.websiteId);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiEventNames",
});
export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopPages",
});
export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopReferrers",
});
export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandler<
UmamiEventSeries[],
"umami",
{ websiteId: string; timeFrame: string; eventNames: string[] }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiMultiEvent",
});
export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestHandler<
number,
"umami",
{ websiteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "umamiActiveVisitors",
});
+92 -48
View File
@@ -1,10 +1,11 @@
import dayjs from "dayjs";
import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer } from "semver-parser";
import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser";
import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
@@ -16,58 +17,101 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
if (env.NO_EXTERNAL_CONNECTION)
return {
availableUpdates: [],
};
const octokit = new Octokit({
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
});
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const currentVersion = (packageJson as { version: string }).version;
const availableReleases = [];
for (const release of releases.data) {
if (!isValidSemVer(release.tag_name)) {
logger.warn("Unable to parse semantic tag. Update check might not work.", { tagName: release.tag_name });
continue;
}
availableReleases.push(release);
}
const availableNewerReleases = availableReleases
.filter((release) => compareSemVer(release.tag_name, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tag_name, releaseA.tag_name));
if (availableNewerReleases.length > 0) {
logger.info(
"Update checker found a new available version",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ version: availableReleases[0]!.tag_name, currentVersion },
);
} else {
logger.debug("Update checker did not find any available updates", { currentVersion });
}
return {
availableUpdates: availableNewerReleases.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tagName: release.tag_name,
})),
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
};
},
createRedisChannel() {
return createChannelWithLatestAndEvents<{
availableUpdates: { name: string | null; contentHtml?: string; url: string; tagName: string }[];
availableUpdates: Update[];
}>("homarr:update");
},
});
interface Update {
name: string | null;
contentHtml?: string;
url: string;
tagName: string;
isPrerelease: boolean;
}
const isPrereleaseTag = (tagName: string) => {
try {
const parsed = parseSemVer(tagName);
return Boolean(parsed.pre?.length);
} catch {
return false;
}
};
export const getAvailableUpdatesAsync = async (currentVersion: string) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
if (!isValidSemVer(currentVersion)) {
throw new ErrorWithMetadata("Unable to check for updates due to non semantic current version", {
currentVersion,
});
}
const octokit = new Octokit({
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
});
const isCurrentPrerelease = isPrereleaseTag(currentVersion);
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const { skippedTags, semanticReleases } = releases.data
.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tagName: release.tag_name,
isPrerelease: release.prerelease,
}))
.reduce(
(prev, curr) => {
if (!isValidSemVer(curr.tagName)) {
prev.skippedTags.push(curr.tagName);
return prev;
}
prev.semanticReleases.push(curr);
return prev;
},
{ semanticReleases: [] as Update[], skippedTags: [] as string[] },
);
if (skippedTags.length > 0) {
logger.warn(
"Some releases were skipped during the update check because their tag name is not a valid semantic version",
{
skippedTags: skippedTags.join(","),
},
);
}
const availableUpdates = semanticReleases
.filter((release) => isCurrentPrerelease || !release.isPrerelease)
.filter((release) => compareSemVer(release.tagName, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
if (availableUpdates.length === 0) {
logger.debug("No available updates found", { currentVersion });
return [];
}
logger.info("Found available updates", {
version: availableUpdates[0]?.tagName,
count: availableUpdates.length,
currentVersion,
});
return availableUpdates;
};
+1 -1
View File
@@ -36,7 +36,7 @@ export const defaultServerSettings = {
enableGravatar: true,
},
appearance: {
defaultColorScheme: "light" as ColorScheme,
defaultColorScheme: "auto" as ColorScheme,
},
culture: {
defaultLocale: "en" as SupportedLanguage,
+3
View File
@@ -8,6 +8,7 @@ export type SettingsContextProps = Pick<
| "homeBoardId"
| "mobileHomeBoardId"
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
@@ -29,6 +30,7 @@ export type UserSettings = Pick<
| "homeBoardId"
| "mobileHomeBoardId"
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
>;
@@ -41,6 +43,7 @@ export const createSettings = ({
}) => ({
defaultSearchEngineId: user?.defaultSearchEngineId ?? serverSettings.search.defaultSearchEngineId,
openSearchInNewTab: user?.openSearchInNewTab ?? true,
ddgBangs: user?.ddgBangs ?? true,
firstDayOfWeek: user?.firstDayOfWeek ?? (1 as const),
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
@@ -12,6 +12,7 @@ import { SpotlightGroupActionItem } from "./items/group-action-item";
interface GroupActionsProps<TOption extends Record<string, unknown>> {
group: SearchGroup<TOption>;
query: string;
setQuery: (query: string) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
@@ -19,6 +20,7 @@ interface GroupActionsProps<TOption extends Record<string, unknown>> {
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
group,
query,
setQuery,
setMode,
setChildrenOptions,
}: GroupActionsProps<TOption>) => {
@@ -58,6 +60,7 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
option={option}
group={group}
query={query}
setQuery={setQuery}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
@@ -90,6 +93,7 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
option={option}
group={group}
query={query}
setQuery={setQuery}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
@@ -11,6 +11,7 @@ import { SpotlightGroupActions } from "../group-actions";
interface SpotlightActionGroupsProps {
groups: SearchGroup[];
query: string;
setQuery: (query: string) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
@@ -19,7 +20,10 @@ export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGrou
const t = useI18n();
return groups.map((group) => (
<Spotlight.ActionsGroup key={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}>
<Spotlight.ActionsGroup
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
label={translateIfNecessary(t, group.title)}
>
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any> group={group} {...others} />
</Spotlight.ActionsGroup>
@@ -5,11 +5,13 @@ import { Link } from "@homarr/ui";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import { selectAction, spotlightStore } from "../../../spotlight-store";
import classes from "./action-item.module.css";
interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>> {
option: TOption;
query: string;
setQuery: (query: string) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
group: SearchGroup<TOption>;
@@ -18,11 +20,15 @@ interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>>
export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>>({
group,
query,
setQuery,
setMode,
setChildrenOptions,
option,
}: SpotlightGroupActionItemProps<TOption>) => {
const interaction = group.useInteraction(option, query);
// Avoid passing React's special `key` prop via spread
const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record<string, unknown>;
const renderRoot =
interaction.type === "link"
@@ -34,6 +40,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
const handleClickAsync = async () => {
if (interaction.type === "javaScript") {
await interaction.onSelect();
} else if (interaction.type === "setQuery") {
setQuery(interaction.query);
setTimeout(() => selectAction(0, spotlightStore));
} else if (interaction.type === "mode") {
setMode(interaction.mode);
} else if (interaction.type === "children") {
@@ -46,11 +55,14 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
renderRoot={renderRoot}
onClick={handleClickAsync}
closeSpotlightOnTrigger={
interaction.type !== "mode" && interaction.type !== "children" && interaction.type !== "none"
interaction.type !== "mode" &&
interaction.type !== "children" &&
interaction.type !== "none" &&
interaction.type !== "setQuery"
}
className={classes.spotlightAction}
>
<group.Component {...option} />
<group.Component {...(optionProps as TOption)} />
</Spotlight.Action>
);
};
+26 -24
View File
@@ -4,7 +4,7 @@ import type { Dispatch, SetStateAction } from "react";
import { useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconQuestionMark, IconSearch, IconX } from "@tabler/icons-react";
import { IconSearch, IconX } from "@tabler/icons-react";
import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
@@ -13,6 +13,7 @@ import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -22,6 +23,7 @@ type SearchModeKey = keyof TranslationObject["search"]["mode"];
const defaultMode = "home";
export const Spotlight = () => {
const searchModeState = useState<SearchModeKey>(defaultMode);
const queryState = useState("");
const mode = searchModeState[0];
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
@@ -30,16 +32,19 @@ export const Spotlight = () => {
}
// We use the "key" below to prevent the 'Different amounts of hooks' error
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
);
};
interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
queryState: [string, Dispatch<SetStateAction<string>>];
activeMode: SearchMode;
}
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = useState("");
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = queryState;
const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n();
@@ -47,28 +52,34 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
// Works as always the same amount of hooks are executed
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroups();
return (
<MantineSpotlight.Root
shortcut={hotkeys.openSpotlight}
yOffset={8}
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
onSpotlightClose={() => {
setMode(defaultMode);
setChildrenOptions(null);
setQuery("");
}}
query={query}
onQueryChange={(query) => {
if (mode !== "help" || query.length !== 1) {
setQuery(query);
}
onQueryChange={(nextQuery) => {
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
setQuery(sanitizedQuery);
const modeToActivate = searchModes.find((mode) => mode.character === query);
if (!modeToActivate) {
return;
}
// Only switch modes when a single trigger character is typed
if (sanitizedQuery.length !== 1) return;
const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery);
if (!modeToActivate) return;
setMode(modeToActivate.modeKey);
setChildrenOptions(null);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}}
store={spotlightStore}
@@ -91,17 +102,7 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
},
}}
rightSection={
mode === defaultMode ? (
<ActionIcon
onClick={() => {
setMode("help");
inputRef.current?.focus();
}}
variant="subtle"
>
<IconQuestionMark stroke={1.5} />
</ActionIcon>
) : (
mode === defaultMode ? null : (
<ActionIcon
onClick={() => {
setMode(defaultMode);
@@ -138,6 +139,7 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
/>
) : (
<SpotlightActionGroups
setQuery={setQuery}
setMode={(mode) => {
setMode(mode);
setChildrenOptions(null);
@@ -152,7 +154,7 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
});
}}
query={query}
groups={groups}
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
/>
)}
</MantineSpotlight.ActionsList>
@@ -11,6 +11,7 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
const searchInteractions = [
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more