chore(release): automatic release v2.3.0

This commit is contained in:
homarr-releases[bot] authored and GitHub committed 2026-10-07 15:45:15 +00:00
commit 6c355897d2
193 files changed
+13778 -30900

No files matched your search

+3 -3
View File
@@ -17,11 +17,11 @@ Orient from the current checkout before editing. Treat package manifests, export
## Current toolchain
- Use Node `24.18.0` from `mise.toml` and pnpm `11.15.1` from `package.json`.
- Use pnpm workspaces with Turborepo. Workspace packages use the `@homarr/` scope and catalog-managed dependencies.
- Use Node `24.18.0` for application runtimes and Bun `1.4.2` for package management, pinned in `mise.toml` and `package.json`.
- Use Bun workspaces with Turborepo and a shared hoisted install. Workspace packages use the `@homarr/` scope and catalog-managed dependencies.
- Use Next.js App Router, TypeScript, tRPC, Drizzle, Mantine v9, Tabler icons, Jotai, TanStack Query, next-intl, Vitest, and Playwright.
- Use oxlint and oxfmt. Mantine is the application UI system; Tailwind is limited to the docs app.
- `pnpm dev` starts only `@homarr/nextjs`. Start a standalone runtime explicitly when the task needs it.
- `bun run dev` starts only `@homarr/nextjs`. Start a standalone runtime explicitly when the task needs it.
## High-value seams
@@ -18,9 +18,9 @@
| `apps/websocket` | tRPC WebSocket subscriptions on port 3001 |
| `apps/workshop` | Go/PocketBase Workshop service and provider integration |
`pnpm dev` runs only `@homarr/nextjs`. Use `pnpm --filter @homarr/tasks dev`, `pnpm --filter @homarr/websocket dev`, or `pnpm dev:docs` when a task needs those runtimes.
`bun run dev` runs only `@homarr/nextjs`. Use `bun run --filter @homarr/tasks dev`, `bun run --filter @homarr/websocket dev`, or `bun run dev:docs` when a task needs those runtimes.
In production, `apps/nextjs/src/instrumentation-node.ts` starts the tasks and WebSocket runtimes inside the Node process. Nginx listens on port 7575, proxies `/websockets` to port 3001, and sends other traffic to port 3000. Redis can run internally or externally.
In production, `apps/nextjs/src/instrumentation-node.ts` starts the tasks and WebSocket runtimes inside the Bun process. Nginx listens on port 7575, proxies `/websockets` to port 3001, and sends other traffic to port 3000. Redis can run internally or externally.
## Package map
@@ -34,7 +34,7 @@ In production, `apps/nextjs/src/instrumentation-node.ts` starts the tasks and We
### Data, identity, and transport
- `db`: Drizzle schemas, queries, migrations, and three database drivers.
- `db`: Drizzle schemas, queries, migrations, and SQLite and PostgreSQL drivers.
- `auth`: Auth.js/NextAuth configuration, sessions, providers, and API keys.
- `api`: tRPC routers, contexts, MCP metadata extraction, OpenAPI, and client/server entrypoints.
- `redis`: Redis clients, pub/sub channels, and caches.
@@ -57,7 +57,7 @@ In production, `apps/nextjs/src/instrumentation-node.ts` starts the tasks and We
- `settings`, `spotlight`, `notifications`, `onboarding`: cross-feature UI flows.
- `widgets`: widget definitions, dynamic component loaders, and shared widget UI.
- `translation`: next-intl configuration and locale catalogs.
- `cli`: Node operations CLI. `tools/homarr-dev` is the separate Go developer CLI.
- `cli`: Bun operations CLI. `tools/homarr-dev` is the separate Go developer CLI.
## Dependency direction
@@ -103,8 +103,8 @@ User routes live below `apps/nextjs/src/app/[locale]/`; the locale segment does
## Commands and conventions
- Read current scripts from the root or package `package.json`; avoid caching commands that do not exist.
- Use `pnpm dev` for Next.js, `pnpm dev:docs` for docs, and `pnpm dev:cli -- <args>` for the Go developer CLI.
- Use `pnpm docker:dev:up` for the Redis development dependency.
- Use `bun run dev` for Next.js, `bun run dev:docs` for docs, and `bun run dev:cli -- <args>` for the Go developer CLI.
- Use `bun run docker:dev:up` for the Redis development dependency.
- Follow the Testing section in `AGENTS.md` for test creation and validation scope.
- Use `~/*` only inside the Next.js app. Use public `@homarr/*` entrypoints across package boundaries.
- Keep Mantine application styles separate from the Tailwind-based docs app.
+2 -2
View File
@@ -53,5 +53,5 @@ For a widget:
1. Every passage adds information a user needs and cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
5. Treat broken links and anchors as failures; run `pnpm --filter @homarr/docs validate:links` after the static build.
4. Run the narrowest useful docs validation. Use `bun run turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
5. Treat broken links and anchors as failures; run `bun run --filter @homarr/docs validate:links` after the static build.
+2 -2
View File
@@ -93,6 +93,6 @@ Register the smallest router that owns the enabled procedures. If a parent route
1. Confirm the procedure still enforces its ordinary auth and permission boundary.
2. Confirm `packages/api/src/mcp.ts` eagerly includes the procedure.
3. Confirm the generated tool name, description, and JSON schema are unambiguous.
4. When validating tool-list or extraction changes, use the focused API MCP spec: `pnpm test packages/api/src/test/mcp.spec.ts`.
5. When validating protocol or transport changes, use the focused route spec: `pnpm test apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts`.
4. When validating tool-list or extraction changes, use the focused API MCP spec: `bun run test packages/api/src/test/mcp.spec.ts`.
5. When validating protocol or transport changes, use the focused route spec: `bun run test apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts`.
6. If manually probing `/api/mcp/<transport>`, use a scoped API key and avoid printing credentials or secret-bearing results.
+16 -2
View File
@@ -5,9 +5,18 @@ node_modules
npm-debug.log
README.md
.next
**/.output
.turbo
.pnpm-store
**/.pnpm-store
**/.turbo
**/next-env.d.ts
packages/db/migrations/*/migrate.cjs
packages/cli/cli.cjs
apps/tasks/tasks.cjs
apps/tasks/tasks.css
apps/websocket/wssServer.cjs
apps/websocket/wssServer.css
.bun-cache
**/.bun-cache
coverage
**/coverage
**/.docusaurus
@@ -23,3 +32,8 @@ apps/nextjs/.next
.env
.env.*
!.env.example
benchmark-results
scripts/benchmarks/results
**/__pycache__
**/*.tsbuildinfo
+1
View File
@@ -45,6 +45,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 2.2.0
- 2.1.2
- 2.0.0
- 1.77.0
+8 -6
View File
@@ -3,18 +3,20 @@
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
bun run ci:act:fast
bun run ci:act:docker
bun run ci:act:all
bun run ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
`fast` runs the database checks and quality gate and requires an `origin/dev` tracking ref. `docker` and `all` use the manual validation event to run those checks, build the container once, and run the converter boot/restart smoke test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
For rootless Docker, set `ACT_TESTCONTAINERS_HOST` to a host address reachable from the runner when loopback callbacks are unavailable.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
```bash
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
ACT_SECRET_FILE=.github/act/.secrets bun run ci:act:all
```
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
@@ -19,11 +19,6 @@ inputs:
runs:
using: "composite"
steps:
- name: Prebuilt debian dependencies
uses: ./.github/actions/prebuilt-debian
id: prebuilt-debian
with:
architecture: ${{ inputs.architecture }}
- name: Start docker container for ${{ inputs.architecture }}
run: |
docker run --name homarr \
@@ -43,8 +38,6 @@ runs:
shell: bash
- name: Extract source from ${{ inputs.architecture }} container (debian)
run: |
docker cp ${{ steps.prebuilt-debian.outputs.path }}/. homarr:/app/build && \
docker cp ${{ steps.prebuilt-debian.outputs.path }}/. homarr:/app/node_modules/better-sqlite3/build/Release && \
docker exec homarr tar -czf extraction-debian.tar.gz -C /app . && \
docker cp homarr:/app/extraction-debian.tar.gz ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-debian-${{ inputs.architecture }}.tar.gz
shell: bash
+1 -13
View File
@@ -6,11 +6,6 @@
matchPackageNames: ["^@homarr/"],
enabled: false,
},
// Reenable once https://github.com/privatenumber/tsx/issues/737 is fixed
{
matchPackageNames: ["tsx"],
enabled: false,
},
// Reenable once bug with undici v8 is fixed (https://github.com/homarr-labs/homarr/issues/5467)
{
matchPackageNames: ["undici"],
@@ -22,11 +17,10 @@
automerge: true,
},
{
// disallow automerge for allowBuilds dependencies (native builds need manual verification)
// disallow automerge for trustedDependencies (native builds need manual verification)
matchPackageNames: [
"@tree-sitter-grammars/tree-sitter-yaml",
"bcrypt",
"better-sqlite3",
"cpu-features",
"esbuild",
"sharp",
@@ -37,12 +31,6 @@
automerge: false,
},
],
// Updates lockfile for prebuilt-debian after if package.json is updated, otherwise only root lockfile is updated
postUpgradeTasks: {
commands: ["cd deployments/prebuilt-debian && pnpm install --ignore-workspace --lockfile-only"],
fileFilters: ["deployments/prebuilt-debian/pnpm-lock.yaml"],
executionMode: "update",
},
updateInternalDeps: true,
rangeStrategy: "bump",
automerge: false,
+1 -1
View File
@@ -32,4 +32,4 @@ jobs:
run: |
git diff --name-only --diff-filter=ACMR -z "origin/${BASE_REF}...HEAD" -- \
'*.css' '*.js' '*.json' '*.jsonc' '*.jsx' '*.md' '*.mdx' '*.scss' '*.ts' '*.tsx' '*.yaml' '*.yml' \
| xargs -0 -r pnpm exec oxfmt --check --
| xargs -0 -r bun x oxfmt --check --no-error-on-unmatched-pattern --
+22 -21
View File
@@ -67,7 +67,6 @@ jobs:
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
TURBO_PLATFORM: linux/amd64
steps:
- name: Require database validation
env:
@@ -92,26 +91,34 @@ jobs:
- name: Setup
uses: ./tooling/github/setup
with:
next-cache: "all"
turbo-cache: "true"
- name: Copy environment defaults
run: cp .env.example .env
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
- name: Lint, typecheck, and build workspaces
run: |
scope=(--affected)
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
scope=()
fi
bun run turbo run lint typecheck build "${scope[@]}" --continue=dependencies-successful --concurrency=2
- name: Validate workspace configuration
run: pnpm lint:ws
run: bun run lint:ws
- name: Validate Custom Widget architecture
run: pnpm check:custom-widgets
run: bun run check:custom-widgets
- name: Validate checked-in OpenAPI schema
run: pnpm check:openapi
run: bun run check:openapi
- name: Validate Custom Widget bundle budget
run: |
pnpm turbo run build --filter=@homarr/nextjs
pnpm check:custom-widget-bundle
bun run turbo run build --filter=@homarr/nextjs
bun run check:custom-widget-bundle
container:
name: Container and migration smoke
@@ -180,14 +187,8 @@ jobs:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
run: |
secret_args=()
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
if [ -n "${!secret:-}" ]; then
secret_args+=(--secret "id=${secret},env=${secret}")
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
bash tooling/github/docker-build.sh --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e .
- name: Cache Playwright browsers
if: "!env.ACT"
@@ -195,24 +196,24 @@ jobs:
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
run: bun x playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
run: bun x playwright install-deps chromium
- name: Install standalone converter test dependencies
run: npm ci --prefix tools/mysql-to-sqlite
run: bun install --cwd tools/mysql-to-sqlite --frozen-lockfile
- name: Verify converted database boots and survives restart
env:
NODE_ENV: development
CI: "true"
run: pnpm exec vitest run --project e2e e2e/mysql-conversion.spec.ts --maxWorkers 1
run: bun x vitest run --project e2e e2e/mysql-conversion.spec.ts --maxWorkers 1
- name: Upload E2E artifacts
if: failure() && !env.ACT
+7 -4
View File
@@ -20,6 +20,7 @@ jobs:
migrations: ${{ steps.select.outputs.migrations }}
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
fetch-depth: 0
persist-credentials: false
@@ -43,7 +44,7 @@ jobs:
git diff --name-only -z "$comparison" > "$changed_files"
while IFS= read -r -d '' file; do
case "$file" in
packages/db/*|packages/core/src/infrastructure/db/*|packages/core/src/infrastructure/certificates/*|packages/auth/*|packages/api/src/router/onboard*|packages/api/src/router/test/onboard*|packages/onboarding/*|apps/nextjs/src/app/api/backup/*|apps/nextjs/src/app/*/init/*|Dockerfile|scripts/run.sh|scripts/entrypoint.sh|deployments/*|package.json|packages/core/package.json|packages/common/package.json|packages/definitions/package.json|packages/server-settings/package.json|packages/validation/package.json|mise.toml|pnpm-lock.yaml|pnpm-workspace.yaml|patches/*|vitest*|tooling/github/*|.github/workflows/database.yml|.github/workflows/ci.yml)
packages/db/*|packages/core/src/infrastructure/db/*|packages/core/src/infrastructure/certificates/*|packages/auth/*|packages/api/src/router/onboard*|packages/api/src/router/test/onboard*|packages/onboarding/*|apps/nextjs/src/app/api/backup/*|apps/nextjs/src/app/*/init/*|Dockerfile|scripts/run.sh|scripts/entrypoint.sh|deployments/*|package.json|packages/core/package.json|packages/common/package.json|packages/definitions/package.json|packages/server-settings/package.json|packages/validation/package.json|mise.toml|bun.lock|bunfig.toml|patches/*|vitest*|tooling/github/*|.github/workflows/database.yml|.github/workflows/ci.yml)
migrations=true
;;
esac
@@ -59,10 +60,11 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
persist-credentials: false
- uses: ./tooling/github/setup
- run: pnpm exec vitest run --project db-node packages/db/test/migration-journal.spec.ts
- run: bun x vitest run --project db-node packages/db/test/migration-journal.spec.ts
migrations:
name: SQLite and PostgreSQL migrations
@@ -72,12 +74,13 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
persist-credentials: false
- uses: ./tooling/github/setup
- name: Reject retired database configuration
run: pnpm exec vitest run --project dom packages/core/src/infrastructure/db/env.spec.ts
- run: pnpm exec vitest run --project db-node --project integration packages/db/test/sqlite-migration.spec.ts packages/db/test/postgresql-migration.spec.ts packages/db/proxy-reader.integration.spec.ts --maxWorkers 1
run: bun x vitest run --project dom packages/core/src/infrastructure/db/env.spec.ts
- run: bun x vitest run --project db-node --project integration packages/db/test/sqlite-migration.spec.ts packages/db/test/postgresql-migration.spec.ts packages/db/proxy-reader.integration.spec.ts --maxWorkers 1
database-gate:
name: Database gate
@@ -63,13 +63,10 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v6
if: env.SKIP_RELEASE == 'false'
- uses: actions/setup-node@v6
- uses: oven-sh/setup-bun@v2
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.18.0
package-manager-cache: false
bun-version: 1.4.2
- name: Verify major release intent
if: env.SKIP_RELEASE == 'false'
run: |
@@ -92,7 +89,7 @@ jobs:
exit 1
- name: Install dependencies
if: env.SKIP_RELEASE == 'false'
run: pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Run Semantic Release
if: env.SKIP_RELEASE == 'false'
env:
@@ -102,7 +99,7 @@ jobs:
GIT_COMMITTER_NAME: "Releases Homarr"
GIT_COMMITTER_EMAIL: "175486441+homarr-releases[bot]@users.noreply.github.com"
run: |
pnpm release
bun run release
- name: Read semver output
# We read the last tag either from the created release or from the current branch, this is to rerun the deployment job for the currently released version when it failed
if: env.SKIP_RELEASE == 'false' || github.ref_name == 'main' || github.ref_name == 'beta'
+14 -5
View File
@@ -5,7 +5,13 @@ on:
branches: ["dev"]
paths:
- "apps/docs/**"
- "packages/definitions/src/docs/**"
- "packages/**"
- "tooling/**"
- "tools/motion-reel/three/**"
- "package.json"
- "bun.lock"
- "bunfig.toml"
- "turbo.json"
- ".github/workflows/deployment-docs.yml"
workflow_dispatch:
@@ -33,15 +39,18 @@ jobs:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
with:
next-cache: "docs"
turbo-cache: "true"
- name: Build docs
run: pnpm turbo build --filter=@homarr/docs
run: bun run turbo run build --filter=@homarr/docs
env:
CI: "true"
- name: Validate exported links and search
run: |
pnpm --filter @homarr/docs validate:links
pnpm --filter @homarr/docs verify:search
pnpm --filter @homarr/docs verify:seo
bun run --filter @homarr/docs validate:links
bun run --filter @homarr/docs verify:search
bun run --filter @homarr/docs verify:seo
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload pages artifact
@@ -28,7 +28,7 @@ jobs:
- name: Dump contributors
working-directory: apps/docs
run: pnpm api-dump-contributors
run: bun run api-dump-contributors
- name: Commit updated contributors
uses: EndBug/add-and-commit@v10
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run: cp .env.example .env
- name: Generate and validate OpenAPI spec
run: pnpm scripts:update-openapi
run: bun run scripts:update-openapi
- name: Commit updated spec
uses: EndBug/add-and-commit@v10
@@ -12,8 +12,15 @@ jobs:
renovate-validate:
runs-on: ubuntu-latest
timeout-minutes: 2
env:
# The optional RE2 addon uses V8 APIs; validation can use JavaScript regexes.
RENOVATE_X_IGNORE_RE2: "true"
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2
- run: |
npx --yes --package renovate -- \
bun x --package renovate \
renovate-config-validator --strict .github/renovate.json5
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
ref: dev
- name: Setup
uses: ./tooling/github/setup
- run: pnpm run scripts:update-bug-report-template
- run: bun run scripts:update-bug-report-template
env:
NEXT_VERSION: ${{ github.event.release.tag_name }}
+3 -7
View File
@@ -29,16 +29,12 @@ jobs:
uses: actions/checkout@v7
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- uses: pnpm/action-setup@v6
- name: Use Node.js
uses: actions/setup-node@v6
- uses: oven-sh/setup-bun@v2
with:
node-version: 24.18.0
cache: "pnpm"
bun-version: 1.4.2
- name: Run update script
run: node ./scripts/update-contributors.mjs
run: bun ./scripts/update-contributors.mjs
- name: Commit changes
env:
@@ -33,7 +33,7 @@ jobs:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Setup
uses: ./tooling/github/setup
- run: pnpm run scripts:update-readme-integrations
- run: bun run scripts:update-readme-integrations
- name: Commit changes
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
+19 -8
View File
@@ -13,10 +13,11 @@ on:
- "packages/api/src/**"
- "packages/auth/src/**"
- "packages/integrations/src/**"
- "tools/motion-reel/three/**"
- "tooling/**"
- "pnpm-lock.yaml"
- "bun.lock"
- "package.json"
- "pnpm-workspace.yaml"
- "bunfig.toml"
- "turbo.json"
- ".github/workflows/deployment-docs.yml"
- ".github/workflows/validate-docs.yml"
@@ -35,20 +36,30 @@ jobs:
timeout-minutes: 15
env:
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
with:
next-cache: "docs"
turbo-cache: "true"
- name: Validate the published API schema against the application
run: pnpm check:openapi
run: bun run check:openapi
- name: Validate browser access and API schema rendering
run: pnpm exec vitest run 'apps/nextjs/src/app/api/[...trpc]/route.spec.ts' apps/docs/src/lib/openapi.spec.ts
run: bun x vitest run 'apps/nextjs/src/app/api/[...trpc]/route.spec.ts' apps/docs/src/lib/openapi.spec.ts
- name: Build docs
run: pnpm --filter @homarr/docs build
run: bun run turbo run build --filter=@homarr/docs
env:
CI: "true"
- name: Validate exported links, search, and metadata
run: |
pnpm --filter @homarr/docs validate:links
pnpm --filter @homarr/docs verify:search
pnpm --filter @homarr/docs verify:seo
bun run --filter @homarr/docs validate:links
bun run --filter @homarr/docs verify:search
bun run --filter @homarr/docs verify:seo
+54 -7
View File
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- ".github/workflows/workshop.yml"
- "tooling/github/**"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/common/**"
@@ -20,14 +21,14 @@ on:
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "bun.lock"
- "bunfig.toml"
- "turbo.json"
- ".npmrc"
push:
branches: [dev, v2, release/v2, main]
paths:
- ".github/workflows/workshop.yml"
- "tooling/github/**"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/common/**"
@@ -44,10 +45,9 @@ on:
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "bun.lock"
- "bunfig.toml"
- "turbo.json"
- ".npmrc"
permissions:
contents: read
@@ -72,10 +72,44 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 24.18.0
- name: Build and exercise the combined production image
- name: Set up Docker Buildx
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Build the production image
if: "!env.ACT"
uses: docker/build-push-action@v7
with:
context: .
file: apps/workshop/Dockerfile
target: production
platforms: linux/${{ matrix.arch }}
load: true
tags: homarr-workshop:smoke
cache-from: type=gha,scope=workshop-linux-${{ matrix.arch }}
cache-to: type=gha,scope=workshop-linux-${{ matrix.arch }},mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
- name: Exercise the production image
if: "!env.ACT"
env:
WORKSHOP_IMAGE_TEST_IMAGE: homarr-workshop:smoke
run: sh apps/workshop/test-image.sh
- name: Build and exercise the production image with Act
if: env.ACT
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
run: sh apps/workshop/test-image.sh
validate:
@@ -83,6 +117,7 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
publish-amd64:
@@ -97,6 +132,7 @@ jobs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
@@ -113,6 +149,11 @@ jobs:
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=workshop-linux-amd64
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
publish-arm64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
@@ -126,6 +167,7 @@ jobs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
@@ -142,6 +184,11 @@ jobs:
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=workshop-linux-arm64
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
publish:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
+3 -3
View File
@@ -2,7 +2,7 @@
# dependencies
node_modules
.pnpm-store/
.bun-cache/
.pnp
.pnp.js
.idea/
@@ -36,7 +36,7 @@ tools/homarr-dev/homarr-dev
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
bun-debug.log*
# local env files
.env
@@ -81,7 +81,7 @@ skills-lock.json
# docusaurus
apps/docs/.docusaurus
apps/docs/build
.pnpm-store
.bun-cache
benchmark-results
# local performance investigation (never ship benchmark data or restored backups)
+2 -2
View File
@@ -1,8 +1,8 @@
{
"mcpServers": {
"mantine": {
"command": "npx",
"args": ["-y", "@mantine/mcp-server@9.6.0"]
"command": "bun",
"args": ["x", "@mantine/mcp-server@9.6.0"]
}
}
}
-2
View File
@@ -1,2 +0,0 @@
[tools]
node = "24.18.0"
-2
View File
@@ -1,2 +0,0 @@
# pnpm v11: non-auth settings moved to pnpm-workspace.yaml
# Keep this file for any future registry/auth configuration
+2 -7
View File
@@ -26,16 +26,11 @@
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/npm",
{
"npmPublish": false
}
],
"./scripts/release-version.mjs",
[
"@semantic-release/git",
{
"assets": ["package.json", "CHANGELOG.md"],
"assets": ["package.json", "bun.lock", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
@@ -6,7 +6,7 @@
<script value="db:migration:postgresql:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="db:migration:sqlite:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="db:push" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="db:studio" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="docker:dev" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="format" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="format:fix" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="test" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="test:ui" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+1 -1
View File
@@ -6,7 +6,7 @@
<script value="typecheck" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<package-manager value="bun" />
<envs />
<method v="2" />
</configuration>
+9 -9
View File
@@ -5,7 +5,7 @@
"name": "dev",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"command": "bun run dev",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -13,7 +13,7 @@
"name": "docker dev",
"type": "node-terminal",
"request": "launch",
"command": "pnpm docker:dev",
"command": "bun run docker:dev",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -21,7 +21,7 @@
"name": "lint fix",
"type": "node-terminal",
"request": "launch",
"command": "pnpm lint:fix",
"command": "bun run lint:fix",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -29,7 +29,7 @@
"name": "format fix",
"type": "node-terminal",
"request": "launch",
"command": "pnpm format:fix",
"command": "bun run format:fix",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -37,7 +37,7 @@
"name": "db push",
"type": "node-terminal",
"request": "launch",
"command": "pnpm db:push",
"command": "bun run db:push",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -45,7 +45,7 @@
"name": "db studio",
"type": "node-terminal",
"request": "launch",
"command": "pnpm db:studio",
"command": "bun run db:studio",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -53,7 +53,7 @@
"name": "db migration run",
"type": "node-terminal",
"request": "launch",
"command": "pnpm db:migration:run",
"command": "bun run db:migration:sqlite:run",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -61,7 +61,7 @@
"name": "test",
"type": "node-terminal",
"request": "launch",
"command": "pnpm test",
"command": "bun run test",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
@@ -69,7 +69,7 @@
"name": "test ui",
"type": "node-terminal",
"request": "launch",
"command": "pnpm test:ui",
"command": "bun run test:ui",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
}
+23 -12
View File
@@ -73,15 +73,15 @@ Only after this user-need test passes, use these locations:
## Monorepo Commands
- `pnpm dev` — Next.js app only
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
- `pnpm db:seed` — seed default database data explicitly
- `pnpm docker:dev:up` — start the Redis development service in the background
- `pnpm dev:docs` — Fumadocs site only
- `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only
- `pnpm turbo typecheck` — typecheck all packages
- `pnpm lint` / `pnpm format` — oxlint / oxfmt
- `bun run dev` — Next.js app only
- `bun run dev:cli -- dev` — run the developer CLI without installing a global binary
- `bun run db:seed` — seed default database data explicitly
- `bun run docker:dev:up` — start the Redis development service in the background
- `bun run dev:docs` — Fumadocs site only
- `bun run turbo build` — build all packages
- `bun run turbo build --filter=@homarr/docs` — build docs only
- `bun run turbo typecheck` — typecheck all packages
- `bun run lint` / `bun run format` — oxlint / oxfmt
## Code Style
@@ -91,9 +91,9 @@ Only after this user-need test passes, use these locations:
- Mantine: use the `mantine` MCP server in `.mcp.json` for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox`, the polymorphic `component` prop, `@mantine/hooks`) and check `packages/ui/` for existing conventions first.
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run `bun run dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `bun run dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `bun run dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
## Testing
@@ -111,3 +111,14 @@ Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before w
- `documentation-sync` — user-critical documentation only
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
<!-- BEGIN:turborepo-agent-rules -->
# This is NOT the Turborepo you know
Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`.
Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access.
This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. When agent guidance is enabled, keep the block committed to avoid an uncommitted change on the next agent invocation. This repository disables agent guidance in `turbo.json`.
<!-- END:turborepo-agent-rules -->
+24 -62
View File
@@ -1,100 +1,62 @@
# syntax=docker/dockerfile:1.25
FROM oven/bun:1.4.2-alpine AS bun-tool
FROM node:24.18.0-alpine AS base
FROM base AS builder
ARG TARGETPLATFORM
WORKDIR /app
# Native dependencies normally use prebuilds, but the build must remain
# reproducible when a prebuild download times out and compilation is required.
RUN apk add --no-cache libc6-compat curl bash python3 make g++
RUN corepack enable pnpm
COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY --from=bun-tool /usr/local/bin/bun /usr/local/bin/bun
# Compiler tools stay in the builder for optional native dependency fallbacks.
RUN apk add --no-cache libstdc++ python3 make g++
COPY bun.lock bunfig.toml package.json ./
COPY patches ./patches
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
# @homarr/definitions generates documentation types during install.
COPY --parents ./packages/definitions/src ./
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch --ignore-scripts && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
# Install only from the fetched, committed lockfile so local and Docker builds
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
RUN --mount=type=cache,id=homarr-bun-cache,target=/root/.bun/install/cache,sharing=locked \
npm_config_nodedir=/usr/local bun install --frozen-lockfile --concurrent-scripts=1
COPY . .
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
--mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
--mount=type=cache,id=homarr-turbo-${TARGETPLATFORM},target=/app/.turbo,sharing=locked \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}/musl/node-24.18.0" \
bun run turbo run build --filter=@homarr/nextjs... --filter=@homarr/cli
FROM base AS runner
FROM alpine:3.24.1 AS runner
WORKDIR /app
# gettext is required for envsubst, openssl for generating AUTH_SECRET, su-exec for running application as non-root
RUN apk add --no-cache redis nginx bash gettext su-exec openssl
RUN mkdir /appdata
COPY --from=base /usr/local/bin/node /usr/local/bin/node
# envsubst, privilege drop and AUTH_SECRET generation are used by the entrypoint.
RUN apk add --no-cache libstdc++ ca-certificates redis nginx bash gettext su-exec openssl && \
mkdir -p /appdata /var/cache/nginx /var/log/nginx /var/lib/nginx \
/run/nginx /etc/nginx/templates /etc/nginx/ssl/certs && \
touch /run/nginx/nginx.pid
VOLUME /appdata
# Enable homarr cli
COPY --from=builder /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
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
RUN mkdir -p /var/cache/nginx && \
mkdir -p /var/log/nginx && \
mkdir -p /var/lib/nginx && \
touch /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
# Bundled CLI/migrations resolve the native binding from the application root.
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
RUN printf '#!/bin/sh\ncd /app/apps/cli && exec node ./cli.cjs "$@"\n' > /usr/bin/homarr && \
chmod +x /usr/bin/homarr
COPY --from=builder /app/packages/db/migrations ./db/migrations
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder /app/apps/nextjs/.next/standalone ./
COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public
# Ship only Next's traced production dependencies and application assets.
COPY --from=builder /app/apps/nextjs/.output/standalone ./
COPY scripts/run.sh ./run.sh
COPY --chmod=755 scripts/entrypoint.sh ./entrypoint.sh
COPY packages/redis/redis.conf /app/redis.conf
COPY nginx.conf /etc/nginx/templates/nginx.conf
ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
EXPOSE 7575
ENTRYPOINT [ "/app/entrypoint.sh" ]
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["sh", "run.sh"]
+1 -1
View File
@@ -5,7 +5,7 @@
/node_modules
/.pnp
.pnp.js
.pnpm-store/
.bun-cache/
# testing
/coverage
+10 -10
View File
@@ -7,23 +7,23 @@ The source for [homarr.dev](https://homarr.dev), built with Next.js and Fumadocs
From the repository root:
```bash
pnpm dev:docs
bun run dev:docs
```
The site runs on `http://127.0.0.1:3003`. To use a local Workshop API:
```bash
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
WORKSHOP_API_URL=http://127.0.0.1:8090 bun run dev:docs
```
## Validate
```bash
pnpm --filter @homarr/docs typecheck
pnpm --filter @homarr/docs build
pnpm --filter @homarr/docs validate:links
pnpm --filter @homarr/docs verify:search
pnpm --filter @homarr/docs verify:seo
bun run --filter @homarr/docs typecheck
bun run --filter @homarr/docs build
bun run --filter @homarr/docs validate:links
bun run --filter @homarr/docs verify:search
bun run --filter @homarr/docs verify:seo
```
The static export is written to `out/`. The build validates docs pages and typed metadata for integrations and widgets
@@ -54,7 +54,7 @@ Verify public item pages without changing the remote database:
```bash
REMOTE_WORKSHOP_URL=https://workshop.example.com
WORKSHOP_TEST_URL=http://127.0.0.1:8093 WORKSHOP_REMOTE_API_URL="$REMOTE_WORKSHOP_URL" \
node apps/workshop/tests/remote-workshop.integration.mjs
bun apps/workshop/tests/remote-workshop.integration.mjs
```
Stop it with `docker stop homarr-docs-static-preview`. Rebuild and restart the preview after changing source files;
@@ -132,7 +132,7 @@ The launcher uses Homarr's existing public Kapa Website ID. The root layout load
client-side navigation and follows the docs light or dark theme. **Search** and `Ctrl+K` / `Cmd+K` always use Fumadocs search, including when Kapa is unavailable.
Set `KAPA_WEBSITE_ID` at build time to use a different Kapa Website Widget integration. An explicitly empty value
disables the widget, for example `KAPA_WEBSITE_ID= pnpm --filter @homarr/docs build`. This is a public integration ID
disables the widget, for example `KAPA_WEBSITE_ID= bun run --filter @homarr/docs build`. This is a public integration ID
included in exported pages, not a Kapa API key. Rebuild the static export after changing it.
In the Kapa dashboard, confirm the integration is live and enable the production and preview domains. Restoring the
@@ -164,5 +164,5 @@ Carbon loads one visible placement after the desktop TOC, below API examples, or
The homepage is excluded at every viewport size. Navigation reloads the ad script; resizing changes placement only when crossing its breakpoint. Ad blockers
or no-fill responses must not prevent content, navigation, or search from working.
To preview Carbon locally, start docs with `NEXT_PUBLIC_ENABLE_CARBON_ADS=true pnpm dev:docs`. Production builds
To preview Carbon locally, start docs with `NEXT_PUBLIC_ENABLE_CARBON_ADS=true bun run dev:docs`. Production builds
enable ads automatically. When the TOC collapses into a toggle, the ad appears below the content before “Edit this page”.
@@ -1,8 +1,8 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `bun run dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
To install an optional `homarr` binary, run `bun run dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
## Requirements
@@ -14,7 +14,7 @@ To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your
## Interactive Development Browser
```bash
pnpm dev:cli --
bun run dev:cli --
# or with global binary:
homarr
```
@@ -66,7 +66,7 @@ Pulls and builds run asynchronously in the background. Press `t` to open the tas
```bash
homarr dash
# or
pnpm dev:cli -- dash
bun run dev:cli -- dash
```
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
@@ -81,7 +81,7 @@ The instance dashboard displays all running and stopped Homarr containers, publi
## CLI Commands
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `bun run dev:cli --` (for example, `bun run dev:cli -- list`).
### Launch Instances
@@ -133,7 +133,7 @@ homarr ci 6441 --watch
homarr shell homarr-dev
# Run specific command in container
homarr shell homarr-dev pnpm db:migration:sqlite:run
homarr shell homarr-dev bun run db:migration:sqlite:run
```
### Image & Volume Management
@@ -50,20 +50,20 @@ The repository's host-side CLI can build the current checkout and run it as a na
![Homarr developer CLI showing pull requests, local images, and PR CI status](./img/homarr-dev-cli.png)
```sh
pnpm cli harness setup feature-board
bun run cli harness setup feature-board
```
This creates `homarr:feature-board`, starts it with persistent demo data, and prints the assigned URL. Query the port
again with:
```sh
pnpm cli harness port feature-board
bun run cli harness port feature-board
```
Pass environment overrides with repeated `--env` options:
```sh
pnpm cli harness setup feature-board \
bun run cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
@@ -12,22 +12,22 @@ sidebar_position: 1
### Prerequisites
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm 11 version.
- **Node.js 24.18.0**: application and tooling runtime, pinned in `mise.toml`.
- **Bun 1.4.2**: Install the version pinned in `mise.toml` from the [official website](https://bun.sh/docs/installation).
- **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
The versions in the root `package.json` are the supported baseline. Let Corepack select the repository version instead
of installing an unrelated global pnpm:
Install dependencies once at the repository root. Bun uses a hoisted workspace installation, so compatible dependencies
are shared across packages. Catalog versions, patches and overrides live in the root `package.json`.
```sh
corepack enable
corepack install
pnpm install --frozen-lockfile
bun install --frozen-lockfile
```
Next.js, operational commands and SQLite run on Node.js. Bun installs dependencies and dispatches package scripts.
### Run the Homarr application
1. Copy `.env.example` to `.env`.
@@ -46,30 +46,30 @@ cp .env.example .env
Terminal 1:
```sh
pnpm docker:dev
bun run docker:dev
```
Terminal 2:
```sh
pnpm db:migration:sqlite:run
pnpm dev
bun run db:migration:sqlite:run
bun run dev
```
The infrastructure command remains in the foreground. Open `http://127.0.0.1:3000`; the first user created through
Homarr becomes the instance administrator.
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
default data; use `pnpm db:seed` to seed it explicitly later.
Use `bun run docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
default data; use `bun run db:seed` to seed it explicitly later.
`pnpm dev` starts only Next.js. When your work needs scheduled tasks or live subscriptions, run the relevant service
`bun run dev` starts only Next.js. When your work needs scheduled tasks or live subscriptions, run the relevant service
in a separate terminal alongside it:
```sh
pnpm --filter @homarr/tasks dev
bun run --filter @homarr/tasks dev
```
```sh
pnpm --filter @homarr/websocket dev
bun run --filter @homarr/websocket dev
```
The WebSocket service listens on port 3001. Production startup embeds both services, so these extra commands are
@@ -80,44 +80,44 @@ Workshop contributors can set up PocketBase and Fumadocs with the dedicated
### Useful commands
- `pnpm dev` — Next.js on port 3000.
- `pnpm dev:benchmark` — authenticated-board benchmark; readiness-only `--smoke` runs are not performance claims.
- `pnpm dev:cli -- dev` — browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>` — build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>` — build a pull request from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>` — rebuild an image from its recorded checkout or pull request.
- `pnpm dev:cli:install` — optionally install the developer CLI as a `homarr` binary.
- `pnpm dev:docs` — Fumadocs on port 3003.
- `pnpm docker:dev:up` — Redis development service in the background.
- `pnpm cli` — Homarr operations CLI.
- `pnpm db:migration:sqlite:run` — create or update a SQLite database.
- `pnpm db:seed` — seed the default database data explicitly.
- `pnpm db:studio` — inspect the database.
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
- `pnpm build` — production monorepo build.
- `bun run dev` — Next.js on port 3000.
- `bun run dev:benchmark` — authenticated-board benchmark; readiness-only `--smoke` runs are not performance claims.
- `bun run dev:cli -- dev` — browse and run local images and remote pull-request images.
- `bun run dev:cli -- build <name>` — build the current checkout as `homarr:<name>`.
- `bun run dev:cli -- build --pr <number>` — build a pull request from a temporary checkout.
- `bun run dev:cli -- rebuild <name>` — rebuild an image from its recorded checkout or pull request.
- `bun run dev:cli:install` — optionally install the developer CLI as a `homarr` binary.
- `bun run dev:docs` — Fumadocs on port 3003.
- `bun run docker:dev:up` — Redis development service in the background.
- `bun run cli` — Homarr operations CLI.
- `bun run db:migration:sqlite:run` — create or update a SQLite database.
- `bun run db:seed` — seed the default database data explicitly.
- `bun run db:studio` — inspect the database.
- `bun run format` / `bun run format:fix` — check or apply oxfmt.
- `bun run lint` / `bun run lint:fix` — check or apply oxlint.
- `bun run typecheck` — typecheck all workspaces.
- `bun run test` — unit and contract suites.
- `bun run build` — production monorepo build.
- `docker build -t homarr:local .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:local` — run it.
## CI and focused validation
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
The main CI workflow validates SQLite and PostgreSQL migrations before lint, typechecking, affected workspace builds, workspace configuration, OpenAPI, and Custom Widget architecture and bundle checks. Manual container validation builds an amd64 image and exercises MySQL conversion, authentication, and restart persistence. Preview publication requires the Fast gate and both architecture builds.
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
Workshop CI runs when its workflow or Docker build inputs change. Pull requests exercise production images on amd64 and arm64; release branches publish them after Compose validation. Workspace typechecks run in the main Fast gate.
Choose the smallest relevant suite when changing behavior:
| Command | Scope | Requirements |
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
| Command | Scope | Requirements |
| ------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `bun run test <file>` | Focused unit or contract tests | Workspace dependencies |
| `bun run test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
| `bun run test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
| `bun run test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
| `bun run test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
| `bun run test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
| `bun run test:workshop` / `bun run test:workshop-image` | Workshop integration / production-image validation | Docker |
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
@@ -125,17 +125,17 @@ Keep behavioral coverage for authorization, migrations, persistence, and widget
## Documentation development
Run `pnpm dev:docs` to open the Next.js and Fumadocs site at `http://127.0.0.1:3003`. Content lives in
Run `bun run dev:docs` to open the Next.js and Fumadocs site at `http://127.0.0.1:3003`. Content lives in
`apps/docs/docs/`; the nearest `meta.json` controls navigation. Keep integration and widget setup metadata in their
typed `index.ts` files alongside the page.
After changing content, navigation, or shared MDX components, run:
```sh
pnpm --filter @homarr/docs build
pnpm --filter @homarr/docs validate:links
pnpm --filter @homarr/docs verify:search
pnpm --filter @homarr/docs verify:seo
bun run --filter @homarr/docs build
bun run --filter @homarr/docs validate:links
bun run --filter @homarr/docs verify:search
bun run --filter @homarr/docs verify:seo
```
The build writes a static site to `apps/docs/out/`. Check heading links, search results, and the page's Markdown export
@@ -22,7 +22,7 @@ docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml
Start Fumadocs in another terminal:
```sh
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
WORKSHOP_API_URL=http://127.0.0.1:8090 bun run dev:docs
```
PocketBase runs at `http://127.0.0.1:8090`; create the first superuser under `/_/`. Workshop is available at
+7 -1
View File
@@ -4,7 +4,13 @@ title: Move from MySQL to SQLite
# Move from MySQL to SQLite
Homarr v2 supports SQLite (the default) and PostgreSQL. MySQL installations must convert before upgrading to v2.
Homarr v2 supports SQLite (the default) and PostgreSQL. MySQL and MariaDB are no longer supported; migrate before upgrading to v2. Changing the database connection settings alone does not move your existing data.
## Choose a database
Use SQLite for an instance with its own persistent database volume. Do not synchronize a running SQLite database between Swarm nodes with tools such as Syncthing.
For a shared database in a multi-node deployment, use PostgreSQL. The converter below targets **SQLite only**; Homarr does not currently provide a MySQL-to-PostgreSQL converter. PostgreSQL connection settings are documented under [environment variables](/docs/advanced/environment-variables#database), but those settings do not import your MySQL data. Keep your previous Homarr image and database available until you have a validated migration plan.
The standalone converter is tested against the **Homarr v1.77.1 database schema** and writes its corresponding SQLite database. Homarr v2 applies its normal migrations when it first opens that file. The converter does not modify the source database.
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.31.1](https://img.shields.io/badge/Version-8.31.1-informational?style=flat)
![Version: 8.32.0](https://img.shields.io/badge/Version-8.32.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v2.1.2](https://img.shields.io/badge/AppVersion-v2.1.2-informational?style=flat)
![AppVersion: v2.2.0](https://img.shields.io/badge/AppVersion-v2.2.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -12,7 +12,7 @@ A Helm chart to deploy homarr for Kubernetes
## Source Code
* <https://github.com/homarr-labs/homarr>
- <https://github.com/homarr-labs/homarr>
## Requirements
@@ -76,38 +76,38 @@ To avoid including sensitive information in plain text within your version contr
Below is an exhaustive list of all secrets:
| FEATURE | SECRET NAME | SECRET KEYS | Required |
|-----------|-------------------------|-----------------------------------------|----------|
| OIDC | auth-oidc-secret | oidc-client-id<br>oidc-client-secret | No |
| LDAP | auth-ldap-secret | bind-password | No |
| DATABASE | db-secret | db-url | No |
| DATABASE | db-encryption | db-encryption-key | yes |
| FEATURE | SECRET NAME | SECRET KEYS | Required |
| -------- | ---------------- | ------------------------------------ | -------- |
| OIDC | auth-oidc-secret | oidc-client-id<br>oidc-client-secret | No |
| LDAP | auth-ldap-secret | bind-password | No |
| DATABASE | db-secret | db-url | No |
| DATABASE | db-encryption | db-encryption-key | yes |
### Database
You have multiple options for configuring the database:
| DRIVER TYPE | Persistence mode |
|----------------|---------------------------------|
| better-sqlite3 | Pod disk |
| better-sqlite3 | homarr-database PVC |
| mysql2 | External MySql database |
| node-postgres | External Postgresql database |
| DRIVER TYPE | Persistence mode |
| -------------- | ---------------------------- |
| better-sqlite3 | Pod disk |
| better-sqlite3 | homarr-database PVC |
| mysql2 | External MySql database |
| node-postgres | External Postgresql database |
#### Pod disk
No additional configuration is required. However, keep in mind that if the pod restarts, all data will be lost. This setup is not *recommended* for production use.
No additional configuration is required. However, keep in mind that if the pod restarts, all data will be lost. This setup is not _recommended_ for production use.
To create the necessary database secret, execute the following command:
<details>
<summary>Required Secrets</summary>
````yaml
```yaml
kubectl create secret generic db-encryption \
--from-literal=db-encryption-key='<SECRET_ENCRYPTION_KEY_SECRET_TO_CHANGE>' \
--namespace homarr
````
```
</details>
@@ -120,11 +120,11 @@ Associated secret to create :
<details>
<summary>DB Required Secrets</summary>
````yaml
```yaml
kubectl create secret generic db-encryption \
--from-literal=db-encryption-key='<SECRET_ENCRYPTION_KEY_SECRET_TO_CHANGE>' \
--namespace homarr
````
```
</details>
@@ -133,13 +133,13 @@ Bellow an example of the override value file :
<details>
<summary>values.yaml</summary>
````yaml
```yaml
persistence:
homarrDatabase:
enabled: true
storageClassName: "default"
size: "1Gi"
````
```
</details>
@@ -150,17 +150,18 @@ To create the necessary database secrets, execute the following command:
<details>
<summary>Required Secrets</summary>
````yaml
```yaml
kubectl create secret generic db-encryption \
--from-literal=db-encryption-key='<SECRET_ENCRYPTION_KEY_SECRET_TO_CHANGE>' \
--namespace homarr
````
```
````yaml
```yaml
kubectl create secret generic db-secret \
--from-literal=db-url='mysql://user:password@host:port/homarrdb' \
--namespace homarr
````
```
</details>
Below is an example of the override values file:
@@ -168,10 +169,11 @@ Below is an example of the override values file:
<details>
<summary>values.yaml</summary>
````yaml
```yaml
database:
type: mysql
````
```
</details>
#### External Postgresql database
@@ -181,17 +183,17 @@ To create the necessary database secrets, execute the following command:
<details>
<summary>Required Secrets</summary>
````yaml
```yaml
kubectl create secret generic db-encryption \
--from-literal=db-encryption-key='<SECRET_ENCRYPTION_KEY_SECRET_TO_CHANGE>' \
--namespace homarr
````
```
````yaml
```yaml
kubectl create secret generic db-secret \
--from-literal=db-url='postgresql://user:password@host:port/homarrdb' \
--namespace homarr
````
```
</details>
@@ -200,10 +202,11 @@ Below is an example of the override values file:
<details>
<summary>values.yaml</summary>
````yaml
```yaml
database:
type: postgresql
````
```
</details>
### Ingress
@@ -213,7 +216,7 @@ The ingress section in the values.yaml file allows you to configure how external
<details>
<summary>values.yaml</summary>
````yaml
```yaml
service:
enabled: true # Ensure the service is enabled for Ingress to route traffic
ingress:
@@ -231,7 +234,8 @@ ingress:
- "homarr.homelab.dev"
- "www.homarr.homelab.dev"
secretName: homelab-tls
````
```
</details>
### HTTPRoute (Gateway API)
@@ -242,9 +246,9 @@ This provides a more expressive and future-proof alternative to Ingress, with su
<details>
<summary>values.yaml</summary>
````yaml
```yaml
service:
enabled: true # must be enabled for HTTPRoute to forward traffic
enabled: true # must be enabled for HTTPRoute to forward traffic
httproute:
enabled: true
parentRefs:
@@ -260,14 +264,15 @@ httproute:
backendRefs:
- name: homarr
port: 7575
````
```
</details>
#### Notes:
- parentRefs: the Gateway(s) this route attaches to. Gateways must already exist.
- hostnames: domain names this route applies to.
- rules: each rule can define matches (paths, headers, queries), optional filters, and backendRefs to services
- rules: each rule can define matches (paths, headers, queries), optional filters, and backendRefs to services
- TLS must be configured on the Gateway (not on HTTPRoute). For example, the Gateway can have a TLS listener for homarr.homelab.dev, and this route will automatically apply once matched.
### Certificates
@@ -284,7 +289,7 @@ Use inline certificates to generate a ConfigMap, mounted as individual files.
<details>
<summary>values.yaml</summary>
````yaml
```yaml
persistence:
homarrTrustedCerts:
enabled: true
@@ -298,7 +303,8 @@ persistence:
-----BEGIN CERTIFICATE-----
MIID...EFGH==
-----END CERTIFICATE-----
````
```
</details>
Behavior:
@@ -313,7 +319,7 @@ Use inline certificates to generate a Kubernetes Secret, mounted as files.
<details>
<summary>values.yaml</summary>
````yaml
```yaml
persistence:
homarrTrustedCerts:
enabled: true
@@ -327,7 +333,8 @@ persistence:
-----BEGIN CERTIFICATE-----
MIIC...XYZ==
-----END CERTIFICATE-----
````
```
</details>
Behavior:
@@ -342,16 +349,17 @@ Use an existing Kubernetes Secret, assuming its keys are filenames.
<details>
<summary>values.yaml</summary>
````yaml
```yaml
persistence:
homarrTrustedCerts:
enabled: true
type: existingSecret
existingSecretName: "existingSecretName"
existingSecretKeys:
- cert3.crt
- cert4.crt
````
- cert3.crt
- cert4.crt
```
</details>
Behavior:
@@ -372,114 +380,115 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
## Values
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| additionalObjects | list | `[]` | Additional resources to deploy. These objects are templated. |
| affinity | object | `{}` | Node affinity for pod scheduling |
| autoscaling.enabled | bool | `false` | Enable autoscaling |
| autoscaling.maxReplicas | int | `100` | Maximum replicas |
| autoscaling.minReplicas | int | `1` | Minimum replicas |
| autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization for autoscaling |
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
| env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user's groups search filter (& based) |
| env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE | string | `"member"` | Attribute used for querying group member |
| env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE | string | `"dn"` | User attribute used for comparing with group member |
| env.AUTH_LDAP_SEARCH_SCOPE | string | `"base"` | LDAP search scope between base, one or sub |
| env.AUTH_LDAP_URI | string | `nil` | URI of your LDAP server |
| env.AUTH_LDAP_USERNAME_ATTRIBUTE | string | `"uid"` | Attribute used for username |
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
| env.AUTH_OIDC_ISSUER | string | `nil` | Issuer URI of OIDC provider without trailing slash (/) |
| env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE | string | `nil` | Overwrite name attribute. By default, it will use preferred_username if it does not contain a @ and otherwise name. |
| env.AUTH_OIDC_SCOPE_OVERWRITE | string | `"openid email profile groups"` | Override the OIDC scopes |
| env.AUTH_PROVIDERS | string | `"credentials"` | Enabled authentication methods. Multiple providers can be enabled with by separating them with , (ex. AUTH_PROVIDERS=credentials,oidc, it is highly recommended to just enable one provider). |
| env.AUTH_SESSION_EXPIRY_TIME | string | `"30d"` | Time for the session to time out. Can be set as pure number, which will automatically be used in seconds, or followed by s, m, h or d for seconds, minutes, hours or days. (ex: "30m") |
| env.ENABLE_DNS_CACHING | string | `"false"` | Enables dns caching. This is not yet working for all users. See #4006 |
| env.LOG_LEVEL | string | `"info"` | Log level to use. Possible values: debug/info/warn/error |
| env.NO_EXTERNAL_CONNECTION | string | `"false"` | Disables some requests that need internet connection |
| env.TZ | string | `"Europe/Paris"` | Your local time zone |
| envSecrets.authLdapCredentials.existingSecret | string | `"auth-ldap-secret"` | Name of existing secret containing LDAP credentials |
| envSecrets.authLdapCredentials.ldapBindingPassword | string | `"bind-password"` | Password for bind user secret key |
| envSecrets.authOidcCredentials.existingSecret | string | `"auth-oidc-secret"` | Name of existing secret containing OIDC credentials |
| envSecrets.authOidcCredentials.oidcClientId | string | `"oidc-client-id"` | ID of OIDC client (application) secret key |
| envSecrets.authOidcCredentials.oidcClientSecret | string | `"oidc-client-secret"` | Secret of OIDC client (application) secret key |
| envSecrets.dbCredentials.dbUrlKey | string | `"db-url"` | Secret key for DB_URL Example for external database: `mysql://username:password@host:port/homarrdb` or `postgresql://username:password@host:port/homarrdb` |
| envSecrets.dbCredentials.existingSecret | string | `"db-secret"` | Name of existing secret containing DB credentials |
| envSecrets.dbEncryption.existingSecret | string | `"db-encryption"` | Name of existing secret containing DB encryption |
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
| fullnameOverride | string | `""` | Overrides chart's fullname |
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute.enabled | bool | `false` | Enable HTTPRoute |
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v2.1.2"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
| ingress.hosts | list | `[{"host":"chart-example.local","paths":[{"path":"/"}]}]` | Ingress hosts configuration |
| ingress.ingressClassName | string | `""` | Ingress class name |
| ingress.tls | list | `[]` | Ingress TLS configuration |
| livenessProbe.failureThreshold | int | `3` | Failure threshold for liveness probe - number of consecutive failures before pod is restarted |
| livenessProbe.httpGet.path | string | `"/api/health/live"` | This is the liveness check endpoint used by Kubernetes to determine if the application is still running. |
| livenessProbe.httpGet.port | int | `7575` | The port on which the liveness check will be performed. This must be the same as the container port exposed by the application. |
| livenessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the liveness probe starts |
| livenessProbe.periodSeconds | int | `10` | Period in seconds between liveness probe checks |
| livenessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each liveness probe check |
| nameOverride | string | `""` | Overrides chart's name |
| nodeSelector | object | `{}` | Node selectors for pod scheduling |
| persistence.homarrDatabase.accessMode | string | `"ReadWriteOnce"` | homarr-database access mode |
| persistence.homarrDatabase.enabled | bool | `false` | Enable homarr-database persistent storage |
| persistence.homarrDatabase.mountPath | string | `"/appdata"` | homarr-database mount path inside the pod |
| persistence.homarrDatabase.name | string | `"homarr-database"` | homarr-database persistent storage name |
| persistence.homarrDatabase.size | string | `"50Mi"` | homarr-database storage size |
| persistence.homarrDatabase.storageClassName | string | `"local-path"` | homarr-database storage class name |
| persistence.homarrDatabase.volumeClaimName | string | `""` | homarr-database optional volumeClaimName to target specific PV |
| persistence.homarrTrustedCerts.certificates | string | `nil` | homarr-trusted-certificates certificates, each entry will become a new trusted certificate as a dedicated file (works only for "configmap" and "secret" mode) |
| persistence.homarrTrustedCerts.enabled | bool | `false` | Enable trusted certificates persistence |
| persistence.homarrTrustedCerts.existingSecretKeys | string | `nil` | List of keys (filenames) to mount from the existing secret (used only when type is "existingSecret") |
| persistence.homarrTrustedCerts.existingSecretName | string | `""` | Name of the existing Kubernetes Secret to mount (required if type is "existingSecret") |
| persistence.homarrTrustedCerts.mountPath | string | `"/appdata/trusted-certificates"` | homarr-trusted-certificates mount path inside the pod |
| persistence.homarrTrustedCerts.type | string | `"configmap"` | Persistence mode can be : configmap (declarative), secret (declarative) or existingSecret (mount an existing Kubernetes Secret by name and specify which keys to mount as files) |
| podAnnotations | object | `{}` | Pod annotations |
| podLabels | object | `{}` | Pod labels |
| podSecurityContext | object | `{}` | Pod security context |
| rbac | object | `{"enabled":false}` | Enable RBAC resources for Kubernetes integration Creates Role, ClusterRole, and associated bindings for Homarr's Kubernetes features |
| rbac.enabled | bool | `false` | Enable to create RBAC resources and activate Kubernetes integration |
| readinessProbe.failureThreshold | int | `3` | Failure threshold for readiness probe - number of consecutive failures before pod is considered unready |
| readinessProbe.httpGet.path | string | `"/api/health/ready"` | This is the readiness check endpoint used by Kubernetes to determine if the application is ready to handle traffic. |
| readinessProbe.httpGet.port | int | `7575` | The port on which the readiness check will be performed. This must match the container's exposed port. |
| readinessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the readiness probe starts. increase this value if the pod is slow to fully start. |
| readinessProbe.periodSeconds | int | `10` | Period in seconds between readiness probe checks |
| readinessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each readiness probe check |
| replicaCount | int | `1` | Number of replicas |
| resources | object | `{}` | Resource configuration |
| securityContext | object | `{}` | Security context |
| service.enabled | bool | `true` | Enable service |
| service.ipFamilies | list | `[]` | List of IP families to use for the service. Examples: - ["IPv4"] - ["IPv6"] - ["IPv4", "IPv6"] for dual-stack Leave empty to use cluster default behavior |
| service.ipFamilyPolicy | string | `"SingleStack"` | Defines how the service assigns IP families (IPv4/IPv6) Possible values: - SingleStack (default): Only one IP family, usually IPv4 - PreferDualStack: Use dual-stack if the cluster supports it, fallback to single - RequireDualStack: Fail if dual-stack cannot be assigned |
| service.ports.app.port | int | `7575` | Service port |
| service.ports.app.protocol | string | `"TCP"` | Service protocol |
| service.ports.app.targetPort | string | `"http"` | Service target port |
| service.type | string | `"ClusterIP"` | Service type |
| strategyType | string | `"RollingUpdate"` | `strategyType` specifies the strategy used to replace old Pods by new ones. `strategyType` can be `"Recreate"` or `"RollingUpdate"`. `"RollingUpdate"` is the default value and updates Pods in a rolling update fashion. `"Recreate"` will kill all existing Pods before new ones are created. The `"Recreate"` strategy is necessary when persistent volume's `accessMode` is set to `"ReadWriteOnce"` when using `helm upgrade`, as pod volume attachments to an existing PersistentVolumeClaim need to be cleared before a new pod can attach to it. |
| tolerations | list | `[]` | Node tolerations for pod scheduling |
| Key | Type | Default | Description |
| -------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| additionalObjects | list | `[]` | Additional resources to deploy. These objects are templated. |
| affinity | object | `{}` | Node affinity for pod scheduling |
| autoscaling.enabled | bool | `false` | Enable autoscaling |
| autoscaling.maxReplicas | int | `100` | Maximum replicas |
| autoscaling.minReplicas | int | `1` | Minimum replicas |
| autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization for autoscaling |
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). You can override these ports via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
| env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user's groups search filter (& based) |
| env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE | string | `"member"` | Attribute used for querying group member |
| env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE | string | `"dn"` | User attribute used for comparing with group member |
| env.AUTH_LDAP_SEARCH_SCOPE | string | `"base"` | LDAP search scope between base, one or sub |
| env.AUTH_LDAP_URI | string | `nil` | URI of your LDAP server |
| env.AUTH_LDAP_USERNAME_ATTRIBUTE | string | `"uid"` | Attribute used for username |
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
| env.AUTH_OIDC_ISSUER | string | `nil` | Issuer URI of OIDC provider without trailing slash (/) |
| env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE | string | `nil` | Overwrite name attribute. By default, it will use preferred_username if it does not contain a @ and otherwise name. |
| env.AUTH_OIDC_SCOPE_OVERWRITE | string | `"openid email profile groups"` | Override the OIDC scopes |
| env.AUTH_PROVIDERS | string | `"credentials"` | Enabled authentication methods. Multiple providers can be enabled with by separating them with , (ex. AUTH_PROVIDERS=credentials,oidc, it is highly recommended to just enable one provider). |
| env.AUTH_SESSION_EXPIRY_TIME | string | `"30d"` | This value sets the session timeout. You can specify a number in seconds or append s, m, h or d for seconds, minutes, hours or days (for example, "30m"). |
| env.ENABLE_DNS_CACHING | string | `"false"` | Enables dns caching. This is not yet working for all users. See #4006 |
| env.LOG_LEVEL | string | `"info"` | Log level to use. Possible values: debug/info/warn/error |
| env.NO_EXTERNAL_CONNECTION | string | `"false"` | Disables some requests that need internet connection |
| env.TZ | string | `"Europe/Paris"` | Your local time zone |
| envSecrets.authLdapCredentials.existingSecret | string | `"auth-ldap-secret"` | Name of existing secret containing LDAP credentials |
| envSecrets.authLdapCredentials.ldapBindingPassword | string | `"bind-password"` | Password for bind user secret key |
| envSecrets.authOidcCredentials.existingSecret | string | `"auth-oidc-secret"` | Name of existing secret containing OIDC credentials |
| envSecrets.authOidcCredentials.oidcClientId | string | `"oidc-client-id"` | ID of OIDC client (application) secret key |
| envSecrets.authOidcCredentials.oidcClientSecret | string | `"oidc-client-secret"` | Secret of OIDC client (application) secret key |
| envSecrets.dbCredentials.dbUrlKey | string | `"db-url"` | Secret key for DB_URL Example for external database: `mysql://username:password@host:port/homarrdb` or `postgresql://username:password@host:port/homarrdb` |
| envSecrets.dbCredentials.existingSecret | string | `"db-secret"` | Name of existing secret containing DB credentials |
| envSecrets.dbEncryption.existingSecret | string | `"db-encryption"` | Name of existing secret containing DB encryption |
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
| fullnameOverride | string | `""` | Overrides chart's fullname |
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute.enabled | bool | `false` | Enable HTTPRoute |
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v2.2.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
| ingress.hosts | list | `[{"host":"chart-example.local","paths":[{"path":"/"}]}]` | Ingress hosts configuration |
| ingress.ingressClassName | string | `""` | Ingress class name |
| ingress.tls | list | `[]` | Ingress TLS configuration |
| livenessProbe.failureThreshold | int | `3` | Failure threshold for liveness probe - number of consecutive failures before pod is restarted |
| livenessProbe.httpGet.path | string | `"/api/health/live"` | This is the liveness check endpoint used by Kubernetes to determine if the application is still running. |
| livenessProbe.httpGet.port | int | `7575` | The port on which the liveness check will be performed. This must be the same as the container port exposed by the application. |
| livenessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the liveness probe starts |
| livenessProbe.periodSeconds | int | `10` | Period in seconds between liveness probe checks |
| livenessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each liveness probe check |
| nameOverride | string | `""` | Overrides chart's name |
| nodeSelector | object | `{}` | Node selectors for pod scheduling |
| persistence.homarrDatabase.accessMode | string | `"ReadWriteOnce"` | homarr-database access mode |
| persistence.homarrDatabase.enabled | bool | `false` | Enable homarr-database persistent storage |
| persistence.homarrDatabase.mountPath | string | `"/appdata"` | homarr-database mount path inside the pod |
| persistence.homarrDatabase.name | string | `"homarr-database"` | homarr-database persistent storage name |
| persistence.homarrDatabase.size | string | `"50Mi"` | homarr-database storage size |
| persistence.homarrDatabase.storageClassName | string | `"local-path"` | homarr-database storage class name |
| persistence.homarrDatabase.volumeClaimName | string | `""` | homarr-database optional volumeClaimName to target specific PV |
| persistence.homarrTrustedCerts.certificates | string | `nil` | homarr-trusted-certificates certificates, each entry will become a new trusted certificate as a dedicated file (works only for "configmap" and "secret" mode) |
| persistence.homarrTrustedCerts.enabled | bool | `false` | Enable trusted certificates persistence |
| persistence.homarrTrustedCerts.existingSecretKeys | string | `nil` | List of keys (filenames) to mount from the existing secret (used only when type is "existingSecret") |
| persistence.homarrTrustedCerts.existingSecretName | string | `""` | Name of the existing Kubernetes Secret to mount (required if type is "existingSecret") |
| persistence.homarrTrustedCerts.mountPath | string | `"/appdata/trusted-certificates"` | homarr-trusted-certificates mount path inside the pod |
| persistence.homarrTrustedCerts.type | string | `"configmap"` | Persistence mode can be : configmap (declarative), secret (declarative) or existingSecret (mount an existing Kubernetes Secret by name and specify which keys to mount as files) |
| podAnnotations | object | `{}` | Pod annotations |
| podLabels | object | `{}` | Pod labels |
| podSecurityContext | object | `{}` | Pod security context |
| rbac | object | `{"enabled":false}` | Enable RBAC resources for Kubernetes integration Creates Role, ClusterRole, and associated bindings for Homarr's Kubernetes features |
| rbac.enabled | bool | `false` | Enable to create RBAC resources and activate Kubernetes integration |
| readinessProbe.failureThreshold | int | `3` | Failure threshold for readiness probe - number of consecutive failures before pod is considered unready |
| readinessProbe.httpGet.path | string | `"/api/health/ready"` | This is the readiness check endpoint used by Kubernetes to determine if the application is ready to handle traffic. |
| readinessProbe.httpGet.port | int | `7575` | The port on which the readiness check will be performed. This must match the container's exposed port. |
| readinessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the readiness probe starts. increase this value if the pod is slow to fully start. |
| readinessProbe.periodSeconds | int | `10` | Period in seconds between readiness probe checks |
| readinessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each readiness probe check |
| replicaCount | int | `1` | Number of replicas |
| resources | object | `{}` | Resource configuration |
| securityContext | object | `{}` | Security context |
| service.enabled | bool | `true` | Enable service |
| service.ipFamilies | list | `[]` | List of IP families to use for the service. Examples: - ["IPv4"] - ["IPv6"] - ["IPv4", "IPv6"] for dual-stack Leave empty to use cluster default behavior |
| service.ipFamilyPolicy | string | `"SingleStack"` | Defines how the service assigns IP families (IPv4/IPv6) Possible values: - SingleStack (default): Only one IP family, usually IPv4 - PreferDualStack: Use dual-stack if the cluster supports it, fallback to single - RequireDualStack: Fail if dual-stack cannot be assigned |
| service.ports.app.port | int | `7575` | Service port |
| service.ports.app.protocol | string | `"TCP"` | Service protocol |
| service.ports.app.targetPort | string | `"http"` | Service target port |
| service.type | string | `"ClusterIP"` | Service type |
| strategyType | string | `"RollingUpdate"` | `strategyType` specifies the strategy used to replace old Pods by new ones. `strategyType` can be `"Recreate"` or `"RollingUpdate"`. `"RollingUpdate"` is the default value and updates Pods in a rolling update fashion. `"Recreate"` will kill all existing Pods before new ones are created. The `"Recreate"` strategy is necessary when persistent volume's `accessMode` is set to `"ReadWriteOnce"` when using `helm upgrade`, as pod volume attachments to an existing PersistentVolumeClaim need to be cleared before a new pod can attach to it. |
| tolerations | list | `[]` | Node tolerations for pod scheduling |
---
Autogenerated from chart metadata using [helm-docs](https://github.com/norwoodj/helm-docs)
@@ -21,8 +21,8 @@ This method is only useful, when you want to develop on Homarr or extend it with
## Prerequisites
- [Node.js](https://nodejs.org/en/download/) 24.18 or newer
- [pnpm](https://pnpm.io/installation) at the version pinned in the repository's `package.json`
- [Node.js](https://nodejs.org/) 24.18.0 for the application runtime
- [Bun](https://bun.sh/docs/installation) 1.4.2 (the version pinned in `mise.toml`)
- [Redis](https://redis.io/download)
## Steps
@@ -35,7 +35,7 @@ This method is only useful, when you want to develop on Homarr or extend it with
Enter the created directory using <code>cd homarr</code>
</li>
<li>
Install all dependencies using <code>pnpm install</code>
Install all dependencies using <code>bun install</code>
</li>
<li>
Copy `.env.example` to `.env` and set `DB_URL` to an absolute writable path, such as
@@ -46,13 +46,9 @@ This method is only useful, when you want to develop on Homarr or extend it with
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
values. Keep both values secret and preserve `SECRET_ENCRYPTION_KEY` with your backups.
</li>
<li>Run `pnpm run db:migration:sqlite:run` and wait that it completes</li>
<li>Build the source using `pnpm build`</li>
<li>
Copy better-sqlite3.node files with `mkdir build` and `cp
./node_modules/better-sqlite3/build/Release/better_sqlite3.node ./build/better_sqlite3.node`
</li>
<li>Run `bun run db:migration:sqlite:run` and wait that it completes</li>
<li>Build the source using `bun run build`</li>
<li>Start a redis server using `redis-server`</li>
<li>Run the server using `pnpm start`</li>
<li>Run the server using `bun run start`</li>
<li>Open `http://localhost:3000` in your browser</li>
</ol>
@@ -15,7 +15,8 @@ import { piHoleIntegration } from ".";
<IntegrationHeader integration={piHoleIntegration} categories={["DNS Hole"]} />
<Alert type="note" className="my-2">
Homarr supports both Pi-hole v5 and v6. It automatically detects the version and uses the correct API.
Homarr supports both Pi-hole v5 and v6. It automatically detects the version and uses the correct API. If your reverse
proxy restricts API paths, allow the public `/api/info/login` endpoint used to detect Pi-hole v6.
</Alert>
### Widgets & Capabilities
+10
View File
@@ -10,6 +10,16 @@ Use the generated specification under **Management → Tools → API** or the [i
This page covers contracts that are not obvious from those schemas.
The in-app reference loads its schema from `/api/openapi?format=scalar` on your Homarr instance. `/api/openapi` provides the raw JSON specification.
REST exposes selected existing Homarr controls. Use tRPC for advanced operations; both accept the `ApiKey` header.
`PUT /api/boards/{id}/content` uses the dashboard editor's complete `sections` and `items` arrays. Omitted entries are
removed, so read the current content through tRPC `board.getBoardByName` before editing it. Widget movement and
resizing can instead use the existing item-layout endpoint.
Use `GET /api/users/preferences` and `PATCH /api/users/preferences` to read or change preferences. Send only changed
values: `{ "colorScheme": "light" }` or `{ "headerPreferences": { "visible": false } }`. Omitted values stay unchanged;
`null` clears nullable board/search references. Header fields merge; supplied zone arrays replace their zone.
Omit `userId` for yourself; another user requires admin permission. Older preference routes remain available.
![Homarr's API documentation page](../img/api.png)
## Authentication and browser access
@@ -57,4 +57,8 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
### Configuration
Enable **Use Pi-Hole colors** to color the four statistics red, yellow, cyan, and green with any supported DNS integration. Disable it to use the board's primary color. Both modes respect the board opacity.
Compact cards, including 1×1 widgets on scaled boards, show abbreviated values without icons or labels. Hover or focus a value to see its metric label; larger cards keep their icons and labels.
<WidgetConfig configuration={dnsHoleSummaryWidget.configuration} />
@@ -42,6 +42,12 @@ export const rssFeedWidget: WidgetDefinition = {
values: "1-50",
defaultValue: "5",
},
{
name: "Description max length",
description: "Maximum number of characters to keep from the feed description",
values: "1-5000",
defaultValue: "250",
},
{
name: "Amount posts limit",
description: "Maximum number of posts to show",
+3
View File
@@ -8,6 +8,9 @@ const config = {
allowedDevOrigins: ["127.0.0.1", "100.71.78.45"],
reactStrictMode: true,
trailingSlash: true,
experimental: {
turbopackFileSystemCacheForBuild: true,
},
images: {
unoptimized: true,
},
+3 -4
View File
@@ -5,10 +5,10 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "next dev --port 3003",
"dev": "node ../../node_modules/next/dist/bin/next dev --port 3003",
"start": "serve out --listen 3003 --config ../serve.json",
"build": "pnpm validate:coverage && next build",
"typecheck": "next typegen && tsc --noEmit",
"build": "bun run validate:coverage && node ../../node_modules/next/dist/bin/next build",
"typecheck": "node ../../node_modules/next/dist/bin/next typegen && tsc --noEmit",
"lint": "oxlint",
"format": "oxfmt .",
"clean": "git clean -xdf .next out node_modules",
@@ -66,7 +66,6 @@
"@types/three": "catalog:",
"cheerio": "^1.2.0",
"next-validate-link": "1.6.7",
"tsx": "catalog:",
"typescript": "catalog:",
"zod": "catalog:"
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -33,7 +33,7 @@ try {
<a href="child/#child">Child</a><a href="/asset.svg">Asset</a>
<a href="https://example.com/missing">External</a><a href="#top">Top</a></main>`,
);
const result = spawnSync(process.execPath, ["--import", import.meta.resolve("tsx"), validator], {
const result = spawnSync(process.execPath, [validator], {
cwd: directory,
encoding: "utf8",
});
+21 -7
View File
@@ -13,7 +13,7 @@ export const metadata = pageMetadata({
});
export default function ApiReferenceIndexPage() {
const pages = apiSource.getPages();
const groups = apiSource.pageTree.children.filter((node) => node.type === "folder");
return (
<DocsPage full breadcrumb={{ enabled: false }} footer={{ enabled: false }}>
@@ -25,12 +25,26 @@ export default function ApiReferenceIndexPage() {
review examples or send a test request from your browser.
</p>
</header>
<div className="mt-10 divide-y border-y">
{pages.map((page) => (
<Link key={page.url} href={page.url} className="group flex items-center gap-3 py-4 hover:bg-fd-accent/50">
<span className="min-w-0 flex-1 truncate font-medium">{page.data.title}</span>
<IconArrowRight className="text-fd-muted-foreground" aria-hidden size={17} />
</Link>
<div className="mt-10 grid gap-6 lg:grid-cols-2">
{groups.map((group) => (
<section key={String(group.name)} className="min-w-0 rounded-xl border bg-fd-card p-5">
<h2 className="text-lg font-semibold">{group.name}</h2>
<div className="mt-3 divide-y border-t">
{group.children.map((page) => {
if (page.type !== "page") return null;
return (
<Link
key={page.url}
href={page.url}
className="group flex items-center gap-3 py-3 hover:bg-fd-accent/50"
>
<span className="min-w-0 flex-1 font-medium">{page.name}</span>
<IconArrowRight className="shrink-0 text-fd-muted-foreground" aria-hidden size={17} />
</Link>
);
})}
</div>
</section>
))}
</div>
<Carbon />
@@ -115,8 +115,8 @@ interface IntegrationSecretsProps {
export const IntegrationSecrets = ({ secrets }: IntegrationSecretsProps) => {
return (
<div className="flex flex-col gap-4 mt-6 w-full">
<div className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] shadow-sm w-full items-center justify-between [&>*]:w-full">
<Tabs className="[&>li]:w-full [&>li]:justify-center">
<div className="w-full">
<Tabs className="w-full rounded-none border-0 bg-transparent [&>[role=tablist]]:px-0">
{secrets.map((secret, index) => {
const key = secret.credentials.join("-") || `no-authentication-${index}`;
const Icon =
@@ -137,10 +137,10 @@ export const IntegrationSecrets = ({ secrets }: IntegrationSecretsProps) => {
<span>{tabLabel}</span>
</div>
}
className="w-100"
className="w-full rounded-none bg-transparent px-0"
value={key}
>
<div className="px-4 w-full gap-4 flex flex-col">
<div className="w-full gap-4 flex flex-col">
{secret.header}
{secret.credentials.length >= 1 && (
<table className="mb-0 w-full">
+33
View File
@@ -0,0 +1,33 @@
{
"extends": ["//"],
"tasks": {
"build": {
"env": [
"$TURBO_EXTENDS$",
"HOMARR_WEBSITE_URL",
"WORKSHOP_API_URL",
"WORKSHOP_WEB_URL",
"KAPA_WEBSITE_ID",
"WORKSHOP_URL"
],
"inputs": [
"$TURBO_DEFAULT$",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.spec.ts",
"!**/*.spec.tsx",
"!**/__tests__/**",
"!README.md",
"!AGENTS.md",
"!PRODUCT.md",
"$TURBO_ROOT$/packages/definitions/src/docs/**",
"$TURBO_ROOT$/tools/motion-reel/three/**",
".env*"
],
"outputs": [".next/**", "!.next/cache/**", "!.next/dev/**", "out/**", "next-env.d.ts", ".source/**"]
},
"typecheck": {
"inputs": ["$TURBO_EXTENDS$", "$TURBO_ROOT$/tools/motion-reel/three/**"]
}
}
}
+6 -6
View File
@@ -6,21 +6,21 @@ widget, translation, and UI packages from this monorepo.
Run commands from the repository root:
```sh
pnpm install --frozen-lockfile
bun install --frozen-lockfile
cp .env.example .env
```
Keep infrastructure running in the first terminal:
```sh
pnpm docker:dev
bun run docker:dev
```
Apply migrations and start the application in a second terminal:
```sh
pnpm db:migration:sqlite:run
pnpm dev
bun run db:migration:sqlite:run
bun run dev
```
The app listens on `http://127.0.0.1:3000`. Configure an absolute writable `DB_URL`, `AUTH_SECRET`, and
@@ -29,8 +29,8 @@ The app listens on `http://127.0.0.1:3000`. Configure an absolute writable `DB_U
To test Custom Widgets against a local Community Workshop, start PocketBase and the docs in separate terminals:
```sh
pnpm docker:workshop
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
bun run docker:workshop
WORKSHOP_API_URL=http://127.0.0.1:8090 bun run dev:docs
```
Then set:
+7 -5
View File
@@ -4,14 +4,16 @@
"private": true,
"type": "module",
"scripts": {
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"build": "node scripts/assemble-standalone.mjs",
"clean": "git clean -xdf .next .output .turbo node_modules",
"dev": "bun run with-env node ../../node_modules/next/dist/bin/next dev",
"format": "oxfmt .",
"lint": "oxlint",
"start": "pnpm with-env next start",
"start": "bun run with-env node ../../node_modules/next/dist/bin/next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
"with-env": "dotenv -e ../../.env --",
"build:compiled": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") bun run with-env node ../../node_modules/next/dist/bin/next build && node scripts/compile-standalone.mjs",
"build:dependencies": "node scripts/cache-standalone.mjs"
},
"dependencies": {
"@ai-sdk/openai-compatible": "catalog:",
@@ -0,0 +1,39 @@
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import {
appDirectory,
collectFiles,
compiled,
copyFiles,
dependencies,
isCompiledFile,
standalone,
} from "./standalone-artifact.mjs";
// Final output is disposable. Never rewrite either restored cache artifact.
if (!existsSync(join(dependencies, appDirectory, "package.json"))) {
throw new Error("Standalone dependencies are missing; run the production build through Turbo");
}
rmSync(standalone, { recursive: true, force: true });
cpSync(dependencies, standalone, { recursive: true, verbatimSymlinks: true });
copyFiles(collectFiles().filter(isCompiledFile), standalone);
const application = join(standalone, appDirectory);
mkdirSync(application, { recursive: true });
copyFileSync(join(compiled, "standalone-server.js"), join(application, "server.js"));
// Next also copies these directories for prerendered HTML/RSC and route files.
for (const directory of ["app", "pages"]) {
const source = join(compiled, "server", directory);
if (existsSync(source)) {
cpSync(source, join(application, ".next/server", directory), {
recursive: true,
force: true,
verbatimSymlinks: true,
});
}
}
cpSync(join(compiled, "static"), join(application, ".next/static"), { recursive: true, verbatimSymlinks: true });
cpSync("public", join(application, "public"), { recursive: true, verbatimSymlinks: true });
if (readFileSync(join(compiled, "BUILD_ID"), "utf8") !== readFileSync(join(application, ".next/BUILD_ID"), "utf8")) {
throw new Error("Standalone build ID differs from the compiled build");
}
+8
View File
@@ -0,0 +1,8 @@
import { rmSync } from "node:fs";
import { collectFiles, copyFiles, dependencies, isCompiledFile } from "./standalone-artifact.mjs";
// Separate uploads stay below the remote cache's per-artifact size limit.
// Recover expired dependencies from traces without changing cached compilation.
const files = collectFiles().filter((file) => !isCompiledFile(file));
rmSync(dependencies, { recursive: true, force: true });
copyFiles(files, dependencies);
@@ -0,0 +1,6 @@
import { copyFileSync } from "node:fs";
import { join } from "node:path";
import { appDirectory, compiled } from "./standalone-artifact.mjs";
// Keep Next's generated launcher with its compilation and server configuration.
copyFileSync(join(compiled, "standalone", appDirectory, "server.js"), join(compiled, "standalone-server.js"));
@@ -0,0 +1,82 @@
import { constants, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
export const compiled = resolve(".next");
export const root = resolve("../..");
export const appDirectory = relative(root, process.cwd());
export const dependencies = resolve(".output/dependencies");
export const standalone = resolve(".output/standalone");
function isOutside(path) {
return path === ".." || path.startsWith(`..${sep}`);
}
// The compiled artifact owns every generated file, including Next's launcher.
// The dependency artifact contains only traced files outside the build tree.
export function collectFiles() {
const files = new Set([join(compiled, "BUILD_ID"), join(compiled, "required-server-files.json")]);
const manifest = JSON.parse(readFileSync(join(compiled, "required-server-files.json"), "utf8"));
for (const file of manifest.files) files.add(resolve(file));
files.add(resolve("package.json"));
for (const file of [".env", ".env.production"]) {
if (existsSync(file)) files.add(resolve(file));
}
function collectTraces(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (directory === compiled && ["cache", "dev", "standalone"].includes(entry.name)) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) {
collectTraces(path);
continue;
}
if (!entry.name.endsWith(".nft.json")) continue;
// NFT lists dependencies, excluding the entrypoint itself (including proxy).
const entrypoint = path.slice(0, -".nft.json".length);
if (existsSync(entrypoint)) files.add(entrypoint);
const trace = JSON.parse(readFileSync(path, "utf8"));
for (const file of trace.files) {
const target = resolve(dirname(path), file);
if (isOutside(relative(root, target))) throw new Error(`Trace escapes the repository: ${target}`);
const generatedPath = relative(compiled, target);
if (generatedPath.startsWith(`standalone${sep}`)) continue;
// Keep native alias links, without copying through them into dependencies.
if (generatedPath.startsWith(`node_modules${sep}`) && !lstatSync(target).isSymbolicLink()) continue;
files.add(target);
}
}
}
collectTraces(compiled);
// Edge middleware/functions can have assets which are not covered by NFT.
const middleware = JSON.parse(readFileSync(join(compiled, "server/middleware-manifest.json"), "utf8"));
for (const entry of [...Object.values(middleware.middleware), ...Object.values(middleware.functions)]) {
for (const file of entry.files ?? []) files.add(join(compiled, file));
for (const file of [...(entry.wasm ?? []), ...(entry.assets ?? [])]) files.add(join(compiled, file.filePath));
}
return [...files];
}
export function isCompiledFile(file) {
return !isOutside(relative(compiled, file));
}
export function copyFiles(files, destinationRoot) {
const ordered = files.map((file) => ({ file, symbolicLink: lstatSync(file).isSymbolicLink() }));
ordered.sort((left, right) => {
if (left.symbolicLink !== right.symbolicLink) {
if (left.symbolicLink) return -1;
return 1;
}
return left.file.localeCompare(right.file);
});
for (const { file, symbolicLink } of ordered) {
const destination = join(destinationRoot, relative(root, file));
mkdirSync(dirname(destination), { recursive: true });
if (symbolicLink) {
cpSync(file, destination, { recursive: true, verbatimSymlinks: true });
} else {
copyFileSync(file, destination, constants.COPYFILE_FICLONE);
}
}
}
@@ -1,3 +1,10 @@
/* Phone widgets already retain their own 10px inset; avoid doubling it with shell padding. */
@media (width < $mantine-breakpoint-sm) {
.board:not([data-board-layout-preview]) {
margin-inline: calc(-1 * var(--app-shell-padding, 0px));
}
}
.board[data-board-left-rail="true"]:not([data-board-layout-preview]) {
margin-inline-start: calc(-1 * var(--app-shell-padding, 0px));
}
@@ -5,6 +5,7 @@ import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />,
withCustomCss: false,
async getInitialBoardAsync({ name }) {
return await getBoardByNameAsync(name);
},
@@ -34,6 +34,7 @@ interface CreateBoardLayoutProps<TParams extends Params> {
headerBoardSettingsAction?: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
withCustomCss?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
@@ -42,6 +43,7 @@ export const createBoardLayout = <TParams extends Params>({
headerBoardSettingsAction,
getInitialBoardAsync: getInitialBoard,
withTour = false,
withCustomCss = true,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
@@ -107,7 +109,7 @@ export const createBoardLayout = <TParams extends Params>({
<BoardReadyProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<ModalProvider>
<CustomCss />
{withCustomCss && <CustomCss />}
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
@@ -5,8 +5,12 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { calculateBoardUiScale } from "@homarr/boards/scale";
import classes from "./scaled-board-canvas.module.css";
export { calculateBoardUiScale } from "@homarr/boards/scale";
const BoardCanvasScaleContext = createContext(1);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -18,12 +22,6 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
return canvasScale < 1 ? 1 / canvasScale : 1;
};
interface ScaledBoardCanvasProps {
logicalWidth: number;
initialLogicalHeight: number;
@@ -143,18 +143,12 @@ export const SectionGrid = ({
const railLogicalHeight = useRailLogicalHeight(viewportRef, isRail, canvasScale);
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const railBaselineRef = useRef({ key: "", rowCount: 1 });
const railBaselineKey = `${section.id}:${currentLayoutId}`;
if (railBaselineRef.current.key !== railBaselineKey) {
railBaselineRef.current = { key: railBaselineKey, rowCount: Math.max(1, getLayoutRowCount(placements)) };
}
const railViewportRowCount = getGridRowCountForVisualHeight(railLogicalHeight, 1);
let rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
if (isRail) {
// Existing taller rails remain scrollable, but collision pushes cannot grow this cap.
rowCount = Math.max(railViewportRowCount, railBaselineRef.current.rowCount);
rowCount = Math.max(railViewportRowCount, contentRowCount);
}
const maxRowCount = section.kind === "container" || isRail ? rowCount : null;
const maxRowCount = section.kind === "container" ? rowCount : null;
const placementMaxRowCount = maxRowCount;
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
@@ -181,7 +175,19 @@ export const SectionGrid = ({
allocatedViewportHeight *= parentRowScale;
}
const logicalWidth = Math.max(1, fullGridWidth - outerCardInlineInset);
const viewportHeight = Math.max(1, allocatedViewportHeight - outerCardInset);
let nestedHeaderInset = 0;
if (
section.kind === "container" &&
(section.options.collapsible || (section.options.showLabel && section.options.title)) &&
innerSections.some(
(child) => child.yOffset === 0 && (child.options.collapsible || (child.options.showLabel && child.options.title)),
)
) {
// Separate floating parent/child headers while retaining the persisted cell footprint.
nestedHeaderInset = (20 * calculateBoardUiScale(canvasScale)) / effectiveCanvasScale;
}
nestedHeaderInset = Math.min(nestedHeaderInset, Math.max(0, allocatedViewportHeight - outerCardInset - 1));
const viewportHeight = Math.max(1, allocatedViewportHeight - outerCardInset - nestedHeaderInset);
// Fit columns to the card width. Rows fit the available height independently
// without creating horizontal gutters or distorting text and icons.
let containerContentScale = 1;
@@ -294,6 +300,7 @@ export const SectionGrid = ({
style={
{
width: logicalWidth,
marginTop: nestedHeaderInset,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
"--board-grid-content-scale": containerContentScale,
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { UnstyledButton } from "@mantine/core";
import { useOptionalBoard } from "@homarr/boards/context";
import { Link } from "@homarr/ui";
import classes from "./header-logo.module.css";
@@ -13,11 +14,15 @@ interface HeaderLogoProps {
}
export const HeaderLogo = ({ display, logo, logoWithTitle, label }: HeaderLogoProps) => {
const board = useOptionalBoard();
let href = "/";
if (board) href = `/boards/${encodeURIComponent(board.name)}`;
let content = logo;
if (display === "logoAndText") content = logoWithTitle;
return (
<UnstyledButton component={Link} href="/" className={classes.root} data-display={display} aria-label={label}>
<UnstyledButton component={Link} href={href} className={classes.root} data-display={display} aria-label={label}>
{content}
</UnstyledButton>
);
+1 -25
View File
@@ -1,9 +1,5 @@
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";
@@ -34,29 +30,9 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
try {
const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
} catch (error) {
// The workspace config is absent in Docker (only the runner stage is shipped),
// so a missing file is expected. Surface any other failure (e.g. malformed YAML).
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Map();
}
throw error;
}
return new Map(Object.entries(packageJson.workspaces.catalog));
};
const workspaceConfigCatalogSchema = z.object({
catalog: z.record(z.string(), z.string()).optional(),
});
export type PackageJsonDependencies = Record<string, string>;
interface PackageJson {
dependencies: PackageJsonDependencies | undefined;
+49
View File
@@ -0,0 +1,49 @@
{
"extends": ["//"],
"tasks": {
"build": {
"dependsOn": ["build:dependencies"],
"cache": false,
"outputs": [".output/standalone/**"],
"inputs": [
"$TURBO_EXTENDS$",
"$TURBO_ROOT$/package.json",
"$TURBO_ROOT$/static-data/**",
".env*",
"$TURBO_ROOT$/.env*"
]
},
"typecheck": {
"inputs": ["$TURBO_EXTENDS$", "$TURBO_ROOT$/static-data/**"]
},
"build:compiled": {
"inputs": [
"$TURBO_DEFAULT$",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.spec.ts",
"!**/*.spec.tsx",
"!**/__tests__/**",
"!**/*.md",
"!**/*.mdx",
"!**/README*",
"!**/CHANGELOG*",
"!**/LICENSE*",
"!**/*.stories.{ts,tsx}",
"$TURBO_ROOT$/package.json",
"$TURBO_ROOT$/static-data/**",
".env*",
"$TURBO_ROOT$/.env*"
],
"outputs": [".next/**", "!.next/cache/**", "!.next/dev/**", "!.next/standalone/**", "next-env.d.ts"],
"dependsOn": ["^build"],
"env": ["TURBO_PLATFORM"]
},
"build:dependencies": {
"dependsOn": ["build:compiled"],
"env": ["TURBO_PLATFORM"],
"inputs": ["package.json", "scripts/**"],
"outputs": [".output/dependencies/**"]
}
}
}
+1 -2
View File
@@ -11,7 +11,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"dev": "bun run with-env tsx ./src/main.ts",
"format": "oxfmt .",
"lint": "oxlint",
"typecheck": "tsc --noEmit",
@@ -46,7 +46,6 @@
"@types/node": "catalog:",
"dotenv-cli": "catalog:",
"esbuild": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
}
}
+1 -2
View File
@@ -11,7 +11,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"dev": "bun run with-env tsx ./src/main.ts",
"format": "oxfmt .",
"lint": "oxlint",
"typecheck": "tsc --noEmit",
@@ -27,7 +27,6 @@
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "catalog:",
"tsx": "catalog:",
"ws": "catalog:"
},
"devDependencies": {
+31 -6
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.10
# syntax=docker/dockerfile:1.25
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39.9@sha256:c0667fe6c5197ff226ff642120ab1ad870c3f2f27300940a61e5a7fa082b6816
@@ -13,12 +13,30 @@ RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod \
FROM pocketbase-builder AS pocketbase-test
RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod go test ./...
FROM oven/bun:1.4.2-alpine AS bun-tool
FROM node:24.18.0-alpine AS dependencies
COPY --from=bun-tool /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
ENV COREPACK_HOME=/corepack
RUN mkdir -p /corepack && corepack enable pnpm && corepack install --global pnpm@11.15.1
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
RUN apk add --no-cache libstdc++ python3 make g++
COPY bun.lock bunfig.toml package.json turbo.json ./
COPY patches ./patches
COPY apps/docs/package.json ./apps/docs/package.json
COPY packages/common/package.json ./packages/common/package.json
COPY packages/core/package.json ./packages/core/package.json
COPY packages/custom-widgets/package.json ./packages/custom-widgets/package.json
COPY packages/definitions/package.json ./packages/definitions/package.json
COPY packages/db/package.json ./packages/db/package.json
COPY packages/server-settings/package.json ./packages/server-settings/package.json
COPY packages/settings/package.json ./packages/settings/package.json
COPY packages/translation/package.json ./packages/translation/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/validation/package.json ./packages/validation/package.json
COPY packages/workshop/package.json ./packages/workshop/package.json
COPY tooling/typescript/package.json ./tooling/typescript/package.json
RUN --mount=type=cache,id=workshop-bun-cache,target=/root/.bun/install/cache \
npm_config_nodedir=/usr/local bun install --filter @homarr/docs --filter homarr --frozen-lockfile --concurrent-scripts=1
COPY apps/docs ./apps/docs
COPY tools/motion-reel/three ./tools/motion-reel/three
COPY packages/common ./packages/common
@@ -33,9 +51,9 @@ COPY packages/ui ./packages/ui
COPY packages/validation ./packages/validation
COPY packages/workshop ./packages/workshop
COPY tooling/typescript ./tooling/typescript
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set store-dir /pnpm/store && pnpm install --filter @homarr/docs... --frozen-lockfile
FROM dependencies AS website
ARG TARGETPLATFORM
ARG KAPA_WEBSITE_ID=1e4656f4-abeb-4343-bbae-1d8626f52378
ENV KAPA_WEBSITE_ID=$KAPA_WEBSITE_ID
ARG HOMARR_WEBSITE_URL=https://homarr.dev
@@ -44,7 +62,14 @@ ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop
ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL
ENV WORKSHOP_API_URL=$WORKSHOP_API_URL
ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL
RUN CI=true SKIP_ENV_VALIDATION=true pnpm --filter @homarr/docs build
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
--mount=type=cache,id=workshop-next-build-${TARGETPLATFORM},target=/app/apps/docs/.next/cache,sharing=locked \
--mount=type=cache,id=workshop-turbo-${TARGETPLATFORM},target=/app/.turbo,sharing=locked \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}/musl/node-24.18.0" \
CI=true SKIP_ENV_VALIDATION=true bun run turbo run build --filter=@homarr/docs
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
ENV PORT=8090
+7 -7
View File
@@ -12,14 +12,14 @@ From the repository root:
```sh
cp apps/workshop/.env.example apps/workshop/.env
pnpm install --frozen-lockfile
bun install --frozen-lockfile
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
```
Start the documentation site in another terminal:
```sh
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
WORKSHOP_API_URL=http://127.0.0.1:8090 bun run dev:docs
```
Open PocketBase administration at `http://127.0.0.1:8090/_/` and Workshop at
@@ -71,18 +71,18 @@ deployments can use an explicit comma-separated allowlist.
Run the focused Workshop checks:
```sh
pnpm test:workshop
pnpm --filter @homarr/workshop typecheck
pnpm --filter @homarr/docs typecheck
bun run test:workshop
bun run --filter @homarr/workshop typecheck
bun run --filter @homarr/docs typecheck
```
The integration suite uses a disposable Compose project. `pnpm test:workshop-image` additionally builds and smoke-tests
The integration suite uses a disposable Compose project. `bun run test:workshop-image` additionally builds and smoke-tests
the combined production image. The production image smoke also runs in PR CI on native amd64 and arm64 runners, including data persistence and backup restore. The broader integration suite remains available manually. Workshop CI validates Compose configuration and publishes images only on release branches. The main Fast gate owns workspace typechecking.
To smoke-test an already built candidate without rebuilding:
```sh
WORKSHOP_IMAGE_TEST_IMAGE=homarr-workshop:candidate pnpm test:workshop-image
WORKSHOP_IMAGE_TEST_IMAGE=homarr-workshop:candidate bun run test:workshop-image
```
## Data safety
+1 -1
View File
@@ -17,7 +17,7 @@ remote_api_url=${remote_api_url%/}
remote_api_url=${remote_api_url%/api}
if [ ! -f apps/docs/out/404.html ]; then
echo 'Build the docs first: pnpm --filter @homarr/docs build' >&2
echo 'Build the docs first: bun run --filter @homarr/docs build' >&2
exit 1
fi
+1 -1
View File
@@ -60,7 +60,7 @@ docker volume create "$WORKSHOP_IMAGE_TEST_VOLUME" >/dev/null
docker volume create "$WORKSHOP_IMAGE_TEST_RESTORE_VOLUME" >/dev/null
if [ -z "${WORKSHOP_IMAGE_TEST_IMAGE:-}" ]; then
docker build --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" .
bash tooling/github/docker-build.sh --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" .
fi
docker run --detach --name "$WORKSHOP_IMAGE_TEST_NAME" \
--publish "127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT:8090" \
+6444
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
[install]
# Share compatible dependencies across all workspaces at the repository root.
linker = "hoisted"
[run]
# Preserve Node runtimes for tools with a Node shebang.
bun = false
+6 -6
View File
@@ -1,7 +1,7 @@
FROM node:24.18.0-trixie AS base
FROM oven/bun:1.4.2 AS bun-tool
FROM node:24.18.0-trixie
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
COPY pnpm-workspace.yaml .
RUN corepack enable pnpm && pnpm install --frozen-lockfile
CMD ["sleep", "60s"]
COPY --from=bun-tool /usr/local/bin/bun /usr/local/bin/bun
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --concurrent-scripts=1
CMD ["sleep", "60s"]
+92
View File
@@ -0,0 +1,92 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "homarr-prebuilt-debian",
"dependencies": {
"better-sqlite3": "12.11.1",
},
},
},
"trustedDependencies": [
"better-sqlite3",
],
"packages": {
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.11.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"node-abi": ["node-abi@3.96.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
}
}
+7 -4
View File
@@ -2,11 +2,14 @@
"name": "homarr-prebuilt-debian",
"private": true,
"dependencies": {
"better-sqlite3": "^12.11.1"
"better-sqlite3": "12.11.1"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.15.1"
"bun": ">=1.4.2",
"node": ">=24.18.0"
},
"packageManager": "pnpm@11.15.1"
"packageManager": "bun@1.4.2",
"trustedDependencies": [
"better-sqlite3"
]
}
-287
View File
@@ -1,287 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
better-sqlite3:
specifier: ^12.11.1
version: 12.11.1
packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.11.1:
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
node-abi@3.85.0:
resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==}
engines: {node: '>=10'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
snapshots:
base64-js@1.5.1: {}
better-sqlite3@12.11.1:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
chownr@1.1.4: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-extend@0.6.0: {}
detect-libc@2.1.2: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
expand-template@2.0.3: {}
file-uri-to-path@1.0.0: {}
fs-constants@1.0.0: {}
github-from-package@0.0.0: {}
ieee754@1.2.1: {}
inherits@2.0.4: {}
ini@1.3.8: {}
mimic-response@3.1.0: {}
minimist@1.2.8: {}
mkdirp-classic@0.5.3: {}
napi-build-utils@2.0.0: {}
node-abi@3.85.0:
dependencies:
semver: 7.7.3
once@1.4.0:
dependencies:
wrappy: 1.0.2
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.85.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
safe-buffer@5.2.1: {}
semver@7.7.3: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-json-comments@2.0.1: {}
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
util-deprecate@1.0.2: {}
wrappy@1.0.2: {}
@@ -1,2 +0,0 @@
allowBuilds:
better-sqlite3: true
+1
View File
@@ -1,2 +1,3 @@
[tools]
node = "24.18.0"
bun = "1.4.2"
+341 -22
View File
@@ -2,8 +2,260 @@
"name": "homarr",
"version": "2.2.0",
"private": true,
"workspaces": {
"packages": [
"apps/*",
"packages/*",
"tooling/*"
],
"catalog": {
"@ai-sdk/openai-compatible": "3.0.25",
"@ai-sdk/react": "4.0.59",
"@assistant-ui/react": "0.15.8",
"@assistant-ui/react-ai-sdk": "1.4.4",
"@assistant-ui/react-lexical": "0.2.7",
"@assistant-ui/react-markdown": "0.14.9",
"@auth/core": "0.41.3",
"@auth/drizzle-adapter": "1.11.3",
"@babel/parser": "7.29.7",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lint": "6.9.6",
"@codemirror/state": "6.6.0",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.43.0",
"@ctrl/deluge": "8.0.0",
"@ctrl/qbittorrent": "9.14.0",
"@ctrl/transmission": "7.4.0",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/collision": "0.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/react": "0.5.0",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@drizzle-team/brocli": "0.12.0",
"@extractus/feed-extractor": "7.2.1",
"@gfazioli/mantine-onboarding-tour": "4.0.8",
"@gitbeaker/rest": "^43.8.0",
"@homarr/node-unifi": "2.6.0",
"@immich/sdk": "3.0.2",
"@jellyfin/sdk": "0.13.0",
"@kubernetes/client-node": "1.4.0",
"@libsql/client": "0.18.0",
"@mantine/charts": "9.6.0",
"@mantine/colors-generator": "9.6.0",
"@mantine/core": "9.6.0",
"@mantine/dates": "9.6.0",
"@mantine/dropzone": "9.6.0",
"@mantine/form": "9.6.0",
"@mantine/hooks": "9.6.0",
"@mantine/lightbox": "9.6.0",
"@mantine/modals": "9.6.0",
"@mantine/notifications": "9.6.0",
"@mantine/spotlight": "9.6.0",
"@mantine/tiptap": "9.6.0",
"@modelcontextprotocol/server": "2.0.0",
"@octokit/auth-app": "^8.2.0",
"@paralleldrive/cuid2": "3.3.0",
"@playwright/test": "1.61.1",
"@rexa-developer/tiks": "0.3.0",
"@scalar/api-client-react": "2.0.51",
"@scalar/api-reference-react": "0.9.74",
"@semantic-release/changelog": "7.0.0",
"@semantic-release/commit-analyzer": "13.0.1",
"@semantic-release/git": "11.0.1",
"@semantic-release/github": "12.0.9",
"@semantic-release/release-notes-generator": "14.1.1",
"@shadcn/react": "^0.2.1",
"@t3-oss/env-nextjs": "0.13.11",
"@tabler/icons-react": "3.44.0",
"@tanstack/query-async-storage-persister": "5.101.2",
"@tanstack/react-devtools": "0.10.9",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
"@tanstack/react-query-next-experimental": "5.101.2",
"@tanstack/react-query-persist-client": "5.101.2",
"@testcontainers/postgresql": "12.0.4",
"@testcontainers/redis": "12.0.4",
"@tiptap/extension-color": "3.30.5",
"@tiptap/extension-details": "3.30.5",
"@tiptap/extension-highlight": "3.30.5",
"@tiptap/extension-image": "3.30.5",
"@tiptap/extension-link": "3.31.3",
"@tiptap/extension-placeholder": "3.31.3",
"@tiptap/extension-table": "3.30.5",
"@tiptap/extension-table-cell": "3.30.5",
"@tiptap/extension-table-header": "3.30.5",
"@tiptap/extension-table-row": "3.30.5",
"@tiptap/extension-task-item": "3.30.5",
"@tiptap/extension-task-list": "3.30.5",
"@tiptap/extension-text-align": "3.30.5",
"@tiptap/extension-text-style": "3.30.5",
"@tiptap/extension-underline": "3.30.5",
"@tiptap/react": "3.31.3",
"@tiptap/starter-kit": "3.31.3",
"@trpc/client": "11.18.0",
"@trpc/next": "11.18.0",
"@trpc/react-query": "11.18.0",
"@trpc/server": "11.18.0",
"@trpc/tanstack-react-query": "11.18.0",
"@turbo/gen": "2.11.7",
"@types/adm-zip": "0.5.8",
"@types/bcrypt": "6.0.0",
"@types/canvas-confetti": "1.9.0",
"@types/chroma-js": "3.1.2",
"@types/cookies": "0.9.2",
"@types/crypto-js": "4.2.2",
"@types/css-modules": "1.0.5",
"@types/dockerode": "4.0.1",
"@types/mdx": "2.0.14",
"@types/node": "24.13.3",
"@types/node-unifi": "2.5.1",
"@types/pg": "8.20.0",
"@types/prismjs": "^1.26.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/three": "0.180.0",
"@types/video.js": "7.3.58",
"@types/ws": "8.18.1",
"@types/xml2js": "0.4.14",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "6.0.3",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/ui": "4.1.10",
"@xterm/addon-canvas": "0.7.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"acorn": "8.16.0",
"acorn-jsx": "5.3.2",
"adm-zip": "0.6.1",
"ai": "7.0.56",
"ajv": "8.18.0",
"assistant-stream": "0.3.34",
"babel-plugin-react-compiler": "1.0.0",
"bcrypt": "6.0.0",
"canvas-confetti": "1.9.4",
"chroma-js": "3.2.0",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"codemirror": "6.0.2",
"concurrently": "10.0.3",
"conventional-changelog-conventionalcommits": "10.2.0",
"cookie": "2.0.1",
"cookies": "0.9.1",
"croner": "10.0.1",
"cross-env": "10.1.0",
"crypto-js": "4.2.0",
"date-fns": "4.4.0",
"dayjs": "1.11.21",
"deepmerge": "4.3.1",
"dns-caching": "0.2.9",
"dockerode": "5.0.1",
"dotenv": "17.4.2",
"dotenv-cli": "11.0.0",
"drizzle-kit": "0.31.10",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"embla-carousel-react": "^8.6.0",
"esbuild": "0.28.2",
"fast-xml-parser": "5.11.1",
"fastify": "5.12.5",
"feed": "6.0.0",
"flag-icons": "7.5.0",
"fumadocs-core": "16.15.12",
"fumadocs-mdx": "15.4.3",
"fumadocs-openapi": "12.0.1",
"fumadocs-ui": "16.15.12",
"glob": "13.0.6",
"ical.js": "2.2.1",
"input-otp": "^1.4.2",
"ioredis": "5.11.1",
"isomorphic-dompurify": "3.18.0",
"jotai": "2.20.1",
"jsdom": "29.1.1",
"json-schema-typed": "8.0.2",
"json5": "2.2.3",
"jsonpath-plus": "10.4.0",
"jszip": "3.10.1",
"ldapts": "8.1.8",
"lodash.clonedeep": "4.5.0",
"lucide-react": "1.24.0",
"mantine-contextmenu": "^9.3.1",
"mantine-datatable": "9.3.1",
"mantine-react-table": "npm:mantine-react-table-open@9.0.3",
"maria2": "0.4.1",
"mermaid": "11.17.2",
"motion": "^13.0.0",
"next": "16.3.6",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.13.1",
"next-themes": "0.4.6",
"node-loader": "2.1.0",
"octokit": "5.0.5",
"oxfmt": "0.58.0",
"oxlint": "1.73.0",
"pg": "8.22.0",
"pocketbase": "0.26.9",
"postcss-preset-mantine": "1.18.0",
"posthog-js": "1.399.2",
"posthog-node": "5.41.0",
"prismjs": "^1.30.0",
"proxmox-api": "1.1.1",
"react": "19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
"react-error-boundary": "6.1.2",
"react-markdown": "10.1.0",
"react-quill-new": "3.8.3",
"react-resizable-panels": "^4.12.2",
"react-shiki": "0.11.0",
"react-simple-code-editor": "^0.14.1",
"recharts": "3.9.1",
"rehype-raw": "7.0.0",
"rehype-sanitize": "6.0.0",
"remark-breaks": "4.0.0",
"remark-directive": "3.0.1",
"remark-gfm": "4.0.1",
"sass": "1.101.0",
"semantic-release": "25.0.5",
"semver-parser": "4.2.1",
"serve": "14.2.6",
"shiki": "4.4.3",
"sonner": "2.0.7",
"sql.js": "1.14.1",
"superjson": "2.2.6",
"svgson": "5.3.1",
"testcontainers": "12.0.4",
"three": "0.180.0",
"timezones-ical-library": "2.2.1",
"trpc-to-openapi": "3.3.0",
"tsdav": "2.3.0",
"turbo": "2.11.7",
"typescript": "7.0.2",
"undici": "7.29.1",
"use-deep-compare-effect": "1.8.1",
"video.js": "8.23.9",
"vite": "8.1.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"winston": "3.19.0",
"ws": "8.21.0",
"xml2js": "0.6.2",
"yaml": "2.9.0",
"zod": "4.4.3",
"zod-form-data": "3.0.2",
"zod-validation-error": "5.0.0",
"better-sqlite3": "12.11.1",
"@types/better-sqlite3": "7.6.13"
}
},
"scripts": {
"build": "cross-env CI=true turbo build",
"build": "cross-env CI=true turbo run build",
"benchmark:build:docker": "node --experimental-strip-types scripts/benchmarks/docker-build.mts",
"benchmark:compare": "node --experimental-strip-types scripts/benchmarks/benchmark-compare.mts",
"benchmark:board-graph": "node --experimental-strip-types scripts/benchmarks/board-static-graph.mts",
@@ -11,27 +263,27 @@
"benchmark:request-memory": "node --expose-gc --import tsx scripts/benchmarks/request-handler-memory.mts",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
"check:custom-widgets": "bun run --filter @homarr/custom-widgets check:architecture",
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
"check:openapi": "tsx ./scripts/check-openapi-schema.mts",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
"ci:act:fast": "bash scripts/ci/act.sh fast",
"cli": "pnpm with-env tsx packages/cli/index.ts",
"db:migration:postgresql:generate": "pnpm -F db migration:postgresql:generate",
"db:migration:postgresql:run": "pnpm -F db migration:postgresql:run",
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
"db:seed": "pnpm -F db seed",
"db:push": "pnpm -F db push:sqlite",
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"cli": "bun run with-env tsx packages/cli/index.ts",
"db:migration:postgresql:generate": "bun run --filter @homarr/db migration:postgresql:generate",
"db:migration:postgresql:run": "bun run --filter @homarr/db migration:postgresql:run",
"db:migration:sqlite:generate": "bun run --filter @homarr/db migration:sqlite:generate",
"db:migration:sqlite:run": "bun run --filter @homarr/db migration:sqlite:run",
"db:seed": "bun run --filter @homarr/db seed",
"db:push": "bun run --filter @homarr/db push:sqlite",
"db:studio": "bun run --filter @homarr/db studio",
"dev": "bun run --filter @homarr/nextjs dev",
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
"dev:cli": "go -C tools/homarr-dev run -buildvcs=false .",
"dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .",
"dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:docs": "bun run --filter @homarr/docs dev",
"docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs",
"docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
@@ -40,15 +292,15 @@
"format:fix": "turbo format --continue",
"lint": "turbo lint --continue",
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"lint:ws": "bun x sherif@latest",
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
"release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-openapi": "tsx ./scripts/check-openapi-schema.mts --write",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "pnpm -F nextjs start",
"start": "bun run --filter @homarr/nextjs start",
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\"",
"test:coverage": "pnpm test --coverage.enabled",
"test:coverage": "bun run test --coverage.enabled",
"test:integration": "cross-env NODE_ENV=development CI=true vitest run --project integration --maxWorkers 2",
"test:docs-screenshots": "cross-env UPDATE_DOCS_SCREENSHOTS=true NODE_ENV=development CI=true vitest run --project docs-screenshots",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e --maxWorkers 2",
@@ -69,12 +321,11 @@
"@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/coverage-istanbul": "catalog:",
"@vitest/ui": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:",
@@ -84,16 +335,84 @@
"oxlint": "catalog:",
"semantic-release": "catalog:",
"testcontainers": "catalog:",
"tsx": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.15.1"
"overrides": {
"@ai-sdk/provider-utils>undici": "8.10.2",
"@assistant-ui/core": "0.3.7",
"@assistant-ui/react-ai-sdk>@ai-sdk/mcp": "2.0.27",
"@assistant-ui/react-ai-sdk>@ai-sdk/react": "4.0.59",
"@assistant-ui/react-ai-sdk>ai": "7.0.56",
"@assistant-ui/react-ai-sdk>assistant-stream": "0.3.34",
"@assistant-ui/store": "0.3.5",
"@assistant-ui/tap": "0.9.10",
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"@grpc/grpc-js@<1.12.7": "1.13.6",
"@rvf/set-get@<7.0.2": "7.0.2",
"@xmldom/xmldom@<0.8.15": "0.8.15",
"assistant-cloud": "0.1.38",
"axios@>=1.0.0 <1.18.1": "1.20.0",
"brace-expansion@>=1.0.0 <1.1.21": "1.1.21",
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7",
"brace-expansion@>=4.0.0 <5.0.9": "5.0.12",
"browserslist@<4.28.7": "4.28.7",
"esbuild@<=0.24.2": ">=0.28.1",
"fast-uri@>=3.0.0 <3.1.6": "3.1.8",
"find-my-way@<9.6.1": "9.9.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.6",
"hono@<4.6.5": ">=4.13.7",
"image-size@<=2.0.2": "2.0.3",
"immutable@>=5.0.0-beta.1 <5.1.8": "5.1.8",
"ip-address@<10.3.1": "10.7.1",
"js-yaml@>=3.0.0 <3.15.2": "3.15.2",
"js-yaml@>=4.0.0 <4.3.2": "4.3.2",
"jsdom>undici": "8.10.2",
"linkifyjs@<4.3.2": ">=4.3.3",
"lodash-es@<4.17.24": "4.18.1",
"nanoid@>=3.0.0 <3.3.18": "3.3.18",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16",
"picomatch@>=2.0.0 <2.3.2": "2.3.2",
"postcss@<8.5.23": "8.5.23",
"prismjs@<1.30.0": ">=1.30.0",
"protobufjs@<7.5.5": ">=7.5.5 <8",
"proxmox-api>undici": "7.29.1",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.62.2",
"serialize-javascript@<7.0.5": "7.0.5",
"sha.js@<=2.4.11": ">=2.4.12",
"shell-quote@<1.8.5": "1.11.0",
"svgo@>=3.0.0 <3.3.5": "3.3.5",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.3",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.3",
"tmp@<=0.2.3": ">=0.2.7",
"undici@>=7.0.0 <7.29.1": "7.29.1",
"undici@>=8.0.0 <8.9.0": "8.9.0",
"vite@>=5.0.0 <=5.4.18": ">=8.1.4"
},
"packageManager": "pnpm@11.15.1"
"engines": {
"bun": ">=1.4.2",
"node": ">=24.18.0"
},
"packageManager": "bun@1.4.2",
"patchedDependencies": {
"dns-caching@0.2.9": "patches/dns-caching.patch",
"@types/node-unifi@2.5.1": "patches/@types__node-unifi.patch",
"mantine-datatable@9.3.1": "patches/mantine-datatable@9.3.1.patch",
"trpc-to-openapi@3.3.0": "patches/trpc-to-openapi.patch"
},
"trustedDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml",
"bcrypt",
"better-sqlite3",
"esbuild",
"sharp",
"ssh2",
"tree-sitter",
"tree-sitter-json"
]
}
+26
View File
@@ -1,4 +1,5 @@
import { generateOpenApiDocument } from "trpc-to-openapi";
import { z } from "zod/v4";
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
@@ -59,5 +60,30 @@ export const openApiDocument = (base: string) => {
const json = integrationBody.content["application/json"];
if (json) json.example = { integrationKind: "sonarr", method: "GET", path: "/api/v3/system/status" };
}
// The adapter omits root refinements when generating request bodies.
const preferenceBody = document.paths?.["/api/users/preferences"]?.patch?.requestBody;
if (preferenceBody && "content" in preferenceBody) {
const json = preferenceBody.content["application/json"];
if (json) {
const schema = z.toJSONSchema(userRouter["_def"].record.updatePreferences["_def"].inputs[0] as z.ZodType, {
io: "input",
});
delete schema.$schema;
json.schema = schema as typeof json.schema;
json.example = { colorScheme: "light" };
}
}
document.tags = (
[
["boards", "Build dashboards"],
["apps", "Manage apps"],
["users", "Manage users and preferences"],
["settings", "Configure the instance"],
["integrations", "Connect integrations"],
["invites", "Manage invitations"],
["certificates", "Manage trusted certificates"],
["info", "Inspect Homarr"],
] as const
).map(([name, title]) => ({ name, "x-displayName": title }));
return document;
};
+1 -1
View File
@@ -37,7 +37,7 @@ export const apiKeysRouter = createTRPCRouter({
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
enabled: false,
description: "Create a new API key for the current user (admin only)",
},
})
+434 -374
View File
@@ -1205,14 +1205,31 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardByName: publicProcedure.input(boardByNameSchema).query(async ({ input, ctx }) => {
const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
getBoardByName: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Read a dashboard by name, including current sections, items, widget options and layout IDs used by board_saveBoard and board_saveLayouts. Only accessible boards are returned; obtain names from board_getAllBoards.",
},
})
.input(boardByNameSchema)
.query(async ({ input, ctx }) => {
const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardSettings: protectedProcedure
.meta({
openapi: {
method: "GET",
path: "/api/boards/{id}/settings",
tags: ["boards"],
protect: true,
summary: "Read board settings",
description: "Read editable board settings. Requires modify access to the board.",
},
mcp: {
enabled: true,
description:
@@ -1263,6 +1280,21 @@ export const boardRouter = createTRPCRouter({
};
}),
saveLayouts: protectedProcedure
.meta({
openapi: {
method: "PUT",
path: "/api/boards/{id}/layouts",
tags: ["boards"],
protect: true,
summary: "Save dashboard layouts",
description: "Save the same responsive layout settings used by the dashboard editor. Requires modify access.",
},
mcp: {
enabled: true,
description:
"Save dashboard layouts, preserving one Mobile and one Base layout. Requires modify access. Supply the board id and its complete layouts array from board_getBoardByName.",
},
})
.input(boardSaveLayoutsSchema)
.output(boardSaveLayoutsSchema.shape.layouts)
.mutation(async ({ ctx, input }) => {
@@ -1534,6 +1566,14 @@ export const boardRouter = createTRPCRouter({
}),
resetLayout: protectedProcedure
.meta({
openapi: {
method: "POST",
path: "/api/boards/{boardId}/layouts/{layoutId}/reset",
tags: ["boards"],
protect: true,
summary: "Reset a dashboard layout",
description: "Reset a Mobile or custom layout from the Base layout. Requires modify access.",
},
mcp: {
enabled: true,
description:
@@ -1541,6 +1581,7 @@ export const boardRouter = createTRPCRouter({
},
})
.input(boardResetLayoutSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
@@ -1655,231 +1696,51 @@ export const boardRouter = createTRPCRouter({
})
.where(eq(boards.id, input.id));
}),
saveBoard: protectedProcedure.input(boardSaveSchema).mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
storedItems: dbBoard.items,
});
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
let previousOptions: Record<string, unknown> | undefined;
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
boardId: dbBoard.id,
})),
);
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
);
}
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const item of updatedItems) {
await transaction
.update(schema.items)
.set({
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(schema.items.id, item.id));
for (const itemSectionLayout of item.layouts) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, item.id),
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
),
);
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
await transaction
.update(schema.sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(schema.sections.id, section.id));
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
await transaction
.update(schema.sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(schema.sectionLayouts.sectionId, section.id),
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
),
);
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
}
});
saveBoard: protectedProcedure
.meta({
openapi: {
method: "PUT",
path: "/api/boards/{id}/content",
tags: ["boards"],
protect: true,
summary: "Save dashboard content",
description:
"Save the editor's complete sections and items arrays, including widget options and placements. Omitted items and sections are removed. Requires modify access; Custom Widget placement changes require admin access.",
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
mcp: {
enabled: true,
description:
"Save complete dashboard sections and items, including widget options and placements. Omitted entries are removed. Read current content with board_getBoardByName first. Requires modify access; Custom Widget placement changes require admin access.",
},
})
.input(boardSaveSchema)
.output(z.void())
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
storedItems: dbBoard.items,
});
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
let previousOptions: Record<string, unknown> | undefined;
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
@@ -1889,36 +1750,33 @@ export const boardRouter = createTRPCRouter({
name: null,
boardId: dbBoard.id,
})),
)
.run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
}
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
@@ -1926,14 +1784,11 @@ export const boardRouter = createTRPCRouter({
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
)
.run();
transaction
.insert(itemLayouts)
.values(
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
@@ -1944,142 +1799,347 @@ export const boardRouter = createTRPCRouter({
}),
),
),
)
.run();
}
);
}
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(items.id, item.id))
.run();
for (const itemSectionLayout of item.layouts) {
transaction
.update(itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(and(eq(itemLayouts.itemId, item.id), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)))
.run();
);
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const section of updatedSections) {
transaction
.update(sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(sections.id, section.id))
.run();
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
transaction
.update(sectionLayouts)
for (const item of updatedItems) {
await transaction
.update(schema.items)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(schema.items.id, item.id));
for (const itemSectionLayout of item.layouts) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, item.id),
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
),
);
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
await transaction
.update(schema.sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(schema.sections.id, section.id));
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
await transaction
.update(schema.sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(schema.sectionLayouts.sectionId, section.id),
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
),
);
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
.where(
and(eq(sectionLayouts.sectionId, section.id), eq(sectionLayouts.layoutId, sectionLayout.layoutId)),
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
boardId: dbBoard.id,
})),
)
.run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
)
.run();
transaction
.insert(itemLayouts)
.values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
)
.run();
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
)
.run();
}
);
const removedItems = filterRemovedItems(input.items, dbBoard.items);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(items.id, item.id))
.run();
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
});
}),
for (const itemSectionLayout of item.layouts) {
transaction
.update(itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(and(eq(itemLayouts.itemId, item.id), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)))
.run();
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
transaction
.update(sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(sections.id, section.id))
.run();
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
transaction
.update(sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(eq(sectionLayouts.sectionId, section.id), eq(sectionLayouts.layoutId, sectionLayout.layoutId)),
)
.run();
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
)
.run();
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
});
}),
getBoardPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+96 -198
View File
@@ -8,16 +8,15 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, onboarding, users } from "@homarr/db/schema";
import { groupMembers, groupPermissions, groups, invites, onboarding, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
getHeaderItems,
defaultHeaderPreferences,
headerPreferencesMutationSchema,
parseHeaderPreferences,
userByteUnitSystemSchema,
userChangeColorSchemeSchema,
userChangeHomeBoardsSchema,
@@ -31,8 +30,9 @@ import {
userInitSchema,
userPingIconsEnabledSchema,
userRegistrationApiSchema,
userPreferencesSchema,
userPreferencesPatchSchema,
} from "@homarr/validation/user";
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
import { convertIntersectionToZodObject } from "../schema-merger";
import {
@@ -43,13 +43,29 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { getAccessibleBoardIdsForUserAsync } from "./board";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
import { getUserPreferencesAsync, updateUserPreferencesAsync } from "./user/preferences";
const logger = createLogger({ module: "userRouter" });
const userPreferencesResponseSchema = userPreferencesSchema.extend({ userId: z.string() }).meta({
id: "UserPreferences",
example: {
userId: "example-user",
colorScheme: "light",
byteUnitSystem: "binary",
firstDayOfWeek: 0,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
homeBoardId: null,
mobileHomeBoardId: null,
defaultSearchEngineId: null,
openSearchInNewTab: true,
ddgBangs: true,
headerPreferences: defaultHeaderPreferences,
},
});
export const userRouter = createTRPCRouter({
initUser: onboardingProcedure
.requiresStep("user")
@@ -365,6 +381,26 @@ export const userRouter = createTRPCRouter({
email: user.email,
}));
}),
getPreferences: protectedProcedure
.meta({
openapi: {
method: "GET",
path: "/api/users/preferences",
tags: ["users"],
protect: true,
summary: "Get user preferences",
description:
"Read every preference as structured JSON. Omit userId for the current user; reading another user requires admin permission.",
},
mcp: {
enabled: true,
description:
"Read all preferences, including color scheme, home boards, search behavior and structured header layout. Optional userId defaults to the current user; another user requires admin permission. Use user_updatePreferences to patch supplied fields.",
},
})
.input(z.object({ userId: z.string().min(1).optional() }))
.output(userPreferencesResponseSchema)
.query(({ ctx, input }) => getUserPreferencesAsync(ctx, input.userId ?? ctx.session.user.id)),
getById: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(
@@ -377,6 +413,7 @@ export const userRouter = createTRPCRouter({
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
colorScheme: true,
byteUnitSystem: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
@@ -419,6 +456,7 @@ export const userRouter = createTRPCRouter({
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
colorScheme: true,
byteUnitSystem: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
@@ -618,6 +656,37 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
updatePreferences: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/preferences",
tags: ["users"],
protect: true,
summary: "Update user preferences",
description:
"Update only supplied preferences and return the result. Omitted fields remain unchanged; null clears nullable references. Header fields and zones merge, while each supplied zone array replaces that zone. The complete merged header must retain unique items and account access. New board shortcuts must be accessible to the target user. Omit userId for yourself; another user requires admin permission. All validation completes before one write.",
},
mcp: {
enabled: true,
description:
"Patch one or more user preferences and return the complete result. Read current values with user_getPreferences. All preference fields are optional; supply at least one. Omitted fields remain unchanged; null clears home-board/search-engine references. Header fields and zones merge; supplied zone arrays replace their zone and must preserve unique items and account access. Optional userId defaults to yourself; changing another user requires admin permission.",
},
})
.input(
z
.strictObject({ userId: z.string().min(1).optional(), ...userPreferencesPatchSchema.shape })
.refine((input) => Object.keys(input).some((key) => key !== "userId"), "Supply at least one preference")
.meta({
minProperties: 1,
not: { required: ["userId"], maxProperties: 1 },
}),
)
.output(userPreferencesResponseSchema)
.mutation(({ ctx, input }) => {
const { userId, ...patch } = input;
return updateUserPreferencesAsync(ctx, userId ?? ctx.session.user.id, patch);
}),
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
@@ -633,44 +702,10 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
await updateUserPreferencesAsync(ctx, input.userId, {
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Only allow user to select boards they have access to
if (input.homeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.homeBoardId), "view");
}
if (input.mobileHomeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.mobileHomeBoardId), "view");
}
await ctx.db
.update(users)
.set({
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
})
.where(eq(users.id, input.userId));
}),
changeDefaultSearchEngine: protectedProcedure
.input(
@@ -692,13 +727,13 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, {
...input,
openInNewTab: undefined,
await updateUserPreferencesAsync(ctx, input.userId, {
defaultSearchEngineId: input.defaultSearchEngineId,
ddgBangs: input.ddgBangsEnabled,
});
}),
changeSearchPreferences: protectedProcedure
.input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
.input(convertIntersectionToZodObject(userChangeSearchPreferencesSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({
openapi: {
@@ -712,7 +747,11 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
await updateUserPreferencesAsync(ctx, input.userId, {
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
ddgBangs: input.ddgBangsEnabled,
});
}),
changeColorScheme: protectedProcedure
.input(userChangeColorSchemeSchema)
@@ -728,44 +767,13 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
.set({
colorScheme: input.colorScheme,
})
.where(eq(users.id, ctx.session.user.id));
await updateUserPreferencesAsync(ctx, ctx.session.user.id, { colorScheme: input.colorScheme });
}),
changeByteUnitSystem: protectedProcedure
.input(userByteUnitSystemSchema.and(byIdSchema))
.output(z.void())
.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",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.id),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
byteUnitSystem: input.byteUnitSystem,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { byteUnitSystem: input.byteUnitSystem });
}),
changeEnableRightClickOnWidgets: protectedProcedure
.meta({
@@ -782,37 +790,12 @@ export const userRouter = createTRPCRouter({
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
.output(z.void())
.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({
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { enableRightClickOnWidgets: input.enableRightClickOnWidgets });
}),
changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users ping icons enabled
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({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { pingIconsEnabled: input.pingIconsEnabled });
}),
changeHeaderPreferences: protectedProcedure
.meta({
@@ -825,54 +808,11 @@ export const userRouter = createTRPCRouter({
description:
"Replace a user's header layout and display preferences. New board shortcuts must reference boards the target user can view; existing shortcuts may be retained. Users can update themselves; updating another user requires admin permission.",
},
mcp: {
enabled: true,
description:
"Update the header layout preferences of a user. REQUIRED: id (user ID), headerPreferences (zones for left, center and right, visible flag, searchDisplay and logoDisplay). Admins can change any user; other users can only change their own. Board shortcut items must reference boards the target user can view",
},
})
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
.output(z.void())
.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",
});
}
const targetUser = await ctx.db.query.users.findFirst({
columns: { headerPreferences: true },
where: eq(users.id, input.id),
});
if (!targetUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const submittedBoardIds = getBoardShortcutIds(input.headerPreferences);
if (submittedBoardIds.length > 0) {
const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences)));
const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id);
const hasUnavailableShortcut = submittedBoardIds.some(
(boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId),
);
if (hasUnavailableShortcut) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more board shortcuts are unavailable to this user",
});
}
}
await ctx.db
.update(users)
.set({
headerPreferences: serializeHeaderPreferences(input.headerPreferences),
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { headerPreferences: input.headerPreferences });
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -890,19 +830,7 @@ export const userRouter = createTRPCRouter({
},
})
.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));
await updateUserPreferencesAsync(ctx, input.id, { ddgBangs: input.ddgBangs });
}),
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
@@ -919,34 +847,7 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.id),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
firstDayOfWeek: input.firstDayOfWeek,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { firstDayOfWeek: input.firstDayOfWeek });
}),
completeTour: protectedProcedure
.input(z.object({ tour: z.enum(["manage", "board"]) }))
@@ -990,9 +891,6 @@ export const userRouter = createTRPCRouter({
}),
});
const getBoardShortcutIds = (preferences: HeaderPreferences) =>
getHeaderItems(preferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : []));
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const hashedPassword = await hashPasswordAsync(input.password);
@@ -1,54 +0,0 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Modify } from "@homarr/common/types";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { users } from "@homarr/db/schema";
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSchema.and(
z.object({ userId: z.string() }),
);
export const changeSearchPreferencesAsync = async (
db: Database,
session: Session,
input: Modify<
z.infer<typeof changeSearchPreferencesInputSchema>,
{ openInNewTab: boolean | undefined; ddgBangsEnabled: boolean | undefined }
>,
) => {
const user = session.user;
// Only admins can change other users search preferences
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await db
.update(users)
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
ddgBangs: input.ddgBangsEnabled,
})
.where(eq(users.id, input.userId));
};
+103
View File
@@ -0,0 +1,103 @@
import { TRPCError } from "@trpc/server";
import type { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Database, InferInsertModel } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, searchEngines, users } from "@homarr/db/schema";
import { userPreferencesPatchSchema, userPreferencesSchema } from "@homarr/validation/user";
import {
getHeaderItems,
parseHeaderPreferences,
serializeHeaderPreferences,
} from "@homarr/validation/header-preferences";
import { getAccessibleBoardIdsForUserAsync } from "../board";
import { throwIfActionForbiddenAsync } from "../board/board-access";
interface PreferencesContext {
db: Database;
session: Session;
}
const definedFields = <TValue extends object>(value: TValue): TValue =>
Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as TValue;
export async function getUserPreferencesAsync(ctx: PreferencesContext, userId: string) {
if (ctx.session.user.id !== userId && !ctx.session.user.permissions.includes("admin"))
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
const row = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
colorScheme: true,
byteUnitSystem: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
homeBoardId: true,
mobileHomeBoardId: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
headerPreferences: true,
},
});
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
return { userId, ...row, headerPreferences: parseHeaderPreferences(row.headerPreferences) };
}
export async function updateUserPreferencesAsync(
ctx: PreferencesContext,
userId: string,
input: z.infer<typeof userPreferencesPatchSchema>,
) {
const current = await getUserPreferencesAsync(ctx, userId);
const parsed = userPreferencesPatchSchema.safeParse(definedFields(input));
if (!parsed.success) throw new TRPCError({ code: "BAD_REQUEST", message: parsed.error.message, cause: parsed.error });
const patch = parsed.data;
const { headerPreferences, ...fields } = patch;
const mergedHeader = {
...current.headerPreferences,
...definedFields(headerPreferences ?? {}),
zones: { ...current.headerPreferences.zones, ...definedFields(headerPreferences?.zones ?? {}) },
};
const { userId: _userId, ...preferences } = current;
const mergedResult = userPreferencesSchema.safeParse({ ...preferences, ...fields, headerPreferences: mergedHeader });
if (!mergedResult.success)
throw new TRPCError({ code: "BAD_REQUEST", message: mergedResult.error.message, cause: mergedResult.error });
const merged = mergedResult.data;
for (const boardId of [fields.homeBoardId, fields.mobileHomeBoardId]) {
if (boardId) await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
}
if (fields.defaultSearchEngineId) {
const engine = await ctx.db.query.searchEngines.findFirst({
columns: { id: true },
where: eq(searchEngines.id, fields.defaultSearchEngineId),
});
if (!engine) throw new TRPCError({ code: "BAD_REQUEST", message: "Search engine not found" });
}
if (headerPreferences) {
const existingBoardIds = new Set(
getHeaderItems(current.headerPreferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : [])),
);
const newBoardIds = getHeaderItems(merged.headerPreferences.zones).flatMap((item) => {
if (item.type !== "board" || existingBoardIds.has(item.boardId)) return [];
return [item.boardId];
});
if (newBoardIds.length > 0) {
const accessible = await getAccessibleBoardIdsForUserAsync(ctx.db, userId);
if (newBoardIds.some((boardId) => !accessible.has(boardId)))
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more board shortcuts are unavailable to this user",
});
}
}
// All references and the merged header are validated before the single preference write.
const values: Partial<InferInsertModel<typeof users>> = { ...fields };
if (headerPreferences) values.headerPreferences = serializeHeaderPreferences(merged.headerPreferences);
await ctx.db.update(users).set(values).where(eq(users.id, userId));
return { userId, ...merged };
}
@@ -28,6 +28,7 @@ const redactFeedFailureMessage = (message: string) => message.replace(/https?:\/
const feedsInput = z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
descriptionMaxLength: z.number().int().min(1).max(5000).default(250),
});
export const rssFeedRouter = createTRPCRouter({
@@ -43,6 +44,7 @@ export const rssFeedRouter = createTRPCRouter({
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: input.maximumAmountPosts,
descriptionMaxLength: input.descriptionMaxLength,
});
return await innerHandler.getDataAsync();
}),
+5 -2
View File
@@ -24,6 +24,7 @@ const MCP_TOOL_ALLOWLIST = {
"beszel_getSystemStats",
"board_catalog",
"board_getAllBoards",
"board_getBoardByName",
"board_getBoardSettings",
"board_search",
"calendar_findAllEvents",
@@ -85,6 +86,7 @@ const MCP_TOOL_ALLOWLIST = {
"traefik_getDashboard",
"user_getAll",
"user_getById",
"user_getPreferences",
"widgetSecrets_getConfiguredKinds",
"wud_getStats",
],
@@ -100,6 +102,8 @@ const MCP_TOOL_ALLOWLIST = {
"board_duplicateBoard",
"board_renameBoard",
"board_resetLayout",
"board_saveBoard",
"board_saveLayouts",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",
@@ -127,11 +131,10 @@ const MCP_TOOL_ALLOWLIST = {
"smartHome_executeAutomation",
"smartHome_switchEntity",
"stats_refresh",
"user_changeHeaderPreferences",
"user_updatePreferences",
"user_delete",
],
secret: [
"apiKeys_create",
"apiKeys_delete",
"customWidget_configurationRequestUser",
"customWidget_create",
+2 -1
View File
@@ -16,7 +16,8 @@
"./layout-selection": "./src/layout-selection.ts",
"./layout-preview": "./src/layout-preview.ts",
"./updater": "./src/updater.ts",
"./edit-mode": "./src/edit-mode.tsx"
"./edit-mode": "./src/edit-mode.tsx",
"./scale": "./src/scale.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
+6
View File
@@ -0,0 +1,6 @@
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
if (canvasScale < 1) return 1 / canvasScale;
return 1;
};
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more