Compare commits

..
Author SHA1 Message Date
Meier Lukas fe551b7cbe Merge branch 'dev' into undici-v8-issue 2026-04-10 23:33:22 +02:00
Meier Lukas 7ff38d54c8 fix: fetch failed with http/2 and undici v8 2026-04-10 23:27:21 +02:00
243 changed files with 8310 additions and 29632 deletions

No files matched your search

-191
View File
@@ -1,191 +0,0 @@
---
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`
-6
View File
@@ -45,12 +45,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
TURBO_TELEMETRY_DISABLED=1
# 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
-5
View File
@@ -33,11 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.60.1-beta.1
- 1.60.0
- 1.59.3
- 1.59.2
- 1.59.1
- 1.59.0
- 1.58.1
- 1.58.0
+5 -5
View File
@@ -25,16 +25,16 @@ body:
label: Public API available?
description: Is there a public API available, that we can consume in Homarr?
options:
- "Yes, available on a website"
- "Yes, available in the application itself"
- "No"
- Yes, available on a website
- Yes, available in the application itself
- No
validations:
required: true
- type: dropdown
attributes:
label: Are you willing to contribute this yourself?
options:
- "Yes"
- "No"
- Yes
- No
validations:
required: true
+2 -2
View File
@@ -24,7 +24,7 @@ body:
attributes:
label: Are you willing to contribute this yourself?
options:
- "Yes"
- "No"
- Yes
- No
validations:
required: true
-6
View File
@@ -11,12 +11,6 @@
matchPackageNames: ["tsx"],
enabled: false,
},
// Reenable once bug with undici v8 is fixed (https://github.com/homarr-labs/homarr/issues/5467)
{
matchPackageNames: ["undici"],
matchNewValue: "8.*",
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
+2 -7
View File
@@ -9,12 +9,7 @@ jobs:
approve-automatic-prs:
runs-on: ubuntu-latest
timeout-minutes: 2
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175486441: homarr-releases[bot]
# Extracted from https://api.github.com/users/<username>
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D and https://api.github.com/users/homarr-update-contributors%5Bbot%5D
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -23,7 +18,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve pull request
- name: Install GitHub CLI
+6 -2
View File
@@ -11,10 +11,14 @@ on:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
env:
FORCE_COLOR: 3
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
lint:
@@ -55,7 +59,7 @@ jobs:
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm typecheck
run: turbo typecheck
test:
runs-on: ubuntu-latest
@@ -22,7 +22,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
client-id: ${{ secrets.CROWDIN_APP_ID }}
app-id: ${{ secrets.CROWDIN_APP_ID }}
permission-contents: write # required to commit to crowdin branch
permission-pull-requests: write # required to create pull request
@@ -1,104 +0,0 @@
name: "[Deployment] Beta Release"
on:
workflow_dispatch:
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
permissions:
contents: write
pull-requests: write
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: next
- name: Create pull request
id: create-pull-request
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }}-beta (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
id: obtainApprovalToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
- name: Approve PR
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to beta branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge
uses: "pascalgn/automerge-action@v0.16.4"
env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to beta
MERGE_COMMIT_MESSAGE: "chore(release): release ${{ steps.semver.outputs.next }}-beta"
MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Discord Fail Notification
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The beta release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
@@ -53,19 +53,19 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev and publish the release
- uses: actions/checkout@v6
if: env.SKIP_RELEASE == 'false'
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
if: env.SKIP_RELEASE == 'false'
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.15.0
node-version: 24.14.1
cache: "pnpm"
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
@@ -191,7 +191,7 @@ jobs:
name: Extract amd64 asset from docker image
needs: [release, build-amd64]
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 2
steps:
- name: Extract amd64
if: needs.release.outputs.skipped == 'false'
@@ -207,7 +207,7 @@ jobs:
name: Extract arm64 asset from docker image
needs: [release, build-arm64]
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
timeout-minutes: 2
steps:
- name: Extract arm64
if: needs.release.outputs.skipped == 'false'
@@ -234,7 +234,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to publish the release
- name: Log in to the Container registry
@@ -274,6 +274,6 @@ jobs:
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
uses: Ilshidur/action-discord@master
with:
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
+13 -14
View File
@@ -21,10 +21,10 @@ jobs:
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
uses: Ilshidur/action-discord@master
with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
@@ -38,15 +38,14 @@ jobs:
token: ${{ github.token }}
branch: dev
- name: Create pull request
id: create-pull-request
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
uses: Ilshidur/action-discord@master
with:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
@@ -54,7 +53,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
- name: Approve PR
env:
@@ -66,7 +65,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to main branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge
@@ -75,7 +74,7 @@ jobs:
uses: "pascalgn/automerge-action@v0.16.4"
env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to main
MERGE_METHOD: merge # we prefer merge commits for merging to master
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
@@ -84,23 +83,23 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
uses: Ilshidur/action-discord@master
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
uses: Ilshidur/action-discord@master
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Discord Fail Notification
if: failure() && github.event.inputs.send-notifications
if: failure() && github.events.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
uses: Ilshidur/action-discord@master
with:
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
app-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
owner: homarr-labs
repositories: |
documentation
@@ -48,7 +48,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
@@ -33,7 +33,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
@@ -24,7 +24,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
+1 -1
View File
@@ -71,4 +71,4 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
.opencode
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.14.1
-104
View File
@@ -1,107 +1,3 @@
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
### Bug Fixes
* **downloads-widget:** increase name column default width ratio ([e9142c0](https://github.com/homarr-labs/homarr/commit/e9142c0dbcaca6cd9215b5292a2d139d96919b51))
## [1.61.0](https://github.com/homarr-labs/homarr/compare/v1.60.0...v1.61.0) (2026-05-08)
### Features
* add auto/system appearance mode + fix dark mode system resources charts ([#5492](https://github.com/homarr-labs/homarr/issues/5492)) ([831b622](https://github.com/homarr-labs/homarr/commit/831b6221fdf21fe4913b0f165ef551afeada4f33))
* add pwa support ([#5530](https://github.com/homarr-labs/homarr/issues/5530)) ([277a509](https://github.com/homarr-labs/homarr/commit/277a509f490d57bb6547c86c417c29c0e7af7e40))
* add Umami Analytics integration and widget ([#5362](https://github.com/homarr-labs/homarr/issues/5362)) ([43cc68b](https://github.com/homarr-labs/homarr/commit/43cc68bdd49c4877012e39e23355f6506aea7f4e))
* **docker:** Podman compatibility, multi-socket support, per-host error isolation ([#5425](https://github.com/homarr-labs/homarr/issues/5425)) ([56af6bc](https://github.com/homarr-labs/homarr/commit/56af6bcaf00e980e4c7a40d8e912901cd1395f5a))
* **entity-state:** add data attributes for custom-css styling ([#5655](https://github.com/homarr-labs/homarr/issues/5655)) ([0937555](https://github.com/homarr-labs/homarr/commit/0937555d49c29455bc33307ae5e50bf5b812c7c7))
* **installations:** support secrets file for all environment variables ([#5125](https://github.com/homarr-labs/homarr/issues/5125)) ([4d30a2e](https://github.com/homarr-labs/homarr/commit/4d30a2e8c48f818bb4947a651b0fec5f64cbf569))
* **qbittorrent:** add api-key support ([#5677](https://github.com/homarr-labs/homarr/issues/5677)) ([756e8d1](https://github.com/homarr-labs/homarr/commit/756e8d172455f91e40cd8fb5faff6953cc9778c5))
* **spotlight:** rework !bang search UX ([#4813](https://github.com/homarr-labs/homarr/issues/4813)) ([b5c316b](https://github.com/homarr-labs/homarr/commit/b5c316b825bdffa267fd173a3c39a12269142665))
### Bug Fixes
* **apps:** creation on the fly broken ([#5605](https://github.com/homarr-labs/homarr/issues/5605)) ([3183914](https://github.com/homarr-labs/homarr/commit/3183914a1502555681b73975062ed6ca8a48cb75))
* **calendar:** keep working integrations when one fails ([#5629](https://github.com/homarr-labs/homarr/issues/5629)) ([44bd16d](https://github.com/homarr-labs/homarr/commit/44bd16d6770d289054df6c7c93adff42d2243252))
* **lang:** portuguese is linked to brazilian portuguese on crowdin ([#5622](https://github.com/homarr-labs/homarr/issues/5622)) ([62be81e](https://github.com/homarr-labs/homarr/commit/62be81ea607a609a816157686700c54540e1568b))
* **qbittorrent:** support version 5.2 ([#5672](https://github.com/homarr-labs/homarr/issues/5672)) ([00a0552](https://github.com/homarr-labs/homarr/commit/00a055283e8848542b67d88a656a99fdd0bcb444))
* **update-checker:** hide prereleases on non-prerelease versions ([#5645](https://github.com/homarr-labs/homarr/issues/5645)) ([b07a4a0](https://github.com/homarr-labs/homarr/commit/b07a4a0f3e3af384ec477bf5c505a705b003de47))
### Reverts
* **deps:** downgrade pnpm to v10 ([#5673](https://github.com/homarr-labs/homarr/issues/5673)) ([44a2973](https://github.com/homarr-labs/homarr/commit/44a2973b1aba1e2c6bd818be022205f3cdb7c943))
## [1.60.0](https://github.com/homarr-labs/homarr/compare/v1.59.3...v1.60.0) (2026-05-01)
### Features
* **about:** add opencollective contributors ([#5481](https://github.com/homarr-labs/homarr/issues/5481)) ([3d202d4](https://github.com/homarr-labs/homarr/commit/3d202d409febf33d0218b589c979744c84f87407))
### Bug Fixes
* migrate from node-cron to croner ([#5407](https://github.com/homarr-labs/homarr/issues/5407)) ([e8edc20](https://github.com/homarr-labs/homarr/commit/e8edc203b6c862141668b043ef4232c22b8fae24))
* **openapi:** openapi document empty ([#5579](https://github.com/homarr-labs/homarr/issues/5579)) ([ce8e330](https://github.com/homarr-labs/homarr/commit/ce8e330c82bc0be9f299f3071a1e80daafc5f789))
### Performance Improvements
* **icons:** updater takes 10 seconds to run with high cpu usage ([#5608](https://github.com/homarr-labs/homarr/issues/5608)) ([ed6680b](https://github.com/homarr-labs/homarr/commit/ed6680ba6a42864185818eb8d90b2303846c1761))
## [1.60.0](https://github.com/homarr-labs/homarr/compare/v1.59.3...v1.60.0) (2026-05-01)
### Features
* **about:** add opencollective contributors ([#5481](https://github.com/homarr-labs/homarr/issues/5481)) ([3d202d4](https://github.com/homarr-labs/homarr/commit/3d202d409febf33d0218b589c979744c84f87407))
### Bug Fixes
* migrate from node-cron to croner ([#5407](https://github.com/homarr-labs/homarr/issues/5407)) ([e8edc20](https://github.com/homarr-labs/homarr/commit/e8edc203b6c862141668b043ef4232c22b8fae24))
* **openapi:** openapi document empty ([#5579](https://github.com/homarr-labs/homarr/issues/5579)) ([ce8e330](https://github.com/homarr-labs/homarr/commit/ce8e330c82bc0be9f299f3071a1e80daafc5f789))
### Performance Improvements
* **icons:** updater takes 10 seconds to run with high cpu usage ([#5608](https://github.com/homarr-labs/homarr/issues/5608)) ([ed6680b](https://github.com/homarr-labs/homarr/commit/ed6680ba6a42864185818eb8d90b2303846c1761))
## [1.59.3](https://github.com/homarr-labs/homarr/compare/v1.59.2...v1.59.3) (2026-04-24)
### Bug Fixes
* **deps:** update dependency better-sqlite3 to ^12.9.0 ([#5531](https://github.com/homarr-labs/homarr/issues/5531)) ([7852f1d](https://github.com/homarr-labs/homarr/commit/7852f1d987d40707ae13494f9c934a563581f97f))
## [1.59.3](https://github.com/homarr-labs/homarr/compare/v1.59.2...v1.59.3) (2026-04-24)
### Bug Fixes
* **deps:** update dependency better-sqlite3 to ^12.9.0 ([#5531](https://github.com/homarr-labs/homarr/issues/5531)) ([7852f1d](https://github.com/homarr-labs/homarr/commit/7852f1d987d40707ae13494f9c934a563581f97f))
## [1.59.2](https://github.com/homarr-labs/homarr/compare/v1.59.1...v1.59.2) (2026-04-17)
### Bug Fixes
* **about:** add missing dependencies ([#5480](https://github.com/homarr-labs/homarr/issues/5480)) ([c48588c](https://github.com/homarr-labs/homarr/commit/c48588c6a748920b05f8afcd79fad17dc65f2930))
* **deps:** update dependency @drizzle-team/brocli to ^0.12.0 ([#5489](https://github.com/homarr-labs/homarr/issues/5489)) ([74611b7](https://github.com/homarr-labs/homarr/commit/74611b75a736df43a77ebc780d2e0eee5c40e3f3))
* **deps:** update dependency @immich/sdk to ^2.7.0 ([#5490](https://github.com/homarr-labs/homarr/issues/5490)) ([9cdf2aa](https://github.com/homarr-labs/homarr/commit/9cdf2aa1f2b1477522879095b603b100c1dd3ac0))
* **deps:** update dependency @immich/sdk to ^2.7.2 ([#5494](https://github.com/homarr-labs/homarr/issues/5494)) ([0927f45](https://github.com/homarr-labs/homarr/commit/0927f45b2573753152fd5e9e318ba62b88abca35))
* **deps:** update dependency fast-xml-parser to ^5.5.11 ([#5500](https://github.com/homarr-labs/homarr/issues/5500)) ([652ed1a](https://github.com/homarr-labs/homarr/commit/652ed1a5ed0beeb2dbd44976890f24dc12b90cf6))
* **deps:** update dependency isomorphic-dompurify to ^3.8.0 ([#5503](https://github.com/homarr-labs/homarr/issues/5503)) ([0731b7c](https://github.com/homarr-labs/homarr/commit/0731b7c5ef272bcf69c566cdec723f2696309e67))
* **deps:** update dependency jotai to ^2.19.1 ([#5487](https://github.com/homarr-labs/homarr/issues/5487)) ([efd8a55](https://github.com/homarr-labs/homarr/commit/efd8a55b6ea113bc42bc90d9ccb0db6c1d2b68ef))
* **deps:** update dependency next-intl to v4.9.1 [security] ([#5470](https://github.com/homarr-labs/homarr/issues/5470)) ([f2437c4](https://github.com/homarr-labs/homarr/commit/f2437c473106689e9fa93e7dcbea84b00800ea4a))
* **deps:** update dependency swagger-ui-react to ^5.32.2 ([#5488](https://github.com/homarr-labs/homarr/issues/5488)) ([cf131c5](https://github.com/homarr-labs/homarr/commit/cf131c5c51f6c21f942ab89d02353f93dc05774c))
* **deps:** update dependency typescript-eslint to ^8.58.1 ([#5496](https://github.com/homarr-labs/homarr/issues/5496)) ([f88984c](https://github.com/homarr-labs/homarr/commit/f88984cda844beb1099a08c9cdeb6691ad13b5a8))
* **deps:** update react monorepo to v19.2.5 ([#5502](https://github.com/homarr-labs/homarr/issues/5502)) ([c8a61a9](https://github.com/homarr-labs/homarr/commit/c8a61a9df3087de28c76f573623725eec246dd32))
* **deps:** update testcontainers-node monorepo to ^11.14.0 ([#5498](https://github.com/homarr-labs/homarr/issues/5498)) ([1057b47](https://github.com/homarr-labs/homarr/commit/1057b478c37d02e9de7bcd155f0b3fb764149abe))
* **deps:** update tiptap monorepo to v3.22.3 ([#5499](https://github.com/homarr-labs/homarr/issues/5499)) ([95abd2c](https://github.com/homarr-labs/homarr/commit/95abd2c9d3a563c7eaebe12f3b8015e3299e27e8))
* **deps:** update turbo monorepo to ^2.9.4 ([#5476](https://github.com/homarr-labs/homarr/issues/5476)) ([813f2fa](https://github.com/homarr-labs/homarr/commit/813f2fa5efb999d3364882578fdf456537dba3a6))
* **deps:** update turbo monorepo to ^2.9.5 ([#5491](https://github.com/homarr-labs/homarr/issues/5491)) ([ed24ce6](https://github.com/homarr-labs/homarr/commit/ed24ce6381466fa530ce225363231303be4ed92e))
* preserve all-day event dates in Nextcloud calendar integration ([#5523](https://github.com/homarr-labs/homarr/issues/5523)) ([2e78979](https://github.com/homarr-labs/homarr/commit/2e789795ed11c816550b6a3ce88fe92a360ede9c))
## [1.59.1](https://github.com/homarr-labs/homarr/compare/v1.59.0...v1.59.1) (2026-04-10)
### Bug Fixes
* **qbittorrent:** requests fail ([#5473](https://github.com/homarr-labs/homarr/issues/5473)) ([db6bb87](https://github.com/homarr-labs/homarr/commit/db6bb870e3be14c7895a2882755d3aee09e44fa6))
### Reverts
* **deps:** downgrade undici from v8 to v7 ([#5473](https://github.com/homarr-labs/homarr/issues/5473)) ([dc44fac](https://github.com/homarr-labs/homarr/commit/dc44fac42aab5679427b2921402dfa11e634e1cf))
## [1.59.0](https://github.com/homarr-labs/homarr/compare/v1.58.1...v1.59.0) (2026-04-10)
### Features
-4
View File
@@ -3,11 +3,7 @@
# 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
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.15.0-alpine AS base
FROM node:24.14.1-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -30,7 +30,7 @@ VOLUME /appdata
# Enable homarr cli
COPY --from=builder /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
RUN echo $'#!/bin/bash\nexport DISABLE_REDIS_LOGS=true\ncd /app/apps/cli && exec node ./cli.cjs "$@"' > /usr/bin/homarr
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr
# Don't run production as root
-17
View File
@@ -10,21 +10,4 @@ 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",
},
},
];
-9
View File
@@ -42,18 +42,9 @@ 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: [
+56 -57
View File
@@ -15,10 +15,10 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@dnd-kit/core": "catalog:",
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
@@ -31,7 +31,7 @@
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "catalog:",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
@@ -48,62 +48,61 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dropzone": "catalog:",
"@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:",
"@tanstack/react-query-next-experimental": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"chroma-js": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"isomorphic-dompurify": "catalog:",
"jotai": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"prismjs": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"sass": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
"@mantine/colors-generator": "^8.3.18",
"@mantine/core": "^8.3.18",
"@mantine/dropzone": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/tiptap": "^8.3.18",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.41.1",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-query-next-experimental": "^5.96.2",
"@trpc/client": "^11.16.0",
"@trpc/next": "^11.16.0",
"@trpc/react-query": "^11.16.0",
"@trpc/server": "^11.16.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "^6.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"chroma-js": "^3.2.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"dotenv": "^17.4.1",
"flag-icons": "^7.5.0",
"glob": "^13.0.6",
"isomorphic-dompurify": "^3.7.1",
"jotai": "^2.19.0",
"mantine-react-table": "2.0.0-beta.9",
"next": "16.2.3",
"postcss-preset-mantine": "^1.18.0",
"prismjs": "^1.30.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-error-boundary": "^6.1.1",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.99.0",
"superjson": "2.2.6",
"swagger-ui-react": "^5.32.1",
"use-deep-compare-effect": "^1.8.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/swagger-ui-react": "catalog:",
"concurrently": "catalog:",
"eslint": "catalog:",
"node-loader": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
"@types/chroma-js": "3.1.2",
"@types/node": "^24.12.2",
"@types/prismjs": "^1.26.6",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.2.1",
"eslint": "^9.39.4",
"node-loader": "^2.1.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
}
}
-7
View File
@@ -1,7 +0,0 @@
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
@@ -8,12 +8,13 @@ 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: MantineColorScheme }>) => {
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
@@ -34,7 +35,7 @@ export const CustomMantineProvider = ({
export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession();
const updateCookieValue = (value: MantineColorScheme) => {
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
@@ -59,6 +60,7 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });
+1 -4
View File
@@ -4,7 +4,6 @@ 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";
@@ -26,7 +25,6 @@ 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";
@@ -131,7 +129,7 @@ export default async function Layout(props: {
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
}}
suppressHydrationWarning
>
@@ -143,7 +141,6 @@ export default async function Layout(props: {
<body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider>
<Notifications />
<ServiceWorkerRegistration />
{props.children}
</StackedProvider>
</body>
@@ -1,7 +1,3 @@
.contributorCard {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.dependencyCard:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
@@ -12,7 +12,8 @@ import {
Flex,
Group,
Kbd,
SimpleGrid,
List,
ListItem,
Stack,
Table,
TableTbody,
@@ -21,18 +22,9 @@ import {
TableThead,
TableTr,
Text,
ThemeIcon,
Title,
UnstyledButton,
} from "@mantine/core";
import {
IconKeyboard,
IconLanguage,
IconLibrary,
IconMoneybagHeart,
IconPackage,
IconUsers,
} from "@tabler/icons-react";
import { IconKeyboard, IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
@@ -41,10 +33,8 @@ import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import type { PackageJsonDependencies } from "~/versions/package-reader";
import { getPackageVersion } from "~/versions/package-reader";
import { getPackageAttributesAsync } from "~/versions/package-reader";
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
import classes from "./about.module.css";
@@ -67,10 +57,7 @@ const getHostAsync = async () => {
export default async function AboutPage() {
const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
res.json(),
)) as PackageJsonDependencies;
const attributes = await getPackageAttributesAsync();
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
res.json(),
)) as typeof githubContributorsJson;
@@ -79,10 +66,6 @@ export default async function AboutPage() {
res.json(),
)) as typeof crowdinContributorsJson;
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
(res) => res.json(),
)) as typeof openCollectiveContributorsJson;
return (
<div>
<DynamicBreadcrumb />
@@ -93,7 +76,7 @@ export default async function AboutPage() {
<Title order={1} tt="uppercase">
Homarr
</Title>
<Title order={2}>{t("version", { version })}</Title>
<Title order={2}>{t("version", { version: attributes.version })}</Title>
</Stack>
</Group>
</Center>
@@ -148,72 +131,31 @@ export default async function AboutPage() {
</Flex>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="sponsors">
<AccordionControl icon={<IconMoneybagHeart size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.sponsors.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.sponsors.subtitle", {
count: String(openCollectiveContributors.length),
})}
</Text>
</Stack>
</AccordionControl>
<AccordionPanel>
<Flex wrap="wrap" gap="xs">
{openCollectiveContributors.map((sponsor) => (
<GenericContributorLinkCard
key={sponsor.slug}
link={`https://opencollective.com/${sponsor.slug}`}
image={sponsor.imageUrl}
name={sponsor.name}
/>
))}
</Flex>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="libraries">
<AccordionControl icon={<IconLibrary size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.libraries.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.libraries.subtitle", {
count: String(Object.keys(dependencies).length),
count: String(Object.keys(attributes.dependencies).length),
})}
</Text>
</Stack>
</AccordionControl>
<AccordionPanel>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
<List>
{Object.entries(attributes.dependencies)
.sort(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
component="a"
href={`https://www.npmjs.com/package/${name}`}
target="_blank"
>
<Card withBorder radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg" radius="md">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
.map(([key, value]) => (
<ListItem key={key}>
{value.includes("workspace:") ? (
<Text>{key}</Text>
) : (
<a href={`https://www.npmjs.com/package/${key}`}>{key}</a>
)}
</ListItem>
))}
</SimpleGrid>
</List>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="hotkeys">
@@ -1,7 +1,7 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
@@ -34,7 +34,6 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
};
const appearanceIcons = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { openApiDocument } from "@homarr/api/open-api";
import { openApiDocument } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
@@ -58,18 +58,6 @@ 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,7 +26,6 @@ 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"),
@@ -42,7 +41,6 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
},
});
@@ -66,10 +64,6 @@ 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}>
@@ -30,7 +30,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
},
onSuccess(_, variables) {
form.setInitialValues({
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
firstDayOfWeek: variables.firstDayOfWeek,
});
showSuccessNotification({
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
+2 -3
View File
@@ -2,8 +2,7 @@ import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { createOpenApiFetchHandler } from "trpc-to-openapi";
import { createTRPCContext } from "@homarr/api";
import { openApiRouter } from "@homarr/api/open-api";
import { appRouter, createTRPCContext } from "@homarr/api";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -31,7 +30,7 @@ const handlerAsync = async (req: NextRequest) => {
return createOpenApiFetchHandler({
req,
endpoint: "/",
router: openApiRouter,
router: appRouter,
createContext: () => createTRPCContext({ session, headers: req.headers }),
onError({ error, path, type }) {
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
export const GET = () => {
return NextResponse.json(openCollectiveContributors);
};
export const dynamic = "force-static";
@@ -1,10 +0,0 @@
import { NextResponse } from "next/server";
import { getDependenciesAsync } from "../../../../versions/package-reader";
export const GET = async () => {
const dependencies = await getDependenciesAsync();
return NextResponse.json(dependencies);
};
export const dynamic = "force-static";
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { openApiDocument } from "@homarr/api/open-api";
import { openApiDocument } from "@homarr/api";
import { extractBaseUrlFromHeaders } from "@homarr/common";
export function GET(request: Request) {
@@ -1,94 +0,0 @@
"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,17 +1,49 @@
"use client";
import { useMantineColorScheme } from "@mantine/core";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { ColorSchemeCombobox } from "./color-scheme-combobox";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
interface CurrentColorSchemeComboboxProps {
w?: string;
}
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme();
return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
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>
);
};
@@ -1,14 +0,0 @@
"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,7 +5,16 @@ 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, IconSettings, IconTool } from "@tabler/icons-react";
import {
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconMoon,
IconSettings,
IconSun,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -15,7 +24,6 @@ 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";
@@ -26,9 +34,13 @@ interface UserAvatarMenuProps {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
const { colorScheme, 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();
@@ -55,14 +67,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
@@ -1,12 +0,0 @@
// 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;
}
}
+10 -30
View File
@@ -1,16 +1,12 @@
import fsPromises from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { glob } from "glob";
import { parse as parseYaml } from "yaml";
import z from "zod";
import packageJson from "../../../../package.json";
export const getPackageVersion = () => packageJson.version;
export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> => {
const getPackageVersion = () => packageJson.version;
const getDependenciesAsync = async (): Promise<PackageJsonDependencies> => {
const pathNames = await glob("**/package.json", {
ignore: "**/node_modules/**",
ignore: "node_modules/**",
cwd: "../../",
absolute: true,
});
@@ -19,37 +15,21 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
.map((packageContent) => (JSON.parse(packageContent) as PackageJson).dependencies)
.filter((dependencies) => dependencies !== undefined);
const catalog = await parseDependencyCatalogsAsync();
let dependencies = {};
for (const dependenciesOfPackage of packageDependencies) {
const resolvedDependencies = Object.entries(dependenciesOfPackage).map(([name, version]) => {
const catalogVersion = catalog.get(name);
return [name, catalogVersion ?? version] as const;
});
dependencies = { ...dependencies, ...Object.fromEntries(resolvedDependencies) };
dependencies = { ...dependencies, ...dependenciesOfPackage };
}
return dependencies;
};
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigContent = await fsPromises.readFile(
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
"utf-8",
);
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
export const getPackageAttributesAsync = async () => {
return {
version: getPackageVersion(),
dependencies: await getDependenciesAsync(),
};
};
const workspaceConfigCatalogSchema = z.object({
catalog: z.record(z.string(), z.string()).optional(),
});
export type PackageJsonDependencies = Record<string, string>;
type PackageJsonDependencies = Record<string, string>;
interface PackageJson {
dependencies: PackageJsonDependencies | undefined;
}
+12 -13
View File
@@ -37,23 +37,22 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"croner": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"fastify": "catalog:",
"superjson": "catalog:",
"undici": "catalog:"
"dayjs": "^1.11.20",
"dotenv": "^17.4.1",
"fastify": "^5.8.4",
"superjson": "2.2.6",
"undici": "8.0.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"dotenv-cli": "catalog:",
"esbuild": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
"@types/node": "^24.12.2",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"tsx": "4.20.4",
"typescript": "^5.9.3"
}
}
+15 -24
View File
@@ -1,4 +1,4 @@
import { Cron } from "croner";
import { schedule, validate as validateCron } from "node-cron";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IJobManager } from "@homarr/cron-job-api";
@@ -21,31 +21,28 @@ export class JobManager implements IJobManager {
public async triggerAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.runManuallyAsync(name);
}
public stop(name: JobGroupKeys): void {
this.jobGroup.stop(name);
public async stopAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.stopAsync(name);
}
public async updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void> {
logger.info("Updating cron job interval", { name, expression: cron });
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
if (!validateCron(cron)) {
throw new Error(`Invalid cron expression: ${cron}`);
}
if (job.preventCustomInterval && cron !== job.cronExpression) {
throw new Error(`Custom cron expressions are not allowed for job ${name}`);
}
const updatedConfig = await this.updateConfigurationAsync(name, { cronExpression: cron });
this.jobGroup.getTask(name)?.stop();
await this.updateConfigurationAsync(name, { cronExpression: cron });
await this.jobGroup.getTask(name)?.destroy();
this.jobGroup.setTask(
name,
new Cron(
cron,
{
name,
timezone: job.timezone,
paused: !updatedConfig.isEnabled,
},
() => void job.executeAsync(),
),
schedule(cron, () => void job.executeAsync(), {
name,
}),
);
logger.info("Cron job interval updated", { name, expression: cron });
}
@@ -55,7 +52,7 @@ export class JobManager implements IJobManager {
if (!job) throw new Error(`Job ${name} not found`);
await this.updateConfigurationAsync(name, { isEnabled: false });
this.jobGroup.stop(name);
await this.jobGroup.stopAsync(name);
logger.info("Cron job disabled", { name });
}
public async enableAsync(name: JobGroupKeys): Promise<void> {
@@ -89,27 +86,21 @@ export class JobManager implements IJobManager {
name,
configuration: JSON.stringify(configuration),
});
return {
name,
cronExpression: configuration.cronExpression ?? existingConfig.cronExpression,
isEnabled: configuration.isEnabled ?? existingConfig.isEnabled,
};
return;
}
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
const updatedConfig = {
await this.db.insert(cronJobConfigurations).values({
name,
cronExpression: configuration.cronExpression ?? job.cronExpression,
isEnabled: configuration.isEnabled ?? true,
};
await this.db.insert(cronJobConfigurations).values(updatedConfig);
});
logger.debug("Cron job configuration updated", {
name,
configuration: JSON.stringify(configuration),
});
return updatedConfig;
}
public async getAllAsync(): Promise<
+8 -8
View File
@@ -25,18 +25,18 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "catalog:",
"tsx": "catalog:",
"ws": "catalog:"
"dotenv": "^17.4.1",
"tsx": "4.20.4",
"ws": "^8.20.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "catalog:",
"esbuild": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
"@types/ws": "^8.18.1",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.15.0-trixie AS base
FROM node:24.14.1-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+4 -4
View File
@@ -2,12 +2,12 @@
"name": "homarr-prebuilt-debian",
"private": true,
"dependencies": {
"better-sqlite3": "^12.9.0"
"better-sqlite3": "^12.8.0"
},
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=24.15.0",
"pnpm": ">=10.33.2"
"node": ">=24.14.1",
"pnpm": ">=10.33.0"
},
"pnpm": {
"onlyBuiltDependencies": [
+5 -5
View File
@@ -9,16 +9,16 @@ importers:
.:
dependencies:
better-sqlite3:
specifier: ^12.9.0
version: 12.9.0
specifier: ^12.8.0
version: 12.8.0
packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.9.0:
resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==}
better-sqlite3@12.8.0:
resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
bindings@1.5.0:
@@ -148,7 +148,7 @@ snapshots:
base64-js@1.5.1: {}
better-sqlite3@12.9.0:
better-sqlite3@12.8.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
-7
View File
@@ -372,13 +372,6 @@
</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/>
+33 -33
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.61.1",
"version": "1.59.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -36,33 +36,33 @@
"prettier": "@homarr/prettier-config",
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@semantic-release/changelog": "catalog:",
"@semantic-release/commit-analyzer": "catalog:",
"@semantic-release/git": "catalog:",
"@semantic-release/github": "catalog:",
"@semantic-release/npm": "catalog:",
"@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:",
"jsdom": "catalog:",
"json5": "catalog:",
"prettier": "catalog:",
"semantic-release": "catalog:",
"testcontainers": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.0",
"@testcontainers/redis": "^11.13.0",
"@turbo/gen": "^2.9.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.2",
"@vitest/ui": "^4.1.2",
"conventional-changelog-conventionalcommits": "^9.3.1",
"cross-env": "^10.1.0",
"jsdom": "^29.0.1",
"json5": "^2.2.3",
"prettier": "^3.8.1",
"semantic-release": "^25.0.3",
"testcontainers": "^11.13.0",
"turbo": "^2.9.3",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.2"
},
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=24.15.0",
"pnpm": ">=10.33.2"
"node": ">=24.14.1",
"pnpm": ">=10.33.0"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -79,23 +79,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.16.0",
"axios@>=1.0.0 <1.8.2": ">=1.15.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.5",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.5",
"esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.16",
"hono@<4.6.5": ">=4.12.10",
"linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.7",
"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",
"proxmox-api>undici": "8.0.2",
"react-is": "^19.2.4",
"rollup@>=4.0.0 <4.22.4": ">=4.60.1",
"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.3"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
+4 -4
View File
@@ -28,14 +28,14 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@umami/node": "catalog:",
"superjson": "catalog:"
"@umami/node": "^0.4.0",
"superjson": "2.2.6"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
"typescript": "catalog:"
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
+17 -18
View File
@@ -9,8 +9,7 @@
"./client": "./src/client.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts"
"./shared": "./src/shared.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -41,26 +40,26 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"superjson": "catalog:",
"trpc-to-openapi": "catalog:",
"zod": "catalog:"
"@kubernetes/client-node": "^1.4.0",
"@tanstack/react-query": "^5.96.2",
"@trpc/client": "^11.16.0",
"@trpc/react-query": "^11.16.0",
"@trpc/server": "^11.16.0",
"@trpc/tanstack-react-query": "^11.16.0",
"lodash.clonedeep": "^4.5.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"superjson": "2.2.6",
"trpc-to-openapi": "^3.2.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { openApiDocument } from "./open-api";
import type { AppRouter } from "./root";
import { appRouter } from "./root";
import { createCallerFactory, createTRPCContext } from "./trpc";
@@ -29,5 +30,5 @@ type RouterInputs = inferRouterInputs<AppRouter>;
**/
type RouterOutputs = inferRouterOutputs<AppRouter>;
export { createTRPCContext, appRouter, createCaller };
export { createTRPCContext, appRouter, createCaller, openApiDocument };
export type { AppRouter, RouterInputs, RouterOutputs };
+2 -13
View File
@@ -2,21 +2,10 @@ import { generateOpenApiDocument } from "trpc-to-openapi";
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./router/app";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
export const openApiRouter = createTRPCRouter({
appRouter,
infoRouter,
inviteRouter,
userRouter,
});
import { appRouter } from "./root";
export const openApiDocument = (base: string) =>
generateOpenApiDocument(openApiRouter, {
generateOpenApiDocument(appRouter, {
title: "Homarr API documentation",
version: "1.0.0",
baseUrl: base,
-1
View File
@@ -26,7 +26,6 @@ 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)),
});
@@ -1,17 +0,0 @@
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 });
}),
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import z from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { cronJobApi } from "@homarr/cron-job-api/client";
import type { TaskStatus } from "@homarr/cron-job-status";
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
@@ -35,7 +35,7 @@ export const cronJobsRouter = createTRPCRouter({
.input(
z.object({
name: jobNameSchema,
cron: z.string(),
cron: cronExpressionSchema,
}),
)
.mutation(async ({ input }) => {
@@ -138,7 +138,6 @@ const getContainerOrThrowAsync = async (id: string) => {
interface DockerContainer {
name: string;
id: string;
host: string;
state: ContainerState;
image: string;
ports: Port[] | undefined;
+1 -31
View File
@@ -21,7 +21,6 @@ import {
userChangePasswordApiSchema,
userChangeSearchPreferencesSchema,
userCreateSchema,
userDdgBangsSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userInitSchema,
@@ -111,7 +110,7 @@ export const userRouter = createTRPCRouter({
const queryResult = (await trx.delete(schema.invites).where(inviteWhere)) as
| QueryResult
| MySqlRawQueryResult;
let count: number;
let count = 0;
if (Array.isArray(queryResult)) {
count = queryResult[0].affectedRows;
} else {
@@ -277,7 +276,6 @@ export const userRouter = createTRPCRouter({
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -303,7 +301,6 @@ export const userRouter = createTRPCRouter({
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
},
where: eq(users.id, input.userId),
});
@@ -544,33 +541,6 @@ 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,13 +15,10 @@ export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSch
export const changeSearchPreferencesAsync = async (
db: Database,
session: Session,
input: Modify<
z.infer<typeof changeSearchPreferencesInputSchema>,
{ openInNewTab: boolean | undefined; ddgBangsEnabled: boolean | undefined }
>,
input: Modify<z.infer<typeof changeSearchPreferencesInputSchema>, { openInNewTab: boolean | undefined }>,
) => {
const user = session.user;
// Only admins can change other users search preferences
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
@@ -48,7 +45,6 @@ export const changeSearchPreferencesAsync = async (
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
ddgBangs: input.ddgBangsEnabled,
})
.where(eq(users.id, input.userId));
};
+1 -23
View File
@@ -2,8 +2,6 @@ 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";
@@ -14,8 +12,6 @@ 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(
@@ -28,7 +24,7 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
const settled = await Promise.allSettled(
return await Promise.all(
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
@@ -44,24 +40,6 @@ 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,5 +30,4 @@ 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
@@ -1,205 +0,0 @@
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();
};
});
}),
});
@@ -1,55 +0,0 @@
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: "auto",
colorScheme: "dark",
},
} as Session;
};
+15 -15
View File
@@ -24,30 +24,30 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "catalog:",
"@auth/drizzle-adapter": "catalog:",
"@auth/core": "^0.41.1",
"@auth/drizzle-adapter": "^1.11.1",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "catalog:",
"cookies": "catalog:",
"ldapts": "catalog:",
"next": "catalog:",
"next-auth": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"zod": "catalog:"
"bcrypt": "^6.0.0",
"cookies": "^0.9.1",
"ldapts": "8.1.7",
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "catalog:",
"@types/cookies": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
"@types/bcrypt": "6.0.0",
"@types/cookies": "0.9.2",
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
}
}
+4 -4
View File
@@ -25,14 +25,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"react": "catalog:",
"react-dom": "catalog:"
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
"typescript": "catalog:"
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
+6 -10
View File
@@ -15,7 +15,7 @@
}
},
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --external:better-sqlite3 --external:mysql2 --external:pg --loader:.html=text --loader:.node=text",
"build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
@@ -25,23 +25,19 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@drizzle-team/brocli": "^0.11.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"better-sqlite3": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"mysql2": "catalog:",
"pg": "catalog:"
"dotenv": "^17.4.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
-127
View File
@@ -1,127 +0,0 @@
import Database from "better-sqlite3";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import { drizzle as drizzleMysql } from "drizzle-orm/mysql2";
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
import mysql from "mysql2";
import type { Pool as MysqlPool } from "mysql2";
import { Pool as PostgresPool } from "pg";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { schema } from "@homarr/db/schema";
const CONNECT_TIMEOUT_MS = 10_000;
const dbConfig = {
schema,
casing: DB_CASING,
};
export type CliDatabase = ReturnType<typeof drizzleSqlite<typeof schema>>;
type CliConnection =
| { driver: "better-sqlite3"; raw: Database.Database; db: CliDatabase }
| { driver: "mysql2"; raw: MysqlPool; db: CliDatabase }
| { driver: "node-postgres"; raw: PostgresPool; db: CliDatabase };
let connection: CliConnection | null = null;
const createMysqlPool = () => {
const poolOptions = {
maxIdle: 0,
idleTimeout: 60000,
enableKeepAlive: true,
connectTimeout: CONNECT_TIMEOUT_MS,
};
if (!dbEnv.HOST) {
return mysql.createPool({ ...poolOptions, uri: dbEnv.URL });
}
return mysql.createPool({
...poolOptions,
host: dbEnv.HOST,
port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER,
password: dbEnv.PASSWORD,
});
};
const createPostgresPool = () => {
const poolOptions = {
max: 1,
idleTimeoutMillis: 60000,
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
allowExitOnIdle: true,
};
if (!dbEnv.HOST) {
return new PostgresPool({
...poolOptions,
connectionString: dbEnv.URL,
});
}
return new PostgresPool({
...poolOptions,
host: dbEnv.HOST,
port: dbEnv.PORT,
database: dbEnv.NAME,
user: dbEnv.USER,
password: dbEnv.PASSWORD,
});
};
const connectionFactories = {
"better-sqlite3": (): CliConnection => {
const raw = new Database(dbEnv.URL);
const db = drizzleSqlite(raw, dbConfig);
return { driver: "better-sqlite3", raw, db };
},
mysql2: (): CliConnection => {
const raw = createMysqlPool();
const db = drizzleMysql(raw, { ...dbConfig, mode: "default" }) as unknown as CliDatabase;
return { driver: "mysql2", raw, db };
},
"node-postgres": (): CliConnection => {
const raw = createPostgresPool();
const db = drizzlePostgres({ ...dbConfig, client: raw }) as unknown as CliDatabase;
return { driver: "node-postgres", raw, db };
},
};
const closeHandlers: Record<CliConnection["driver"], (entry: CliConnection) => Promise<void>> = {
"better-sqlite3": async (entry) => {
(entry as Extract<CliConnection, { driver: "better-sqlite3" }>).raw.close();
},
mysql2: (entry) => {
const pool = (entry as Extract<CliConnection, { driver: "mysql2" }>).raw;
return new Promise<void>((resolve, reject) => {
pool.end((err) => (err ? reject(err) : resolve()));
});
},
"node-postgres": async (entry) => {
await (entry as Extract<CliConnection, { driver: "node-postgres" }>).raw.end();
},
};
export const getCliDb = (): CliDatabase => {
if (connection) {
return connection.db;
}
const driver = dbEnv.DRIVER ?? "better-sqlite3";
connection = connectionFactories[driver]();
return connection.db;
};
export const closeCliDbAsync = async (): Promise<void> => {
if (!connection) {
return;
}
const current = connection;
connection = null;
await closeHandlers[current.driver](current);
};
+29 -28
View File
@@ -1,35 +1,36 @@
import { eq } from "drizzle-orm";
import { command } from "@drizzle-team/brocli";
import { getCliDb } from "../cli-db";
import { requireCredentialsProvider } from "../errors";
import { db, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
export async function fixUsernamesHandler(): Promise<number> {
requireCredentialsProvider();
const db = getCliDb();
const credentialUsers = await db.query.users.findMany({
where: eq(users.provider, "credentials"),
});
for (const user of credentialUsers) {
if (!user.name) {
continue;
}
if (user.name === user.name.toLowerCase()) {
continue;
export const fixUsernames = command({
name: "fix-usernames",
desc: "Changes all credentials usernames to lowercase",
// eslint-disable-next-line no-restricted-syntax
handler: async () => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
await db
.update(users)
.set({
name: user.name.toLowerCase(),
})
.where(eq(users.id, user.id));
const credentialUsers = await db.query.users.findMany({
where: eq(users.provider, "credentials"),
});
console.log(`Changed username from ${user.name} to ${user.name.toLowerCase()}`);
}
for (const user of credentialUsers) {
if (!user.name) continue;
if (user.name === user.name.toLowerCase()) continue;
console.log("All usernames have been fixed");
return 0;
}
await db
.update(users)
.set({
name: user.name.toLowerCase(),
})
.where(eq(users.id, user.id));
console.log(`Changed username from ${user.name} to ${user.name.toLowerCase()}`);
}
console.log("All usernames have been fixed");
},
});
@@ -1,50 +1,39 @@
import { parseArgs } from "node:util";
import { command, string } from "@drizzle-team/brocli";
import { eq } from "drizzle-orm";
import type { CliDatabase } from "../cli-db";
import { getCliDb } from "../cli-db";
import { CliError } from "../errors";
import { db, eq } from "@homarr/db";
import { integrations } from "@homarr/db/schema";
const integrationLookup: Record<
"id" | "name",
(db: CliDatabase, value: string) => Promise<{ id: string; name: string } | undefined>
> = {
id: (db, value) =>
db.query.integrations.findFirst({
where: eq(integrations.id, value),
}),
name: (db, value) =>
db.query.integrations.findFirst({
where: eq(integrations.name, value),
}),
};
export const integrationsDelete = command({
name: "delete",
desc: "Delete an integration by id or name",
options: {
id: string("id").alias("i").desc("ID of the integration"),
name: string("name").alias("n").desc("Name of the integration"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (options) => {
if (!options.id && !options.name) {
console.error("Either --id or --name must be provided");
return;
}
export async function integrationsDeleteHandler(args: string[]): Promise<number> {
const { values } = parseArgs({
args,
options: {
id: { type: "string", short: "i" },
name: { type: "string", short: "n" },
},
});
let entry;
if (options.id) {
entry = await db.query.integrations.findFirst({
where: eq(integrations.id, options.id),
});
} else if (options.name) {
entry = await db.query.integrations.findFirst({
where: eq(integrations.name, options.name),
});
}
const lookupKey = values.id ? "id" : values.name ? "name" : null;
const lookupValue = values.id ?? values.name;
if (!entry) {
console.error("Integration not found");
return;
}
if (!lookupKey || !lookupValue) {
throw new CliError("Either --id or --name must be provided", 2);
}
const db = getCliDb();
const entry = await integrationLookup[lookupKey](db, lookupValue);
if (!entry) {
throw new CliError("Integration not found");
}
await db.delete(integrations).where(eq(integrations.id, entry.id));
console.log(`Integration ${entry.name} (${entry.id}) deleted`);
return 0;
}
await db.delete(integrations).where(eq(integrations.id, entry.id));
console.log(`Integration ${entry.name} (${entry.id}) deleted`);
},
});
+18 -32
View File
@@ -1,35 +1,21 @@
import { parseArgs } from "node:util";
import { command } from "@drizzle-team/brocli";
import { getCliDb } from "../cli-db";
import { printTable } from "../utils";
import { db } from "@homarr/db";
export async function integrationsListHandler(args: string[] = []): Promise<number> {
const { values } = parseArgs({
args,
options: {
json: { type: "boolean", default: false },
},
strict: false,
});
export const integrationsList = command({
name: "list",
desc: "List all integrations (id and name)",
// eslint-disable-next-line no-restricted-syntax
handler: async () => {
const all = await db.query.integrations.findMany();
if (all.length === 0) {
console.log("No integrations found");
return;
}
const db = getCliDb();
const all = await db.query.integrations.findMany();
if (values.json) {
const output = all.map((i) => ({ id: i.id, name: i.name, kind: i.kind, url: i.url }));
console.log(JSON.stringify(output, null, 2));
return 0;
}
if (all.length === 0) {
console.log("No integrations found");
return 0;
}
printTable(
["ID", "Name", "Kind", "URL"],
all.map((i) => [i.id, i.name, i.kind, i.url]),
);
console.log(`\n${all.length} integration(s) total`);
return 0;
}
console.log("ID\t\t\t\tName");
for (const i of all) {
console.log(`${i.id}\t${i.name}`);
}
},
});
+10
View File
@@ -0,0 +1,10 @@
import { command } from "@drizzle-team/brocli";
import { integrationsDelete } from "./integrations-delete";
import { integrationsList } from "./integrations-list";
export const integrationsRoot = command({
name: "integrations",
desc: "Group of commands to manage integrations",
subcommands: [integrationsList, integrationsDelete],
});
+76 -80
View File
@@ -1,98 +1,94 @@
import { parseArgs } from "node:util";
import { command, string } from "@drizzle-team/brocli";
import { hashPasswordAsync } from "@homarr/auth/security";
import { hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { generateSecureRandomToken } from "../utils";
import { and, count, eq, max } from "drizzle-orm";
import { getCliDb } from "../cli-db";
import { CliError, requireCredentialsProvider } from "../errors";
import { generateSecureRandomToken } from "@homarr/common/server";
import { and, count, db, eq } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { usernameSchema } from "@homarr/validation/user";
export async function recreateAdminHandler(args: string[]): Promise<number> {
requireCredentialsProvider();
export const recreateAdmin = command({
name: "recreate-admin",
desc: "Recreate credentials admin user if none exists anymore",
options: {
username: string("username").required().alias("u").desc("Name of the admin"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (options) => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
const { values } = parseArgs({
args,
options: {
username: { type: "string", short: "u" },
},
});
const result = await usernameSchema.safeParseAsync(options.username);
if (!values.username) {
throw new CliError("Missing required option: --username / -u", 2);
}
if (!result.success) {
console.error("Invalid username:");
console.error(result.error.issues.map((error) => `- ${error.message}`).join("\n"));
return;
}
const result = await usernameSchema.safeParseAsync(values.username);
if (!result.success) {
const messages = result.error.issues.map((issue) => `- ${issue.message}`).join("\n");
throw new CliError(`Invalid username:\n${messages}`, 2);
}
const totalCount = await db
.select({
count: count(),
})
.from(groupPermissions)
.leftJoin(groupMembers, eq(groupMembers.groupId, groupPermissions.groupId))
.leftJoin(users, eq(users.id, groupMembers.userId))
.where(and(eq(groupPermissions.permission, "admin"), eq(users.provider, "credentials")))
.then((rows) => rows.at(0)?.count ?? 0);
const db = getCliDb();
if (totalCount > 0) {
console.error("Credentials admin user exists");
return;
}
const totalCount = await db
.select({
count: count(),
})
.from(groupPermissions)
.leftJoin(groupMembers, eq(groupMembers.groupId, groupPermissions.groupId))
.leftJoin(users, eq(users.id, groupMembers.userId))
.where(and(eq(groupPermissions.permission, "admin"), eq(users.provider, "credentials")))
.then((rows: { count: number }[]) => rows.at(0)?.count ?? 0);
const existingUser = await db.query.users.findFirst({
where: eq(users.name, result.data),
});
if (totalCount > 0) {
throw new CliError("Credentials admin user exists");
}
if (existingUser) {
console.error("User with this name already exists");
return;
}
const existingUser = await db.query.users.findFirst({
where: eq(users.name, result.data),
});
const temporaryGroupId = createId();
if (existingUser) {
throw new CliError("User with this name already exists");
}
const maxPosition = await getMaxGroupPositionAsync(db);
await db.insert(groups).values({
id: temporaryGroupId,
name: temporaryGroupId,
position: maxPosition + 1,
});
const temporaryGroupId = createId();
const maxPosition = await db
.select({ value: max(groups.position) })
.from(groups)
.then((result: { value: number | null }[]) => result[0]?.value ?? 1);
await db.insert(groupPermissions).values({
groupId: temporaryGroupId,
permission: "admin",
});
await db.insert(groups).values({
id: temporaryGroupId,
name: temporaryGroupId,
position: maxPosition + 1,
});
const password = generateSecureRandomToken(24);
const hashedPassword = await hashPasswordAsync(password);
await db.insert(groupPermissions).values({
groupId: temporaryGroupId,
permission: "admin",
});
const userId = createId();
await db.insert(users).values({
id: userId,
name: result.data,
provider: "credentials",
password: hashedPassword,
});
const password = generateSecureRandomToken(24);
const hashedPassword = await hashPasswordAsync(password);
const userId = createId();
await db.insert(groupMembers).values({
groupId: temporaryGroupId,
userId,
});
await db.insert(users).values({
id: userId,
name: result.data,
provider: "credentials",
password: hashedPassword,
});
await db.insert(groupMembers).values({
groupId: temporaryGroupId,
userId,
});
console.log(
"We created a new admin user for you. Please keep in mind, that the admin group of it has a temporary name. You should change it to something more meaningful.",
);
console.log(`\tUsername: ${result.data}`);
console.log(`\tPassword: ${password}`);
console.log(`\tGroup: ${temporaryGroupId}`);
console.log("");
return 0;
}
console.log(
"We created a new admin user for you. Please keep in mind, that the admin group of it has a temporary name. You should change it to something more meaningful.",
);
console.log(`\tUsername: ${result.data}`);
console.log(`\tPassword: ${password}`);
console.log(`\tGroup: ${temporaryGroupId}`);
console.log(""); // Empty line for better readability
},
});
+37 -39
View File
@@ -1,48 +1,46 @@
import { parseArgs } from "node:util";
import { command, string } from "@drizzle-team/brocli";
import { hashPasswordAsync } from "@homarr/auth/security";
import { generateSecureRandomToken } from "../utils";
import { and, eq } from "drizzle-orm";
import { getCliDb } from "../cli-db";
import { CliError, requireCredentialsProvider } from "../errors";
import { hashPasswordAsync } from "@homarr/auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import { and, db, eq } from "@homarr/db";
import { sessions, users } from "@homarr/db/schema";
export async function resetPasswordHandler(args: string[]): Promise<number> {
requireCredentialsProvider();
export const resetPassword = command({
name: "reset-password",
desc: "Reset password for a user",
options: {
username: string("username").required().alias("u").desc("Name of the user"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (options) => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
const { values } = parseArgs({
args,
options: {
username: { type: "string", short: "u" },
},
});
const user = await db.query.users.findFirst({
where: and(eq(users.name, options.username), eq(users.provider, "credentials")),
});
if (!values.username) {
throw new CliError("Missing required option: --username / -u", 2);
}
if (!user?.password) {
console.error(`User ${options.username} not found`);
return;
}
const db = getCliDb();
const user = await db.query.users.findFirst({
where: and(eq(users.name, values.username), eq(users.provider, "credentials")),
});
// Generates a new password with 48 characters
const newPassword = generateSecureRandomToken(24);
if (!user?.password) {
throw new CliError(`User ${values.username} not found`);
}
await db
.update(users)
.set({
password: await hashPasswordAsync(newPassword),
})
.where(eq(users.id, user.id));
const newPassword = generateSecureRandomToken(24);
await db.delete(sessions).where(eq(sessions.userId, user.id));
console.log(`All sessions for user ${options.username} have been deleted`);
await db
.update(users)
.set({
password: await hashPasswordAsync(newPassword),
})
.where(eq(users.id, user.id));
await db.delete(sessions).where(eq(sessions.userId, user.id));
console.log(`All sessions for user ${values.username} have been deleted`);
console.log("You can now login with the new password");
console.log(`New password for user ${values.username}: ${newPassword}`);
return 0;
}
console.log("You can now login with the new password");
console.log(`New password for user ${options.username}: ${newPassword}`);
},
});
+33 -41
View File
@@ -1,47 +1,39 @@
import { parseArgs } from "node:util";
import { command, string } from "@drizzle-team/brocli";
import { eq } from "drizzle-orm";
import type { CliDatabase } from "../cli-db";
import { getCliDb } from "../cli-db";
import { CliError } from "../errors";
import { db, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
const userLookup: Record<"id" | "username", (db: CliDatabase, value: string) => Promise<{ id: string; name: string | null } | undefined>> = {
id: (db, value) =>
db.query.users.findFirst({
where: eq(users.id, value),
}),
username: (db, value) =>
db.query.users.findFirst({
where: eq(users.name, value),
}),
};
export const usersDelete = command({
name: "delete",
desc: "Delete a user by id or username",
options: {
id: string("id").alias("i").desc("ID of the user"),
username: string("username").alias("u").desc("Name of the user"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (options) => {
if (!options.id && !options.username) {
console.error("Either --id or --username must be provided");
return;
}
export async function usersDeleteHandler(args: string[]): Promise<number> {
const { values } = parseArgs({
args,
options: {
id: { type: "string", short: "i" },
username: { type: "string", short: "u" },
},
});
let user;
if (options.id) {
user = await db.query.users.findFirst({
where: eq(users.id, options.id),
});
} else if (options.username) {
user = await db.query.users.findFirst({
where: eq(users.name, options.username),
});
}
const lookupKey = values.id ? "id" : values.username ? "username" : null;
const lookupValue = values.id ?? values.username;
if (!user) {
console.error("User not found");
return;
}
if (!lookupKey || !lookupValue) {
throw new CliError("Either --id or --username must be provided", 2);
}
const db = getCliDb();
const user = await userLookup[lookupKey](db, lookupValue);
if (!user) {
throw new CliError("User not found");
}
await db.delete(users).where(eq(users.id, user.id));
console.log(`User ${user.name ?? user.id} (${user.id}) deleted`);
return 0;
}
await db.delete(users).where(eq(users.id, user.id));
console.log(`User ${user.name ?? user.id} (${user.id}) deleted`);
},
});
+18 -31
View File
@@ -1,35 +1,22 @@
import { parseArgs } from "node:util";
import { command } from "@drizzle-team/brocli";
import { getCliDb } from "../cli-db";
import { printTable } from "../utils";
import { db } from "@homarr/db";
export async function usersListHandler(args: string[] = []): Promise<number> {
const { values } = parseArgs({
args,
options: {
json: { type: "boolean", default: false },
},
strict: false,
});
export const usersList = command({
name: "list",
desc: "List all users (id, username, email)",
// eslint-disable-next-line no-restricted-syntax
handler: async () => {
const allUsers = await db.query.users.findMany();
const db = getCliDb();
const allUsers = await db.query.users.findMany();
if (allUsers.length === 0) {
console.log("No users found");
return;
}
if (values.json) {
const output = allUsers.map((u) => ({ id: u.id, name: u.name, email: u.email, provider: u.provider }));
console.log(JSON.stringify(output, null, 2));
return 0;
}
if (allUsers.length === 0) {
console.log("No users found");
return 0;
}
printTable(
["ID", "Username", "Email", "Provider"],
allUsers.map((u) => [u.id, u.name ?? "", u.email ?? "", u.provider]),
);
console.log(`\n${allUsers.length} user(s) total`);
return 0;
}
console.log("ID\t\t\t\tUsername\tEmail");
for (const user of allUsers) {
console.log(`${user.id}\t${user.name ?? ""}\t${user.email ?? ""}`);
}
},
});
@@ -1,66 +1,50 @@
import { parseArgs } from "node:util";
import { command, string } from "@drizzle-team/brocli";
import { hashPasswordAsync } from "@homarr/auth/security";
import { eq } from "drizzle-orm";
import type { CliDatabase } from "../cli-db";
import { getCliDb } from "../cli-db";
import { CliError, requireCredentialsProvider } from "../errors";
import { hashPasswordAsync } from "@homarr/auth";
import { db, eq } from "@homarr/db";
import { sessions, users } from "@homarr/db/schema";
const userLookup: Record<
"id" | "username",
(db: CliDatabase, value: string) => Promise<{ id: string; name: string | null; password: string | null } | undefined>
> = {
id: (db, value) =>
db.query.users.findFirst({
where: eq(users.id, value),
}),
username: (db, value) =>
db.query.users.findFirst({
where: eq(users.name, value),
}),
};
export const usersUpdatePassword = command({
name: "update-password",
desc: "Update the password for a user (credentials provider only)",
options: {
id: string("id").alias("i").desc("ID of the user"),
username: string("username").alias("u").desc("Name of the user"),
password: string("password").alias("p").required().desc("New password"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (options) => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
export async function usersUpdatePasswordHandler(args: string[]): Promise<number> {
requireCredentialsProvider();
if (!options.id && !options.username) {
console.error("Either --id or --username must be provided");
return;
}
const { values } = parseArgs({
args,
options: {
id: { type: "string", short: "i" },
username: { type: "string", short: "u" },
password: { type: "string", short: "p" },
},
});
const user = await db.query.users.findFirst({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
where: options.id ? eq(users.id, options.id) : eq(users.name, options.username!),
});
const lookupKey = values.id ? "id" : values.username ? "username" : null;
const lookupValue = values.id ?? values.username;
if (!user?.password) {
console.error("User not found or has no credentials record");
return;
}
if (!lookupKey || !lookupValue) {
throw new CliError("Either --id or --username must be provided", 2);
}
await db
.update(users)
.set({
password: await hashPasswordAsync(options.password),
})
.where(eq(users.id, user.id));
if (!values.password) {
throw new CliError("Missing required option: --password / -p", 2);
}
// invalidate existing sessions
await db.delete(sessions).where(eq(sessions.userId, user.id));
console.log(`All sessions for user ${user.name ?? user.id} have been deleted`);
const db = getCliDb();
const user = await userLookup[lookupKey](db, lookupValue);
if (!user?.password) {
throw new CliError("User not found or has no credentials record");
}
await db
.update(users)
.set({
password: await hashPasswordAsync(values.password),
})
.where(eq(users.id, user.id));
await db.delete(sessions).where(eq(sessions.userId, user.id));
console.log(`All sessions for user ${user.name ?? user.id} have been deleted`);
console.log("Password updated successfully");
return 0;
}
console.log("Password updated successfully");
},
});
+11
View File
@@ -0,0 +1,11 @@
import { command } from "@drizzle-team/brocli";
import { usersDelete } from "./users-delete";
import { usersList } from "./users-list";
import { usersUpdatePassword } from "./users-update-password";
export const usersRoot = command({
name: "users",
desc: "Group of commands to manage users",
subcommands: [usersList, usersDelete, usersUpdatePassword],
});
-16
View File
@@ -1,16 +0,0 @@
export class CliError extends Error {
constructor(
message: string,
readonly exitCode: 1 | 2 = 1,
) {
super(message);
this.name = "CliError";
}
}
export const requireCredentialsProvider = () => {
const enabled = process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials") ?? false;
if (!enabled) {
throw new CliError("Credentials provider is not enabled");
}
};
+11 -38
View File
@@ -1,41 +1,14 @@
import { closeCliDbAsync } from "./cli-db";
import { CliError } from "./errors";
import { runCliAsync } from "./router";
import { run } from "@drizzle-team/brocli";
const mainAsync = async (): Promise<number> => {
const argv = process.argv.slice(2);
const start = performance.now();
import { fixUsernames } from "./commands/fix-usernames";
import { integrationsRoot } from "./commands/integrations";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
import { usersRoot } from "./commands/users";
try {
const code = await runCliAsync(argv);
if (code === 0) {
const elapsed = Math.round(performance.now() - start);
console.error(`\nDone in ${elapsed}ms`);
}
return code;
} catch (error) {
if (error instanceof CliError) {
console.error(error.message);
return error.exitCode;
}
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
if (error instanceof TypeError && error.message.startsWith("Unknown option")) {
console.error(error.message);
return 2;
}
console.error(error instanceof Error ? error.message : error);
return 1;
}
};
mainAsync()
.then(async (code) => {
await closeCliDbAsync();
process.exit(code);
})
.catch(async (error) => {
console.error(error instanceof Error ? error.message : error);
await closeCliDbAsync();
process.exit(1);
});
void run(commands, {
name: "homarr-cli",
version: "1.0.0",
});
-167
View File
@@ -1,167 +0,0 @@
import { fixUsernamesHandler } from "./commands/fix-usernames";
import { integrationsDeleteHandler } from "./commands/integrations-delete";
import { integrationsListHandler } from "./commands/integrations-list";
import { recreateAdminHandler } from "./commands/recreate-admin";
import { resetPasswordHandler } from "./commands/reset-password";
import { usersDeleteHandler } from "./commands/users-delete";
import { usersListHandler } from "./commands/users-list";
import { usersUpdatePasswordHandler } from "./commands/users-update-password";
import { CliError } from "./errors";
const VERSION = "1.0.0";
type CommandHandler = (args: string[]) => Promise<number>;
interface CommandDef {
handler: CommandHandler;
desc: string;
usage: string;
}
type CommandGroup = Record<string, CommandDef>;
const topLevelCommands: Record<string, CommandDef> = {
"reset-password": {
handler: resetPasswordHandler,
desc: "Reset password for a credentials user",
usage: "homarr reset-password -u <username>",
},
"fix-usernames": {
handler: (args) => fixUsernamesHandler(),
desc: "Lowercase all credentials usernames",
usage: "homarr fix-usernames",
},
"recreate-admin": {
handler: recreateAdminHandler,
desc: "Create credentials admin if none exists",
usage: "homarr recreate-admin -u <username>",
},
};
const commandGroups: Record<string, CommandGroup> = {
users: {
list: {
handler: (args) => usersListHandler(args),
desc: "List all users",
usage: "homarr users list [--json]",
},
delete: {
handler: usersDeleteHandler,
desc: "Delete a user by id or username",
usage: "homarr users delete --id <id> | --username <name>",
},
"update-password": {
handler: usersUpdatePasswordHandler,
desc: "Set password for a credentials user",
usage: "homarr users update-password (--id <id> | --username <name>) --password <pw>",
},
},
integrations: {
list: {
handler: (args) => integrationsListHandler(args),
desc: "List all integrations",
usage: "homarr integrations list [--json]",
},
delete: {
handler: integrationsDeleteHandler,
desc: "Delete an integration by id or name",
usage: "homarr integrations delete --id <id> | --name <name>",
},
},
};
const HELP_TEXT = `homarr-cli ${VERSION}
Usage:
homarr <command> [options]
Commands:
reset-password Reset password for a credentials user
recreate-admin Create credentials admin if none exists
fix-usernames Lowercase all credentials usernames
users list List all users
users delete Delete a user by id or username
users update-password Set password for a credentials user
integrations list List all integrations
integrations delete Delete an integration by id or name
Global Options:
-h, --help Show help (use after a command for command-specific help)
-v, --version Show CLI version
`;
const printHelp = () => {
console.log(HELP_TEXT);
};
const printCommandHelp = (def: CommandDef) => {
console.log(`${def.desc}\n`);
console.log(`Usage:\n ${def.usage}\n`);
};
const resolveHandler = (argv: string[]): { handler: CommandHandler; args: string[] } => {
const [command, subcommand, ...rest] = argv;
if (!command) {
throw new CliError("Missing command", 2);
}
const group = commandGroups[command];
if (group) {
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
const subcommands = Object.entries(group)
.map(([name, def]) => ` ${name.padEnd(18)} ${def.desc}`)
.join("\n");
console.log(`Usage:\n homarr ${command} <subcommand> [options]\n\nSubcommands:\n${subcommands}\n`);
return { handler: async () => subcommand ? 0 : 2, args: [] };
}
const def = group[subcommand];
if (!def) {
throw new CliError(`Unknown subcommand "${subcommand}" for "${command}"`, 2);
}
if (hasFlag(rest, "--help", "-h")) {
printCommandHelp(def);
return { handler: async () => 0, args: [] };
}
return { handler: def.handler, args: rest };
}
const def = topLevelCommands[command];
if (!def) {
throw new CliError(`Unknown command "${command}"`, 2);
}
const args = argv.slice(1);
if (hasFlag(args, "--help", "-h")) {
printCommandHelp(def);
return { handler: async () => 0, args: [] };
}
return { handler: def.handler, args };
};
const hasFlag = (argv: string[], flag: string, short: string) =>
argv.some((arg) => arg === flag || arg === short);
export const runCliAsync = async (argv: string[]): Promise<number> => {
if (hasFlag(argv, "--help", "-h") && argv.length === 1) {
printHelp();
return 0;
}
if (hasFlag(argv, "--version", "-v")) {
console.log(VERSION);
return 0;
}
if (argv.length === 0) {
printHelp();
return 2;
}
const { handler, args } = resolveHandler(argv);
return handler(args);
};
-18
View File
@@ -1,18 +0,0 @@
import { randomBytes } from "node:crypto";
export const generateSecureRandomToken = (size: number) => randomBytes(size).toString("hex");
export const printTable = (headers: string[], rows: string[][]) => {
const widths = headers.map((h, i) =>
Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
);
const formatRow = (cells: string[]) =>
cells.map((cell, i) => cell.padEnd(widths[i]!)).join(" ");
console.log(formatRow(headers));
console.log(widths.map((w) => "─".repeat(w)).join(" "));
for (const row of rows) {
console.log(formatRow(row));
}
};
+12 -12
View File
@@ -27,22 +27,22 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/core": "workspace:^0.1.0",
"@paralleldrive/cuid2": "catalog:",
"cookie": "catalog:",
"dayjs": "catalog:",
"next": "catalog:",
"octokit": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"undici": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"
"@paralleldrive/cuid2": "^3.3.0",
"cookie": "1.1.1",
"dayjs": "^1.11.20",
"next": "16.2.3",
"octokit": "^5.0.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"undici": "8.0.2",
"zod": "^4.3.6",
"zod-validation-error": "^5.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
"typescript": "catalog:"
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
-4
View File
@@ -41,10 +41,6 @@ export class Stopwatch {
return dayjs().millisecond(this.startTime).fromNow(true);
}
getElapsedInFormattedMilliseconds() {
return `${this.getElapsedInMilliseconds().toFixed(0)}ms`;
}
getElapsedInMilliseconds() {
return performance.now() - this.startTime;
}
+14 -15
View File
@@ -14,7 +14,6 @@
"./infrastructure/db": "./src/infrastructure/db/index.ts",
"./infrastructure/db/env": "./src/infrastructure/db/env.ts",
"./infrastructure/db/constants": "./src/infrastructure/db/constants.ts",
"./infrastructure/db/mapping": "./src/infrastructure/db/mapping.ts",
"./infrastructure/certificates": "./src/infrastructure/certificates/index.ts",
"./infrastructure/certificates/hostnames/db/sqlite": "./src/infrastructure/certificates/hostnames/db/sqlite.ts",
"./infrastructure/certificates/hostnames/db/mysql": "./src/infrastructure/certificates/hostnames/db/mysql.ts",
@@ -38,24 +37,24 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "catalog:",
"better-sqlite3": "catalog:",
"dns-caching": "catalog:",
"drizzle-orm": "catalog:",
"ioredis": "catalog:",
"mysql2": "catalog:",
"pg": "catalog:",
"superjson": "catalog:",
"winston": "catalog:",
"zod": "catalog:"
"@t3-oss/env-nextjs": "^0.13.11",
"better-sqlite3": "^12.8.0",
"dns-caching": "^0.2.9",
"drizzle-orm": "^0.45.2",
"ioredis": "5.10.1",
"mysql2": "3.20.0",
"pg": "^8.20.0",
"superjson": "2.2.6",
"winston": "3.19.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "catalog:",
"@types/pg": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
"@types/better-sqlite3": "7.6.13",
"@types/pg": "^8.20.0",
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
@@ -15,7 +15,11 @@ export class UndiciHttpAgent extends EnvHttpProxyAgent {
private logger: ILogger;
constructor(props?: HttpAgentOptions) {
super(props);
super({
...props,
// See https://github.com/homarr-labs/homarr/issues/5467#issuecomment-4226844346
allowH2: false,
});
this.logger = props?.logger ?? createLogger({ module: "httpAgent" });
}
+11 -9
View File
@@ -28,19 +28,21 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"react": "catalog:",
"zod": "catalog:"
"@tanstack/react-query": "^5.96.2",
"@trpc/client": "^11.16.0",
"@trpc/server": "^11.16.0",
"@trpc/tanstack-react-query": "^11.16.0",
"node-cron": "^4.2.1",
"react": "19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/react": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
"@types/node-cron": "^3.0.11",
"@types/react": "19.2.14",
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
+9 -4
View File
@@ -1,4 +1,5 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { validate } from "node-cron";
import { z } from "zod/v4";
import type { JobGroupKeys } from "@homarr/cron-jobs";
@@ -12,7 +13,7 @@ export const jobNameSchema = z.enum(jobGroup.getKeys());
export interface IJobManager {
startAsync(name: JobGroupKeys): Promise<void>;
triggerAsync(name: JobGroupKeys): Promise<void>;
stop(name: JobGroupKeys): void;
stopAsync(name: JobGroupKeys): Promise<void>;
updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void>;
disableAsync(name: JobGroupKeys): Promise<void>;
enableAsync(name: JobGroupKeys): Promise<void>;
@@ -51,6 +52,10 @@ const apiKeyProcedure = t.procedure.use(({ ctx, next }) => {
});
});
export const cronExpressionSchema = z.string().refine((expression) => validate(expression), {
error: "Invalid cron expression",
});
export const jobRouter = createTrpcRouter({
start: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.startAsync(input);
@@ -58,14 +63,14 @@ export const jobRouter = createTrpcRouter({
trigger: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.triggerAsync(input);
}),
stop: apiKeyProcedure.input(jobNameSchema).mutation(({ input, ctx }) => {
ctx.manager.stop(input);
stop: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.stopAsync(input);
}),
updateInterval: apiKeyProcedure
.input(
z.object({
name: jobNameSchema,
cron: z.string(),
cron: cronExpressionSchema,
}),
)
.mutation(async ({ input, ctx }) => {
+2 -2
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
"typescript": "catalog:"
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
+4 -3
View File
@@ -27,13 +27,14 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^",
"@homarr/db": "workspace:^0.1.0",
"croner": "catalog:"
"node-cron": "^4.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
"typescript": "catalog:"
"@types/node-cron": "^3.0.11",
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
+12 -11
View File
@@ -1,4 +1,4 @@
import { Cron } from "croner";
import { createTask, validate } from "node-cron";
import { Stopwatch } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
@@ -83,15 +83,10 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
? defaultCronExpression
: (configuration?.cronExpression ?? defaultCronExpression);
const scheduledTask = new Cron(
cronExpression,
{
name,
timezone: creatorOptions.timezone,
paused: true,
},
() => void catchingCallbackAsync(),
);
const scheduledTask = createTask(cronExpression, () => void catchingCallbackAsync(), {
name,
timezone: creatorOptions.timezone,
});
creatorOptions.logger.logDebug("The scheduled task for cron job was created", {
name,
cronExpression: defaultCronExpression,
@@ -121,7 +116,6 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
},
preventManualExecution: options.preventManualExecution ?? false,
preventCustomInterval: options.preventCustomInterval ?? false,
timezone: creatorOptions.timezone,
};
};
};
@@ -138,6 +132,13 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
defaultCronExpression: TExpression,
options: CreateCronJobOptions = { runOnStart: false },
) => {
creatorOptions.logger.logDebug("Validating cron expression for cron job", {
name,
cronExpression: defaultCronExpression,
});
if (!validate(defaultCronExpression)) {
throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
}
creatorOptions.logger.logDebug("Cron job expression for cron job is valid", {
name,
cronExpression: defaultCronExpression,
+11 -11
View File
@@ -1,4 +1,4 @@
import type { Cron } from "croner";
import type { ScheduledTask } from "node-cron";
import { objectEntries, objectKeys } from "@homarr/common";
import { db } from "@homarr/db";
@@ -34,7 +34,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
});
}
const tasks = new Map<string, Cron>();
const tasks = new Map<string, ScheduledTask>();
return {
initializeAsync: async () => {
@@ -62,7 +62,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
name: job.name,
});
await job.onStartAsync();
tasks.get(name as string)?.resume();
await tasks.get(name as string)?.start();
},
startAllAsync: async () => {
for (const job of jobRegistry.values()) {
@@ -74,7 +74,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
name: job.name,
});
await job.onStartAsync();
tasks.get(job.name)?.resume();
await tasks.get(job.name)?.start();
}
},
runManuallyAsync: async (name: keyof TJobs) => {
@@ -87,23 +87,23 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
options.logger.logInfo("Running schedule cron job manually.", {
name: job.name,
});
await tasks.get(name as string)?.trigger();
await tasks.get(name as string)?.execute();
},
stop: (name: keyof TJobs) => {
stopAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string);
if (!job) return;
options.logger.logInfo("Stopping schedule of cron job.", {
name: job.name,
});
tasks.get(name as string)?.pause();
await tasks.get(name as string)?.stop();
},
stopAll: () => {
stopAllAsync: async () => {
for (const job of jobRegistry.values()) {
options.logger.logInfo("Stopping schedule of cron job.", {
name: job.name,
});
tasks.get(job.name)?.pause();
await tasks.get(job.name)?.stop();
}
},
getJobRegistry() {
@@ -112,8 +112,8 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
getTask(name: keyof TJobs) {
return tasks.get(name as string) ?? null;
},
setTask(name: keyof TJobs, cron: Cron) {
tasks.set(name as string, cron);
setTask(name: keyof TJobs, task: ScheduledTask) {
tasks.set(name as string, task);
},
getKeys() {
return objectKeys(jobs);
+3 -3
View File
@@ -38,13 +38,13 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"semver-parser": "catalog:"
"semver-parser": "^4.1.8"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
"typescript": "catalog:"
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
}
-2
View File
@@ -20,7 +20,6 @@ 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,7 +54,6 @@ export const jobGroup = createCronJobGroup({
weather: weatherJob,
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
+52 -44
View File
@@ -1,12 +1,9 @@
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { createId, splitToNChunks, Stopwatch } from "@homarr/common";
import { env } from "@homarr/common/env";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db";
import { and, db, eq, handleTransactionsAsync, inArray, notInArray, or } from "@homarr/db";
import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
import { iconRepositories, icons } from "@homarr/db/schema";
import { fetchIconsAsync } from "@homarr/icons";
@@ -29,7 +26,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
logger.info("Fetched icons from repositories", {
repositoryCount: repositoryIconGroups.length,
iconCount: countIcons,
duration: stopWatch.getElapsedInFormattedMilliseconds(),
duration: stopWatch.getElapsedInHumanWords(),
});
const databaseIconRepositories = await db.query.iconRepositories.findMany({
@@ -38,7 +35,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
},
});
const skippedChecksums = new Map<string, string[]>();
const skippedChecksums: `${string}.${string}`[] = [];
let countDeleted = 0;
let countInserted = 0;
@@ -53,7 +50,6 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
if (!repositoryIconGroup.success) {
continue;
}
const localSkippedChecksums: string[] = [];
const repositoryInDb = databaseIconRepositories.find(
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
@@ -70,7 +66,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
for (const icon of repositoryIconGroup.icons) {
if (dbIconsInRepository.some((dbIcon) => dbIcon.checksum === icon.checksum)) {
localSkippedChecksums.push(icon.checksum);
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
continue;
}
@@ -83,38 +79,19 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
});
countInserted++;
}
skippedChecksums.set(iconRepositoryId, localSkippedChecksums);
}
const deadIcons = allDbIcons.filter(
(icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`),
);
const deadIconRepositories = databaseIconRepositories.filter(
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
);
const deadIconsFilter = or(
...[...skippedChecksums.entries()].map(([iconRepositoryId, checksums]) =>
and(eq(icons.iconRepositoryId, iconRepositoryId), notInArray(icons.checksum, checksums)),
),
);
await handleTransactionsAsync(db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const result = (await transaction.delete(schema.icons).where(deadIconsFilter)) as
| MySqlRawQueryResult
| QueryResult;
countDeleted += Array.isArray(result) ? result[0].affectedRows : (result.rowCount ?? 0);
if (deadIconRepositories.length >= 1) {
await transaction.delete(schema.iconRepositories).where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
);
}
if (newIconRepositories.length >= 1) {
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
}
@@ -125,12 +102,52 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
await transaction.insert(schema.icons).values(chunck);
}
}
if (deadIcons.length >= 1) {
await transaction.delete(schema.icons).where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
);
}
if (deadIconRepositories.length >= 1) {
await transaction.delete(schema.iconRepositories).where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
);
}
countDeleted += deadIcons.length;
});
},
handleSync() {
db.transaction((transaction) => {
const result = transaction.delete(icons).where(deadIconsFilter).run();
countDeleted += result.changes;
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
transaction
@@ -144,22 +161,13 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
.run();
}
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
countDeleted += deadIcons.length;
});
},
});
logger.info("Updated icons in database", {
duration: stopWatch.getElapsedInFormattedMilliseconds(),
duration: stopWatch.getElapsedInHumanWords(),
added: countInserted,
deleted: countDeleted,
});
@@ -1,14 +0,0 @@
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 } : []),
},
}),
);
@@ -1 +0,0 @@
ALTER TABLE `user` ADD `ddg_bangs` boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more