Compare commits

..
Author SHA1 Message Date
Meier Lukas 29293992ca fix: bytestring middleware issue 2025-06-07 14:25:09 +02:00
859 changed files with 21370 additions and 79541 deletions

No files matched your search

-1
View File
@@ -5,7 +5,6 @@ node_modules
npm-debug.log
README.md
.next
.turbo
.git
dev
.build
+2 -14
View File
@@ -12,10 +12,6 @@ AUTH_SECRET="supersecret"
# or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Cron job API key is generated every time the container starts as it is required for communication between nextjs-api and tasks-api
# export CRON_JOB_API_KEY=$(openssl rand -base64 32)
# CRON_JOB_API_KEY="your-generated-api-key"
LOG_LEVEL='info'
# This is how you can use the sqlite driver:
@@ -34,19 +30,11 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# The following is an example on how to use the node-postgres driver:
# DB_DRIVER='node-postgres'
# DB_URL='postgres://user:password@host:port/database'
# The below path can be used to store trusted certificates, it is not required and can be left empty.
# If it is empty, it will default to `/appdata/trusted-certificates` in production.
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# If it is used, please use the full path to the directory where the certificates are stored.
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# ENABLE_KUBERNETES=true
-46
View File
@@ -31,52 +31,6 @@ body:
label: Version
description: What version of Homarr are you running?
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.56.0
- 1.55.0
- 1.54.0
- 1.53.0
- 1.52.0
- 1.51.0
- 1.50.1
- 1.50.0
- 1.49.1
- 1.49.0
- 1.48.0
- 1.47.0
- 1.46.0
- 1.45.3
- 1.45.2
- 1.45.1
- 1.45.0
- 1.44.0
- 1.43.1
- 1.43.0
- 1.42.1
- 1.42.0
- 1.41.0
- 1.40.0
- 1.39.0
- 1.38.0
- 1.37.0
- 1.36.1
- 1.36.0
- 1.35.1
- 1.35.0
- 1.34.0
- 1.33.0
- 1.32.0
- 1.31.0
- 1.30.1
- 1.30.0
- 1.29.0
- 1.28.1
- 1.28.0
- 1.27.0
- 1.26.0
- 1.25.0
- 1.24.0
- 1.23.0
- 1.22.0
- 1.21.0
@@ -1,64 +0,0 @@
name: Extract Build Artifact
description: Extracts artifacts from an existing Docker image to be used for from source installation
inputs:
digest:
description: Digest of Docker image to use
required: true
architecture:
description: Name of architecture, will be used to build artifact content (amd64 or arm64)
required: true
release-tag:
description: Tag of the release to which the artifact will be attached
required: true
repository:
description: Repository to which the release belongs, e.g. owner/repo
required: true
token:
description: GitHub token with permissions to upload release assets
required: true
runs:
using: "composite"
steps:
- name: Prebuilt debian dependencies
uses: homarr-labs/homarr/.github/actions/prebuilt-debian@dev
id: prebuilt-debian
with:
architecture: ${{ inputs.architecture }}
- name: Start docker container for ${{ inputs.architecture }}
run: |
docker run --name homarr \
-e "SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000" \
--detach --rm ${{ inputs.digest }}
shell: bash
- name: Prepare extraction
run: |
docker exec homarr cp /etc/nginx/templates/nginx.conf /app && \
mkdir -p ${{ runner.temp }}/extraction/${{ inputs.architecture }}
shell: bash
- name: Extract source from ${{ inputs.architecture }} container (alpine)
run: |
docker exec homarr tar -czf extraction-alpine.tar.gz -C /app . && \
docker cp homarr:/app/extraction-alpine.tar.gz ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-alpine-${{ inputs.architecture }}.tar.gz && \
docker exec homarr rm /app/extraction-alpine.tar.gz
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
- name: Stop ${{ inputs.architecture }} container
if: always()
run: docker container remove --force --volumes homarr
shell: bash
- name: Add build archive to release (alpine)
env:
GH_TOKEN: ${{ inputs.token }}
run: gh release upload --repo ${{ inputs.repository }} ${{ inputs.release-tag }} ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-alpine-${{ inputs.architecture }}.tar.gz --clobber
shell: bash
- name: Add build archive to release (debian)
env:
GH_TOKEN: ${{ inputs.token }}
run: gh release upload --repo ${{ inputs.repository }} ${{ inputs.release-tag }} ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-debian-${{ inputs.architecture }}.tar.gz --clobber
shell: bash
@@ -1,42 +0,0 @@
name: Prebuilt dependencies for debian
description: Provides prebuilt dependencies for debian based docker images
inputs:
architecture:
description: Name of architecture, will be used to build docker image (e.g. amd64, arm64)
required: true
outputs:
path:
description: Path to extracted prebuilt dependencies
value: ${{ runner.temp }}/prebuilts
runs:
using: "composite"
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build docker image for ${{ inputs.architecture }}
id: build
uses: docker/build-push-action@v6
with:
push: false
load: true
context: ./deployments/prebuilt-debian
platforms: linux/${{ inputs.architecture }}
tags: prebuilt-debian
- name: Start docker container for ${{ inputs.architecture }}
run: |
docker run --name prebuilt-debian \
--detach --rm prebuilt-debian
shell: bash
- name: Extract prebuilt dependencies from ${{ inputs.architecture }} container
run: |
mkdir -p ${{ runner.temp }}/prebuilts && \
docker cp prebuilt-debian:/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node ${{ runner.temp }}/prebuilts/better_sqlite3.node
shell: bash
- name: Stop ${{ inputs.architecture }} container
if: always()
run: docker container remove --force --volumes prebuilt-debian
shell: bash
+1 -23
View File
@@ -3,39 +3,17 @@
extends: ["config:recommended"],
packageRules: [
{
matchPackageNames: ["^@homarr/"],
enabled: false,
},
// Reenable once https://github.com/privatenumber/tsx/issues/737 is fixed
{
matchPackageNames: ["tsx"],
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
},
{
// disallow automerge for onlyBuiltDependencies
matchPackageNames: [
"@tree-sitter-grammars/tree-sitter-yaml",
"bcrypt",
"better-sqlite3",
"cpu-features",
"esbuild",
"sharp",
"ssh2",
"tree-sitter",
"tree-sitter-json",
],
automerge: false,
},
],
updateInternalDeps: true,
rangeStrategy: "bump",
automerge: false,
baseBranches: ["dev"],
dependencyDashboard: false,
// prevent special characters in branch names (otherwise git errors occur on windows)
branchNameStrict: true,
}
+4 -9
View File
@@ -3,24 +3,19 @@ on:
pull_request:
types: [opened, synchronize]
permissions: {}
jobs:
approve-automatic-prs:
runs-on: ubuntu-latest
timeout-minutes: 2
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D and https://api.github.com/users/homarr-update-contributors%5Bbot%5D
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v2
uses: tibdex/github-app-token@v2
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve pull request
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve automatic PRs
+6 -14
View File
@@ -1,6 +1,4 @@
name: "[Quality] Code Analysis"
permissions:
contents: read
on:
pull_request:
@@ -23,9 +21,8 @@ env:
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
@@ -39,9 +36,8 @@ jobs:
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
@@ -51,9 +47,8 @@ jobs:
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
@@ -63,9 +58,8 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
@@ -81,10 +75,9 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Build docker image
@@ -106,9 +99,8 @@ jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
@@ -0,0 +1,14 @@
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
name: "[Conventions] Semantic Commits"
on:
pull_request:
branches: [ dev ]
jobs:
build:
name: Conventional Commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
@@ -13,8 +13,7 @@ permissions:
jobs:
validate-pull-request-title:
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- uses: amannn/action-semantic-pull-request@v6
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -5,26 +5,20 @@ on:
schedule:
- cron: "0 0 * * *" # every day at midnight
permissions:
contents: write # required to push changes
jobs:
download-crowdin-translations:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v2
uses: tibdex/github-app-token@v2
with:
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app-id: ${{ secrets.CROWDIN_APP_ID }}
permission-contents: write # required to commit to crowdin branch
permission-pull-requests: write # required to create pull request
private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app_id: ${{ secrets.CROWDIN_APP_ID }}
- name: Download Crowdin translations
id: crowdin-download
+1 -4
View File
@@ -1,6 +1,4 @@
name: "[Crowdin] Upload translations"
permissions:
contents: read
on:
workflow_dispatch:
@@ -14,11 +12,10 @@ jobs:
# Don't run this action if the downloaded translations are being pushed
if: "!contains(github.event.head_commit.message, 'chore(lang)')"
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Upload Crowdin translations
uses: crowdin/github-action@v2
+14 -68
View File
@@ -32,13 +32,12 @@ jobs:
release:
name: Create tag and release
runs-on: ubuntu-latest
timeout-minutes: 5
env:
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
outputs:
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
skipped: ${{ env.SKIP_RELEASE }}
steps:
- run: echo "Skipping release for workflow_dispatch event"
if: env.SKIP_RELEASE == 'true'
@@ -50,23 +49,18 @@ jobs:
- name: Obtain token
if: env.SKIP_RELEASE == 'false'
id: obtainToken
uses: actions/create-github-app-token@v2
uses: tibdex/github-app-token@v2
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev and publish the release
- uses: actions/checkout@v6
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
- uses: actions/checkout@v4
if: env.SKIP_RELEASE == 'false'
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
if: env.SKIP_RELEASE == 'false'
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.14.0
cache: "pnpm"
node-version: 22
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies
@@ -113,11 +107,10 @@ jobs:
name: Build docker image for amd64
needs: release
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.git_ref }}
@@ -128,7 +121,7 @@ jobs:
images: "${{ env.GHCR_REPO }}"
- name: Log in to the Container registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -148,15 +141,15 @@ jobs:
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
env:
SKIP_ENV_VALIDATION: true
build-arm64:
name: Build docker image for arm64
needs: release
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.git_ref }}
@@ -167,7 +160,7 @@ jobs:
images: "${{ env.GHCR_REPO }}"
- name: Log in to the Container registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -187,58 +180,18 @@ jobs:
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
env:
SKIP_ENV_VALIDATION: true
extract-asset-amd64:
name: Extract amd64 asset from docker image
needs: [release, build-amd64]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Extract amd64
if: needs.release.outputs.skipped == 'false'
uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev
with:
digest: "${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }}"
architecture: amd64
release-tag: ${{ needs.release.outputs.version }}
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
extract-asset-arm64:
name: Extract arm64 asset from docker image
needs: [release, build-arm64]
runs-on: ubuntu-24.04-arm
timeout-minutes: 2
steps:
- name: Extract arm64
if: needs.release.outputs.skipped == 'false'
uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev
with:
digest: "${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}"
architecture: arm64
release-tag: ${{ needs.release.outputs.version }}
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
publish:
name: Complete deployment and notify
needs: [release, build-amd64, build-arm64, extract-asset-amd64, extract-asset-arm64]
needs: [release, build-amd64, build-arm64]
runs-on: ubuntu-latest
timeout-minutes: 5
env:
NEXT_VERSION: ${{ needs.release.outputs.version }}
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
steps:
- name: Obtain token
if: needs.release.outputs.skipped == 'false'
id: obtainToken
uses: actions/create-github-app-token@v2
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to publish the release
- name: Log in to the Container registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -264,13 +217,6 @@ jobs:
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
- name: Publish release
if: needs.release.outputs.skipped == 'false'
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh release edit --repo ${{ github.repository }} ${{ needs.release.outputs.version }} --draft=false
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
@@ -18,7 +18,6 @@ permissions:
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
@@ -28,7 +27,7 @@ jobs:
with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get Next Version
@@ -50,11 +49,10 @@ jobs:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
id: obtainApprovalToken
uses: actions/create-github-app-token@v2
uses: tibdex/github-app-token@v2
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Approve PR
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
@@ -62,12 +60,10 @@ jobs:
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v2
uses: tibdex/github-app-token@v2
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to main branch (due to merge)
permission-pull-requests: write # merge pull request
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
- id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge
@@ -1,45 +0,0 @@
name: "[Deployments] Validate prebuilt debian dependencies"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
paths: [".github/actions/prebuilt-debian/**", "deployments/prebuilt-debian/**"]
jobs:
prebuilt-debian-validate-amd64:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Validate prebuilt dependencies for amd64
id: validate-amd64
uses: ./.github/actions/prebuilt-debian
with:
architecture: amd64
- name: Check extracted files for amd64
run: |
if [ ! -f "${{ steps.validate-amd64.outputs.path }}/better_sqlite3.node" ]; then
echo "better_sqlite3.node not found for amd64!"
exit 1
fi
prebuilt-debian-validate-arm64:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Validate prebuilt dependencies for arm64
id: validate-arm64
uses: ./.github/actions/prebuilt-debian
with:
architecture: arm64
- name: Check extracted files for arm64
run: |
if [ ! -f "${{ steps.validate-arm64.outputs.path }}/better_sqlite3.node" ]; then
echo "better_sqlite3.node not found for arm64!"
exit 1
fi
@@ -1,19 +0,0 @@
name: "[Renovate] Validate configuration"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
paths: [".github/renovate.json5"]
jobs:
renovate-validate:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v6
- run: |
npx --yes --package renovate -- \
renovate-config-validator --strict .github/renovate.json5
-87
View File
@@ -1,87 +0,0 @@
permissions: {}
on:
release:
types: [released]
jobs:
trigger-docs-release:
name: Trigger Documentation Release
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v2
with:
private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
owner: homarr-labs
repositories: |
documentation
permission-contents: write # required to dispatch repository workflow
- name: Trigger documentation release
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
SOURCE_TAG: ${{ github.event.release.tag_name }}
run: |
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/homarr-labs/documentation/dispatches \
-d @- <<EOF
{
"event_type": "trigger-release",
"client_payload": {
"tag": "${SOURCE_TAG}"
}
}
EOF
update-bug-report-template:
name: Update Bug Report Template
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v2
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
with:
token: ${{ steps.obtainToken.outputs.token }}
ref: dev
- name: Setup
uses: ./tooling/github/setup
- run: pnpm run scripts:update-bug-report-template
env:
NEXT_VERSION: ${{ github.event.release.tag_name }}
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v8
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
commit-message: "chore: update bug report template"
author: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
token: ${{ steps.obtainToken.outputs.token }}
branch: update-bug-report-template
base: dev
title: "chore: update bug report template"
delete-branch: true
body: |
This PR automatically updates the bug report template of Homarr in the .github/ISSUE_TEMPLATE/bug_report.yml
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Enable auto-merge
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash
@@ -1,27 +0,0 @@
permissions:
statuses: write
on:
pull_request:
types: [opened, synchronize]
jobs:
skip-stability-days:
if: ${{ !startsWith(github.head_ref, 'renovate/') }}
name: Skip Stability Days
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Add status check
env:
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
run: |
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
-f 'state=success' \
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
-f 'description=Skipped for non renovate branches' \
-f 'context=renovate/stability-days'
+8 -12
View File
@@ -10,35 +10,31 @@ env:
CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}"
permissions:
contents: read
contents: write
jobs:
update-contributors:
runs-on: ubuntu-latest
timeout-minutes: 2
strategy:
matrix:
node-version: [22]
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v2
uses: tibdex/github-app-token@v2
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
private_key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app_id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Run update script
run: node ./scripts/update-contributors.mjs
@@ -54,7 +50,7 @@ jobs:
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v8
uses: peter-evans/create-pull-request@v7
with:
token: ${{ steps.obtainToken.outputs.token }}
branch: update-contributors
@@ -1,65 +0,0 @@
name: Update integration list
on:
workflow_dispatch: {}
push:
paths:
- packages/definitions/src/integration.ts
branches:
- dev
permissions:
pull-requests: write
jobs:
update-readme:
concurrency:
group: update-integration
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v2
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Setup
uses: ./tooling/github/setup
- run: pnpm run scripts:update-readme-integrations
- name: Commit changes
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
git config --global user.email "210161987+homarr-update-contributors[bot]@users.noreply.github.com"
git config --global user.name "Homarr Update Contributors"
git add .
git commit -m "chore: update integrations list readme"
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ steps.obtainToken.outputs.token }}
branch: update-integrations-readme
base: dev
title: "chore: update integrations list readme"
delete-branch: true
body: |
This PR automatically updates the list of integrations of Homarr in the README.md
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Enable auto-merge
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash
+1 -5
View File
@@ -57,7 +57,6 @@ apps/tasks/tasks.cjs
apps/tasks/tasks.css
apps/websocket/wssServer.cjs
apps/websocket/wssServer.css
apps/nextjs/customServer.cjs
apps/nextjs/.million/
packages/cli/cli.cjs
@@ -68,7 +67,4 @@ e2e/shared/tmp
apps/nextjs/public/images/background.png
# next-intl
en.d.json.ts
# Custom server build artifacts
apps/nextjs/customServer.css
en.d.json.ts
-1
View File
@@ -3,4 +3,3 @@
# @link https://docs.expo.dev/guides/monorepos/#common-issues
node-linker=hoisted
strict-peer-dependencies=false
engine-strict=true
+1 -1
View File
@@ -1 +1 @@
24.14.0
22.16.0
-1
View File
@@ -44,7 +44,6 @@
{
"successComment": false,
"failComment": false,
"draftRelease": true,
"releaseBodyTemplate": "<%= _.truncate(nextRelease.notes, { 'length': 124000, 'omission': '' }) %>"
}
]
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:migration:postgresql:generate" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:migration:postgresql:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-369
View File
@@ -1,369 +0,0 @@
# Memory Optimization Approaches
This document describes the approaches tried and considered for reducing the RAM footprint of Homarr's server processes.
## Baseline Architecture (Before)
Homarr runs **three separate Node.js processes**:
| Process | Port | Purpose |
|---|---|---|
| `apps/nextjs` (Next.js) | 3000 | Web UI + API routes |
| `apps/websocket` (WS server) | 3001 | tRPC WebSocket subscriptions |
| `apps/tasks` (Tasks/Cron server) | 3002 | Cron job scheduling + management API |
Each process loads its own V8 heap, duplicates shared dependencies (`@homarr/db`, `@homarr/auth`, etc.), and runs its own event loop.
**Measured baseline (dev mode, idle):** ~2,954 MB total RSS
---
## Approach 1: Integrate WebSocket Server into Next.js Custom Server
**Status:** ✅ Implemented
Eliminates `apps/websocket` by handling WebSocket upgrades inside a [Next.js custom server](https://nextjs.org/docs/pages/guides/custom-server):
```ts
const app = next({ dev, hostname, port, dir });
const upgradeHandler = app.getUpgradeHandler();
server.on("upgrade", (req, socket, head) => {
if (req.url?.startsWith("/websockets")) {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
} else {
upgradeHandler(req, socket, head); // HMR, etc.
}
});
```
### PROs
- Eliminates one V8 heap (~208 MB)
- WebSocket runs on same port as HTTP (simpler networking/proxying)
- Shared `@homarr/db` connection pool and module cache
- `getUpgradeHandler()` properly delegates HMR to Next.js in dev mode
### CONs
- Custom server disables some Next.js optimizations (automatic static optimization still works with `output: "standalone"`)
- WebSocket errors could affect the main server process
- Slightly more complex startup code
---
## Approach 2: Integrate Tasks/Cron Server into Next.js Custom Server
**Status:** ✅ Implemented (combined with Approach 1)
Eliminates `apps/tasks` by running the Fastify tRPC cron API and job scheduler inside the same process:
```ts
async function startTasksServer() {
const tasksServer = fastify({ maxParamLength: 5000 });
tasksServer.register(fastifyTRPCPlugin, { ... });
await cleanupSessionsAsync();
await invalidateUpdateCheckerCacheAsync();
await jobGroup.initializeAsync();
await jobGroup.startAllAsync();
await tasksServer.listen({ port: CRON_JOB_API_PORT });
return tasksServer;
}
```
### PROs
- Eliminates another V8 heap (~328 MB)
- Shared database connection pool across Next.js API routes and cron jobs
- Single process to manage (simpler Docker, `run.sh`, monitoring)
- Cron jobs can directly share in-memory state with the web server
### CONs
- A misbehaving cron job could affect web request latency
- Fastify still binds to port 3002 for internal tRPC communication (minor overhead)
- More dependencies in the custom server bundle
---
## Combined Result (Approaches 1 + 2)
| Component | Separate (before) | Integrated (after) |
|---|---|---|
| WebSocket server | ~208 MB | — |
| Tasks/Cron server | ~328 MB | — |
| Next.js (incl. workers) | ~2,418 MB | — |
| Custom Server (all-in-one) | — | ~2,007 MB |
| **Total RSS** | **~2,954 MB** | **~2,007 MB** |
| **Savings** | — | **~947 MB (~32%)** |
---
## Approach 3: Eliminate Fastify — Direct Function Calls for Cron API
**Status:** 📋 Considered, not implemented
Since the cron job API client (`cronJobApi`) is only used from Next.js API routes (same process now), we could replace the HTTP+tRPC client with direct function calls to `JobManager`:
```ts
// Before: HTTP call to localhost:3002/trpc
await cronJobApi.trigger.mutate({ name: "myJob" });
// After: direct call in same process
await jobManager.triggerAsync("myJob");
```
### PROs
- Eliminates Fastify dependency and port 3002 listener entirely
- Zero network overhead for cron API calls
- Simpler architecture (no internal HTTP, no API key needed)
### CONs
- Requires refactoring `packages/api/src/router/cron-jobs.ts` and `packages/cron-job-api`
- Breaks the clean separation between Next.js API and tasks concerns
- If the architecture ever splits back to separate processes, this would need to be reverted
- API key authentication for cron operations would need a different mechanism
---
## Approach 4: Node.js `--max-old-space-size` Flag
**Status:** 📋 Considered, available as user override
Setting `NODE_OPTIONS=--max-old-space-size=N` limits V8 heap growth and forces more aggressive garbage collection.
```dockerfile
# Example: limit heap to 512 MB
ENV NODE_OPTIONS="--max-old-space-size=512"
```
### PROs
- Can reduce peak memory usage in constrained environments
- Easy to configure via environment variable
- No code changes required
### CONs
- Too low a value causes OOM crashes under load
- Doesn't reduce actual memory *needed*, just forces earlier GC
- Default Node.js behavior (~1.7 GB limit on 64-bit) is reasonable for most deployments
- Different deployments have different memory constraints — no single good default
**Recommendation:** Don't set a default in the Dockerfile. Document as a tuning option for users running on low-memory hosts (e.g., `docker run -e NODE_OPTIONS="--max-old-space-size=512"`).
---
## Approach 5: Next.js `output: "standalone"` (Already in Use)
**Status:** ✅ Already configured
The `next.config.ts` already uses `output: "standalone"`, which:
- Traces only the files needed for production
- Creates a minimal `.next/standalone` directory
- Avoids shipping full `node_modules`
This is already optimized and no further action needed.
---
## Approach 6: Shared Redis Connection Pooling
**Status:** 📋 Considered, not implemented
With all services in one process, Redis connection pools could be shared. Currently, each module may create its own Redis client instance.
### PROs
- Fewer open connections to Redis
- Slightly less memory for connection buffers
### CONs
- Marginal memory savings (Redis connections are lightweight)
- Requires auditing all Redis usage across packages
- Risk of connection contention under high load
---
## Summary of Recommendations
| Approach | Impact | Effort | Status |
|---|---|---|---|
| 1. Integrate WS server | ~208 MB saved | Low | ✅ Done |
| 2. Integrate Tasks server | ~328 MB saved | Medium | ✅ Done |
| 3. Eliminate Fastify | ~10-30 MB saved | High | 📋 Future |
| 4. `--max-old-space-size` | Configurable | None | 📋 User option |
| 5. Standalone output | Already optimal | None | ✅ Already done |
| 6. Shared Redis pools | ~5-10 MB saved | Medium | 📋 Future |
| 7. Redis-backed initial query data | Faster page loads | Medium | 📋 POC documented |
| 8. Client-side rendering with ISR shell | Faster TTFB | High | 📋 POC documented |
---
## Approach 7: Redis-Backed TanStack Query Initial Data
**Status:** 📋 POC documented
### Problem
Currently, the dashboard page (`/`) is fully server-rendered on every request:
1. Server calls `api.board.getHomeBoard()` (database query)
2. Server prefetches all widget data via `prefetchForKindAsync()` (multiple database/integration queries)
3. Server renders the full React tree with hydration boundary
4. Client hydrates and takes over
Each page load triggers a cascade: **Client → Next.js Server → tRPC → Database → Back**. For data that doesn't change often (board layout, widget configurations), this is wasteful.
### Approach
Use Redis as a shared cache layer between cron jobs and page loads. Cron jobs already populate Redis with integration data. The idea is to:
1. **Cron jobs write to Redis** (already happening via `createItemAndIntegrationChannel`)
2. **Server reads from Redis first** instead of hitting the database for widget data
3. **Pass Redis data as `initialData`** to TanStack Query on the client
4. **Client-side queries take over** with stale-while-revalidate behavior
```tsx
// POC: In _creator.tsx, read from Redis cache first for widget prefetching
import { createItemAndIntegrationChannel } from "@homarr/redis";
// Instead of always calling the database:
const cachedData = await redisChannel.getAsync();
if (cachedData) {
// Use cached data as initial query data - skip the database call
queryClient.setQueryData(queryKey, cachedData.data);
} else {
// Fall back to normal prefetch
await prefetchForKindAsync(kind, queryClient, items);
}
```
On the client side, TanStack Query's `initialData` option lets us hydrate from cache:
```tsx
// Client component using cached initial data
const { data } = useQuery({
queryKey: ["widget", widgetId],
queryFn: () => fetchWidgetData(widgetId),
initialData: cachedWidgetData, // From Redis via server props
staleTime: 30_000, // Consider fresh for 30s
});
```
### How TanStack Query Initial Data Works
From the [TanStack Query docs](https://tanstack.com/query/v5/docs/framework/react/guides/initial-query-data):
- `initialData` pre-populates the query cache before the component mounts
- The query still revalidates in the background (stale-while-revalidate)
- Combined with `staleTime`, the query won't refetch until the data is considered stale
- This means the page renders instantly with cached data, then silently updates
### PROs
- **Faster page loads**: Redis reads are ~1ms vs ~50-200ms for database queries with joins
- **Reduced database load**: Most page views hit Redis instead of SQLite/MySQL/PostgreSQL
- **Leverages existing infrastructure**: Cron jobs already write widget data to Redis channels
- **Graceful degradation**: Falls back to normal database queries on cache miss
- **Non-breaking**: Can be adopted incrementally per widget type
- **Shared across users**: Non-personal data (board layout, widget data) is the same for all users viewing the same board
### CONs
- **Cache staleness**: Data may be up to one cron interval old (typically 1-5 minutes)
- **Cache invalidation complexity**: Need to invalidate Redis when boards are edited
- **Memory usage**: More data stored in Redis (marginal since it's already stored for pub/sub)
- **Serialization overhead**: superjson parse/stringify on every cache read
- **Testing complexity**: Need to test cache hit/miss/stale scenarios
### Estimated Impact
| Metric | Before | After (cache hit) | Improvement |
|---|---|---|---|
| Dashboard TTFB | ~200-500ms | ~50-100ms | 2-5x faster |
| Database queries per page load | 5-20 (depending on widgets) | 0-2 (board + auth only) | ~90% reduction |
| Redis reads per page load | 0 | 5-20 | Acceptable (~1ms each) |
### Implementation Path
1. Create a `createBoardCacheChannel(boardId)` in `@homarr/redis`
2. Populate it when boards are saved and when cron jobs update widget data
3. In `_creator.tsx`, check cache before `prefetchForKindAsync()`
4. Set `staleTime` to match cron interval for each widget type
5. Add cache invalidation when boards are edited (`board.saveBoard` mutation)
---
## Approach 8: Client-Side Rendering with ISR Static Shell
**Status:** 📋 POC documented (not recommended for Homarr's use case)
### Concept
Instead of server-rendering the entire dashboard on each request, serve a **static HTML shell** via Next.js ISR (Incremental Static Regeneration) and let the client fetch all dynamic content via TanStack Query.
```tsx
// Hypothetical ISR dashboard page
export const revalidate = 60; // Regenerate every 60 seconds
export default function DashboardPage() {
// Return a static shell - no server-side data fetching
return (
<DashboardShell>
<Suspense fallback={<BoardSkeleton />}>
<ClientBoard /> {/* Fetches via useQuery on mount */}
</Suspense>
</DashboardShell>
);
}
```
The client component would then fetch everything:
```tsx
"use client";
function ClientBoard() {
const { data: board, isLoading } = clientApi.board.getHomeBoard.useQuery(undefined, {
initialData: undefined, // Could be populated from Redis via API route
});
if (isLoading) return <BoardSkeleton />;
return <BoardContent board={board} />;
}
```
### How Next.js ISR Works
- On first request, Next.js generates the page and caches it
- Subsequent requests serve the cached HTML instantly (TTFB ~0ms for CDN)
- After `revalidate` seconds, the next request triggers background regeneration
- The stale page is served until the new one is ready (stale-while-revalidate)
### PROs
- **Near-instant TTFB**: Static HTML served from disk/CDN cache
- **Reduced server CPU**: No React SSR on every request
- **Scalable**: Static pages can be served by any CDN or reverse proxy
- **Good for public boards**: Shared boards viewed by many users benefit most
### CONs
- **Layout shift (CLS)**: Dashboard appears empty then fills in — bad UX for dashboards
- **No SSR for SEO**: Not relevant for Homarr (it's a private dashboard app)
- **Slower perceived load**: Users see a skeleton for 200-500ms while queries run
- **Authentication complexity**: ISR pages must be the same for all users. Homarr's dashboard is user-specific (permissions, board access), making ISR caching ineffective for the page itself
- **Breaks existing hydration pattern**: The current `HydrationBoundary` + `dehydrate()` pattern works well and ensures no layout shift
- **WebSocket subscriptions delayed**: Real-time updates don't start until client JS loads and connects
### Benchmark Comparison (Theoretical)
| Metric | Current (SSR) | Approach 7 (Redis initial data) | Approach 8 (ISR + CSR) |
|---|---|---|---|
| TTFB | 200-500ms | 50-100ms | ~10ms (static) |
| Time to Interactive | 200-500ms | 50-100ms | 200-500ms (same) |
| Largest Contentful Paint | 200-500ms | 50-100ms | 400-800ms (worse) |
| Cumulative Layout Shift | ~0 | ~0 | 0.1-0.3 (worse) |
| Server CPU per request | High | Low | Near zero |
### Recommendation
**Approach 7 (Redis-backed initial data) is recommended over Approach 8 (ISR)** for Homarr because:
1. **Dashboards are personalized**: Board access depends on user permissions, making ISR page-level caching ineffective
2. **No layout shift**: Users expect the dashboard to appear fully rendered
3. **Existing infrastructure**: Redis + cron jobs already provide the cache layer needed
4. **Incremental adoption**: Can be applied per-widget without changing the overall rendering strategy
5. **ISR is designed for content sites**: Blog posts, documentation pages, product pages — not interactive dashboards with real-time WebSocket subscriptions
ISR could still be useful for specific Homarr pages like `/manage/about` or documentation pages that don't depend on user state, but the main dashboard is not a good candidate.
-1144
View File
File diff suppressed because it is too large. Load diff
-8
View File
@@ -1,9 +1 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
# Exempt Renovate‑managed files (no owners)
package.json
package-lock.json
pnpm-lock.yaml
Dockerfile
docker-compose.yml
+8 -5
View File
@@ -1,4 +1,4 @@
FROM node:24.14.0-alpine AS base
FROM node:22.16.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -10,6 +10,8 @@ RUN apk add --no-cache libc6-compat curl bash
RUN apk update
COPY . .
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
@@ -17,7 +19,8 @@ COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm build
FROM base AS runner
@@ -43,7 +46,8 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/apps/nextjs/customServer.cjs ./apps/nextjs/customServer.cjs
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder /app/packages/db/migrations ./db/migrations
@@ -54,7 +58,7 @@ 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
COPY scripts/run.sh ./run.sh
COPY --chmod=755 scripts/entrypoint.sh ./entrypoint.sh
COPY --chmod=777 scripts/entrypoint.sh ./entrypoint.sh
COPY packages/redis/redis.conf /app/redis.conf
COPY nginx.conf /etc/nginx/templates/nginx.conf
@@ -63,7 +67,6 @@ ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
ENTRYPOINT [ "/app/entrypoint.sh" ]
+4 -4
View File
@@ -1,18 +1,18 @@
# Security Policy
This policy is relevant if you found potential vulnerabilities in an audit.
We consider something as a vulnerability if it...
1. puts users or user data at risk
2. enables third parties to gain control or access (e.g. [RATs](https://en.wikipedia.org/wiki/Remote_desktop_software#RAT), [privilege escalation](https://en.wikipedia.org/wiki/Privilege_escalation), ...)
3. abuses the system in an unintended way (e.g. crypto mining, proxy, ...)
## Supported Versions
We only fix security issues in the [latest stable version](https://github.com/homarr-labs/homarr/releases/latest). Meaning security issues in prior versions will not be fixed and users have to upgrade to the latest version to receive them.
| Version | Supported |
| ------- | ------------------ |
| >1.0.0 | :white_check_mark: |
| <1.0.0 | :x: |
## Reporting a Vulnerability
We use [GitHub's system for reporting vulnerabilities](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-advisories/working-with-repository-security-advisories/creating-a-repository-security-advisory).
Click [**here to report an advisory**](https://github.com/homarr-labs/homarr/security/advisories/new). Our team will get notified and will get back to you within 1-6 business days.
+24 -20
View File
@@ -1,8 +1,8 @@
// Importing env files here to validate on build
import "@homarr/auth/env";
import "@homarr/core/infrastructure/db/env";
import "@homarr/db/env";
import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/log/env";
import "@homarr/docker/env";
import type { NextConfig } from "next";
@@ -17,30 +17,37 @@ const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts",
});
interface WebpackConfig {
module: {
rules: {
test: RegExp;
loader: string;
}[];
};
}
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
// react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true,
/** We already do typechecking as separate tasks in CI */
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
/**
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
webpack: (config: WebpackConfig, { isServer }) => {
if (isServer) {
config.module.rules.push({
test: /\.node$/,
loader: "node-loader",
});
}
return config;
},
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: {
localPatterns: [
{
pathname: "/**",
search: "",
},
],
domains: ["cdn.jsdelivr.net"],
},
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
async headers() {
@@ -50,11 +57,9 @@ const nextConfig: NextConfig = {
headers: [
{
key: "Content-Security-Policy",
// worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp
value: `
default-src 'self';
script-src * 'unsafe-inline' 'unsafe-eval';
worker-src * blob:;
base-uri 'self';
connect-src *;
style-src * 'unsafe-inline';
@@ -63,7 +68,6 @@ const nextConfig: NextConfig = {
form-action 'self';
img-src * data:;
font-src * data:;
media-src * data: blob:;
`
.replace(/\s{2,}/g, " ")
.trim(),
+44 -57
View File
@@ -4,13 +4,12 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build && pnpm build:custom-server",
"build:custom-server": "esbuild server.ts --bundle --platform=node --outfile=customServer.cjs --external:next --external:fastify --external:bcrypt --external:@opentelemetry/api --external:deasync --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env tsx ./server.ts",
"dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env node customServer.cjs",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
@@ -24,12 +23,9 @@
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-api": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
@@ -37,16 +33,14 @@
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0",
@@ -54,66 +48,59 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^8.3.16",
"@mantine/core": "^8.3.16",
"@mantine/dropzone": "^8.3.16",
"@mantine/hooks": "^8.3.16",
"@mantine/modals": "^8.3.16",
"@mantine/tiptap": "^8.3.16",
"@mantine/colors-generator": "^8.0.2",
"@mantine/core": "^8.0.2",
"@mantine/dropzone": "^8.0.2",
"@mantine/hooks": "^8.0.2",
"@mantine/modals": "^8.0.2",
"@mantine/tiptap": "^8.0.2",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-query-next-experimental": "^5.91.0",
"@trpc/client": "^11.12.0",
"@trpc/next": "^11.12.0",
"@trpc/react-query": "^11.12.0",
"@trpc/server": "^11.12.0",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tanstack/react-query-devtools": "^5.80.6",
"@tanstack/react-query-next-experimental": "^5.80.6",
"@trpc/client": "^11.3.1",
"@trpc/next": "^11.3.1",
"@trpc/react-query": "^11.3.1",
"@trpc/server": "^11.3.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "^6.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"chroma-js": "^3.2.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"dotenv": "^17.3.1",
"fastify": "^5.7.4",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"flag-icons": "^7.5.0",
"glob": "^13.0.6",
"isomorphic-dompurify": "^3.1.0",
"jotai": "^2.18.1",
"glob": "^11.0.2",
"jotai": "^2.12.5",
"mantine-react-table": "2.0.0-beta.9",
"next": "16.1.6",
"postcss-preset-mantine": "^1.18.0",
"next": "15.3.3",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.30.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-error-boundary": "^6.1.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-error-boundary": "^6.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.97.3",
"superjson": "2.2.6",
"swagger-ui-react": "^5.32.0",
"sass": "^1.89.1",
"superjson": "2.2.2",
"swagger-ui-react": "^5.24.0",
"use-deep-compare-effect": "^1.8.1",
"ws": "^8.19.0",
"zod": "^4.2.1"
"zod": "^3.25.56"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.2",
"@types/node": "^24.12.0",
"@types/prismjs": "^1.26.6",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.15.30",
"@types/prismjs": "^1.26.5",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "^5.18.0",
"@types/ws": "^8.18.1",
"concurrently": "^9.2.1",
"esbuild": "^0.27.3",
"eslint": "^9.39.4",
"concurrently": "^9.1.2",
"eslint": "^9.28.0",
"node-loader": "^2.1.0",
"prettier": "^3.8.1",
"tsx": "4.20.4",
"typescript": "^5.9.3"
"prettier": "^3.5.3",
"typescript": "^5.8.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

-318
View File
@@ -1,318 +0,0 @@
// Must be imported first to set up globalThis.AsyncLocalStorage for Next.js
import "next/dist/server/node-environment-baseline";
// Set up undici HTTP agent override (from tasks app)
import { setGlobalDispatcher } from "undici";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
setGlobalDispatcher(new UndiciHttpAgent());
import { createServer } from "http";
import path from "path";
import next from "next";
import { parse } from "url";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import fastify from "fastify";
import { appRouter, createTRPCContext } from "@homarr/api/websocket";
import { getSessionFromToken, sessionTokenCookieName } from "@homarr/auth";
import { env as authEnv } from "@homarr/auth/env";
import { parseCookies } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { JobRouter } from "@homarr/cron-job-api";
import { jobRouter } from "@homarr/cron-job-api";
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "@homarr/cron-job-api/constants";
import { jobGroup } from "@homarr/cron-jobs";
import { db, eq, inArray } from "@homarr/db";
import { cronJobConfigurations, sessions, users } from "@homarr/db/schema";
import { supportedAuthProviders } from "@homarr/definitions";
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
import { schedule, validate as validateCron } from "node-cron";
const logger = createLogger({ module: "customServer" });
// --- JobManager (from apps/tasks/src/job-manager.ts) ---
import type { IJobManager } from "@homarr/cron-job-api";
import type { jobGroup as CronJobGroup, JobGroupKeys } from "@homarr/cron-jobs";
import type { Database, InferInsertModel } from "@homarr/db";
class JobManager implements IJobManager {
constructor(
private db: Database,
private jobGroup: typeof CronJobGroup,
) {}
public async startAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.startAsync(name);
}
public async triggerAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.runManuallyAsync(name);
}
public async stopAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.stopAsync(name);
}
public async updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void> {
logger.info("Updating cron job interval", { name, expression: cron });
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
if (!validateCron(cron)) {
throw new Error(`Invalid cron expression: ${cron}`);
}
if (job.preventCustomInterval && cron !== job.cronExpression) {
throw new Error(`Custom cron expressions are not allowed for job ${name}`);
}
await this.updateConfigurationAsync(name, { cronExpression: cron });
await this.jobGroup.getTask(name)?.destroy();
this.jobGroup.setTask(
name,
schedule(cron, () => void job.executeAsync(), {
name,
}),
);
logger.info("Cron job interval updated", { name, expression: cron });
}
public async disableAsync(name: JobGroupKeys): Promise<void> {
logger.info("Disabling cron job", { name });
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
await this.updateConfigurationAsync(name, { isEnabled: false });
await this.jobGroup.stopAsync(name);
logger.info("Cron job disabled", { name });
}
public async enableAsync(name: JobGroupKeys): Promise<void> {
logger.info("Enabling cron job", { name });
await this.updateConfigurationAsync(name, { isEnabled: true });
await this.jobGroup.startAsync(name);
logger.info("Cron job enabled", { name });
}
private async updateConfigurationAsync(
name: JobGroupKeys,
configuration: Omit<Partial<InferInsertModel<typeof cronJobConfigurations>>, "name">,
) {
const existingConfig = await this.db.query.cronJobConfigurations.findFirst({
where: (table, { eq }) => eq(table.name, name),
});
if (existingConfig) {
await this.db
.update(cronJobConfigurations)
.set({ ...configuration, name: undefined })
.where(eq(cronJobConfigurations.name, name));
return;
}
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
await this.db.insert(cronJobConfigurations).values({
name,
cronExpression: configuration.cronExpression ?? job.cronExpression,
isEnabled: configuration.isEnabled ?? true,
});
}
public async getAllAsync(): Promise<
{
name: JobGroupKeys;
cron: string;
preventManualExecution: boolean;
preventCustomInterval: boolean;
isEnabled: boolean;
}[]
> {
const configurations = await this.db.query.cronJobConfigurations.findMany();
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
const config = configurations.find((config) => config.name === name);
return {
name,
cron: config?.cronExpression ?? job.cronExpression,
preventManualExecution: job.preventManualExecution,
preventCustomInterval: job.preventCustomInterval,
isEnabled: config?.isEnabled ?? true,
};
});
}
}
// --- On-start functions (from apps/tasks/src/on-start/) ---
async function cleanupSessionsAsync() {
try {
const currentAuthProviders = authEnv.AUTH_PROVIDERS;
const inactiveAuthProviders = supportedAuthProviders.filter((provider) => !currentAuthProviders.includes(provider));
const subQuery = db
.select({ id: users.id })
.from(users)
.where(inArray(users.provider, inactiveAuthProviders))
.as("sq");
const sessionsWithInactiveProviders = await db
.select({ userId: sessions.userId })
.from(sessions)
.rightJoin(subQuery, eq(sessions.userId, subQuery.id));
const userIds = sessionsWithInactiveProviders.map(({ userId }) => userId).filter((value) => value !== null);
await db.delete(sessions).where(inArray(sessions.userId, userIds));
if (sessionsWithInactiveProviders.length > 0) {
logger.info("Deleted sessions for inactive providers", { count: userIds.length });
}
} catch (error) {
logger.error(new Error("Failed to clean up sessions", { cause: error }));
}
}
async function invalidateUpdateCheckerCacheAsync() {
try {
const handler = updateCheckerRequestHandler.handler({});
await handler.invalidateAsync();
logger.debug("Update checker cache invalidated");
} catch (error) {
logger.error(new Error("Failed to invalidate update checker cache", { cause: error }));
}
}
// --- Tasks/Cron Fastify server setup ---
async function startTasksServer() {
const tasksLogger = createLogger({ module: "tasksMain" });
const tasksServer = fastify({ maxParamLength: 5000 });
tasksServer.register(fastifyTRPCPlugin, {
prefix: CRON_JOB_API_PATH,
trpcOptions: {
router: jobRouter,
createContext: ({ req }) => ({
manager: new JobManager(db, jobGroup),
apiKey: req.headers[CRON_JOB_API_KEY_HEADER] as string | undefined,
}),
onError({ path, error }) {
tasksLogger.error(new ErrorWithMetadata("Error in tasks tRPC handler", { path }, { cause: error }));
},
} satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"],
});
// Run on-start tasks
await cleanupSessionsAsync();
await invalidateUpdateCheckerCacheAsync();
// Initialize and start cron jobs
await jobGroup.initializeAsync();
await jobGroup.startAllAsync();
try {
await tasksServer.listen({ port: CRON_JOB_API_PORT });
tasksLogger.info("Tasks web server started successfully", { port: CRON_JOB_API_PORT });
} catch (err) {
tasksLogger.error(
new ErrorWithMetadata("Failed to start tasks web server", { port: CRON_JOB_API_PORT }, { cause: err }),
);
}
return tasksServer;
}
// --- Main server setup ---
const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOSTNAME || "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
// Resolve the directory of this file so Next.js can find the app/pages dirs
const dir = path.resolve(__dirname);
const app = next({ dev, hostname, port, dir });
const handle = app.getRequestHandler();
const upgradeHandler = app.getUpgradeHandler();
app.prepare().then(async () => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
});
// Create WebSocket server in noServer mode so we handle upgrade manually
const wss = new WebSocketServer({ noServer: true });
const wssHandler = applyWSSHandler({
wss,
router: appRouter,
// eslint-disable-next-line no-restricted-syntax
createContext: async ({ req }) => {
try {
const headers = Object.entries(req.headers).map(
([key, value]) => [key, typeof value === "string" ? value : value?.[0]] as [string, string],
);
const nextHeaders = new Headers(headers);
const store = parseCookies(nextHeaders.get("cookie") ?? "");
const sessionToken = store[sessionTokenCookieName];
const session = await getSessionFromToken(db, sessionToken);
return createTRPCContext({
headers: nextHeaders,
session,
});
} catch (error) {
logger.error(error);
return createTRPCContext({
headers: new Headers(),
session: null,
});
}
},
keepAlive: {
enabled: true,
pingMs: 30000,
pongWaitMs: 5000,
},
});
// Handle upgrade requests: route /websockets to our WS server,
// delegate everything else (e.g. HMR _next/webpack-hmr) to Next.js
server.on("upgrade", (req, socket, head) => {
if (req.url?.startsWith("/websockets")) {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
} else {
upgradeHandler(req, socket, head);
}
});
wss.on("connection", (websocket, incomingMessage) => {
logger.info(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`);
websocket.once("close", (code, reason) => {
logger.info(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
});
});
// Start the integrated tasks/cron server
const tasksServer = await startTasksServer();
server.once("error", (err) => {
logger.error(new ErrorWithMetadata("Server error", {}, { cause: err }));
wssHandler.broadcastReconnectNotification();
wss.close();
tasksServer.close().catch((closeError) => logger.error("Failed to close tasks server", closeError));
server.close(() => process.exit(1));
});
server.listen(port, () => {
logger.info(`✅ Custom server ready on http://${hostname}:${port}`);
logger.info(`✅ WebSocket Server integrated on ws://${hostname}:${port}/websockets`);
logger.info(`✅ Tasks/Cron server integrated on port ${CRON_JOB_API_PORT}`);
});
process.on("SIGTERM", () => {
logger.info("SIGTERM");
wssHandler.broadcastReconnectNotification();
wss.close();
tasksServer.close().catch((closeError) => logger.error("Failed to close tasks server", closeError));
server.close();
});
});
@@ -32,12 +32,13 @@ const getWebSocketProtocol = () => {
};
const constructWebsocketUrl = () => {
const fallback = `${getWebSocketProtocol()}://localhost:3001/websockets`;
if (typeof window === "undefined") {
return `${getWebSocketProtocol()}://localhost:3000/websockets`;
return fallback;
}
if (env.NODE_ENV === "development") {
return `${getWebSocketProtocol()}://localhost:3000/websockets`;
return fallback;
}
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
@@ -64,23 +64,16 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
<Stack gap="xl">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}
id="username"
autoComplete="username"
{...form.getInputProps("username")}
/>
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
id="password"
autoComplete="new-password"
{...form.getInputProps("password")}
/>
<PasswordInput
label={t("field.passwordConfirm.label")}
id="password-confirm"
autoComplete="new-password"
{...form.getInputProps("confirmPassword")}
/>
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { z } from "zod/v4";
import { z } from "zod";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -116,18 +116,8 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
<>
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}
id="username"
autoComplete="username"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("field.password.label")}
id="password"
autoComplete="current-password"
{...form.getInputProps("password")}
/>
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{providers.includes("credentials") && (
<Stack gap="sm">
@@ -11,9 +11,8 @@ import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -23,8 +22,6 @@ import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
export type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
@@ -60,13 +57,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
for (const [kind, items] of itemsMap) {
await prefetchForKindAsync(kind, queryClient, items).catch((error) => {
logger.error(
new ErrorWithMetadata(
"Failed to prefetch widget",
{ widgetKind: kind, itemCount: items.length },
{ cause: error },
),
);
logger.error(new Error("Failed to prefetch widget", { cause: error }));
});
}
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
@@ -18,17 +19,14 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
@@ -44,7 +42,7 @@ export const BoardContentHeaderActions = () => {
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
return null; // Hide actions for user without access
}
return (
@@ -63,7 +61,6 @@ export const BoardContentHeaderActions = () => {
};
const AddMenu = () => {
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
@@ -98,13 +95,12 @@ const AddMenu = () => {
const handleSelectApp = useCallback(() => {
openAppSelectModal({
onSelect: (app) => {
onSelect: (appId) => {
createItem({
kind: "app",
options: { appId: app.id },
options: { appId },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
}, [openAppSelectModal, createItem]);
@@ -169,7 +165,7 @@ const EditModeMenu = () => {
open();
}, [board, isEditMode, saveBoard, open]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode);
return (
@@ -17,7 +17,6 @@ import {
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
@@ -103,25 +102,12 @@ export const ColorSettingsContent = ({ board }: Props) => {
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("iconColor")}
/>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
@@ -120,14 +120,11 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
onSuccess={({ url }) =>
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
form.setFieldValue("backgroundImageUrl", url);
})
}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
@@ -3,7 +3,7 @@
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { createId } from "@homarr/db/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
@@ -61,10 +61,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error;
}
};
@@ -6,7 +6,7 @@ import { TRPCError } from "@trpc/server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { logger } from "@homarr/log";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -18,8 +18,6 @@ import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
@@ -47,10 +45,6 @@ export const createBoardLayout = <TParams extends Params>({
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error;
});
const colorScheme = await getCurrentColorSchemeAsync();
@@ -1,3 +1,4 @@
import Link from "next/link";
import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
@@ -7,7 +8,6 @@ import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => {
@@ -2,7 +2,7 @@
import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -12,11 +12,7 @@ import { groupCreateSchema } from "@homarr/validation/group";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation();
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
@@ -25,6 +21,9 @@ export const InitGroup = () => {
const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
@@ -1,8 +1,9 @@
"use client";
import { startTransition } from "react";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -16,15 +17,25 @@ import { settingsInitSchema } from "@homarr/validation/settings";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation();
const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings });
form.watch("analytics.enableGeneral", ({ value }) => {
if (!value) {
startTransition(() => {
form.setFieldValue("analytics.enableWidgetData", false);
form.setFieldValue("analytics.enableIntegrationData", false);
form.setFieldValue("analytics.enableUserData", false);
});
}
});
const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => {
await mutateAsync(values);
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
};
return (
@@ -36,6 +47,24 @@ export const InitSettings = () => {
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
<Stack gap="xs" ps="md" w="100%">
<AnalyticsRow
kind="integrationData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableWidgetData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="widgetData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableIntegrationData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="usersData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableUserData", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Stack>
</Card>
@@ -1,7 +1,7 @@
"use client";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
@@ -26,19 +26,19 @@ export const InitUserForm = () => {
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, {
onSuccess() {
async onSuccess() {
showSuccessNotification({
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
});
void signIn("credentials", {
await signIn("credentials", {
name: values.username,
password: values.password,
redirect: false,
}).then(async () => {
await revalidatePathActionAsync("/init");
});
await revalidatePathActionAsync("/init");
},
onError: (error) => {
showErrorNotification({
+1 -6
View File
@@ -23,7 +23,6 @@ import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
@@ -106,7 +105,6 @@ export default async function Layout(props: {
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
}}
{...innerProps}
/>
@@ -120,12 +118,10 @@ export default async function Layout(props: {
(innerProps) => <SpotlightProvider {...innerProps} />,
]);
const { locale } = await props.params;
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
lang={locale}
lang={(await props.params).locale}
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
@@ -136,7 +132,6 @@ export default async function Layout(props: {
<head>
<Analytics />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider>
@@ -1,32 +1,51 @@
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import classes from "./hero-banner.module.css";
const icons = Object.values(integrationDefs)
.filter((int) => int.name !== "Mock")
.map((int) => int.iconUrl);
const icons = [
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
];
const countIconGroups = 3;
const animationDurationInSeconds = icons.length;
const animationDurationInSeconds = 12;
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
export const HeroBanner = async () => {
const t = await getScopedI18n("management.page.home");
export const HeroBanner = () => {
const gridSpan = 12 / countIconGroups;
return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
<Stack gap={0}>
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
{t("heroBanner.title")}
Welcome back to your
</Title>
<Group gap="xs" wrap="nowrap">
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
<Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
</Group>
</Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
@@ -42,26 +61,12 @@ export const HeroBanner = async () => {
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
radius="md"
fit={"contain"}
w={50}
h={50}
/>
<Image key={`grid-column-${columnIndex}-scroll-1-${index}`} src={icon} radius="md" w={50} h={50} />
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
radius="md"
fit={"contain"}
w={50}
h={50}
/>
<Image key={`grid-column-${columnIndex}-scroll-2-${index}`} src={icon} radius="md" w={50} h={50} />
))}
</Stack>
</GridCol>
@@ -11,23 +11,14 @@ import {
Center,
Flex,
Group,
Kbd,
List,
ListItem,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from "@mantine/core";
import { IconKeyboard, IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
@@ -158,45 +149,6 @@ export default async function AboutPage() {
</List>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="hotkeys">
<AccordionControl icon={<IconKeyboard size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.hotkeys.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.hotkeys.subtitle")}
</Text>
</Stack>
</AccordionControl>
<AccordionPanel>
<Table>
<TableThead>
<TableTr>
<TableTh>{t("accordion.hotkeys.field.shortcut")}</TableTh>
<TableTh>{t("accordion.hotkeys.field.action")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{objectEntries(hotkeys).map(([key, shortcut]) => (
<TableTr key={key}>
<TableTd>
<Kbd size="md">
{shortcut
.split("+")
.map((key) => capitalize(key.trim()))
.join(" + ")}
</Kbd>
</TableTd>
<TableTd>{t(`accordion.hotkeys.action.${key}`)}</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
<Text size="sm" c="dimmed">
{t("accordion.hotkeys.note")}
</Text>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod/v4";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -1,15 +1,15 @@
import { Fragment } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { SearchInput, TablePagination } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -98,12 +98,7 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text>
{app.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{app.description.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
{app.description}
</Text>
)}
{app.href && (
@@ -1,6 +1,7 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
@@ -11,7 +12,6 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -84,6 +84,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
id: board.id,
name: board.name,
},
onSuccess: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
}, [board.id, board.name, openDuplicateModal]);
@@ -1,3 +1,4 @@
import Link from "next/link";
import {
ActionIcon,
Badge,
@@ -20,7 +21,7 @@ import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ManageContainer } from "~/components/manage/manage-container";
@@ -1,15 +1,4 @@
import {
IconCode,
IconGrid3x3,
IconKey,
IconLink,
IconMessage,
IconPassword,
IconPasswordUser,
IconPlug,
IconServer,
IconUser,
} from "@tabler/icons-react";
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -20,12 +9,4 @@ export const integrationSecretIcons = {
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
personalAccessToken: IconPasswordUser,
topic: IconMessage,
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -1,7 +1,7 @@
"use client";
import type { ChangeEventHandler, FocusEventHandler } from "react";
import { PasswordInput, Textarea, TextInput } from "@mantine/core";
import { PasswordInput, TextInput } from "@mantine/core";
import { integrationSecretKindObject } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
@@ -14,9 +14,9 @@ interface IntegrationSecretInputProps {
label?: string;
kind: IntegrationSecretKind;
value?: string;
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onFocus?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onBlur?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onChange: ChangeEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
error?: string;
}
@@ -31,19 +31,6 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
minRows={2}
/>
);
}
return (
<TextInput
@@ -58,21 +45,6 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t("integration.secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
minRows={2}
/>
);
}
return (
<PasswordInput
@@ -1,62 +0,0 @@
import { useCallback } from "react";
import { SegmentedControl } from "@mantine/core";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
interface FormType {
secrets: { kind: IntegrationSecretKind; value: string | null }[];
}
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
defaultKinds?: IntegrationSecretKind[];
secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<TFormType, (values: TFormType) => TFormType>;
}
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
defaultKinds,
secretKinds,
form,
}: SecretKindsSegmentedControlProps<TFormType>) => {
const t = useScopedI18n("integration.secrets");
const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-");
const secretKindGroups = secretKinds.map((kinds) => ({
label:
kinds.length === 0
? t("noSecretsRequired.segmentTitle")
: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
value: kinds.length === 0 ? "empty" : kinds.join("-"),
}));
const onChange = useCallback(
(value: string) => {
if (value === "empty") {
const emptyValues = [] satisfies FormType["secrets"];
// @ts-expect-error somehow it is not able to understand that secrets is an array?
form.setFieldValue("secrets", emptyValues);
return;
}
const kinds = value.split("-") as IntegrationSecretKind[];
const secrets = kinds.map((kind) => ({
kind,
value: "",
})) satisfies FormType["secrets"];
// @ts-expect-error somehow it is not able to understand that secrets is an array?
form.setFieldValue("secrets", secrets);
},
[form],
);
return (
<SegmentedControl
fullWidth
data={secretKindGroups}
defaultValue={defaultValue}
onChange={onChange}
></SegmentedControl>
);
};
@@ -4,7 +4,8 @@ import { IconAlertTriangle, IconCheck, IconCopy, IconExclamationCircle, IconRepe
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, getMantineColor } from "@homarr/common";
import { getMantineColor } from "@homarr/common";
import { createId } from "@homarr/db/client";
import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
@@ -29,8 +30,6 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const rootCertificate = getHeighestCertificate(error.data.certificate);
const handleTrustHostname = () => {
const { hostname } = new URL(url);
openConfirmModal({
@@ -73,7 +72,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
new File([error.data.certificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
@@ -111,11 +110,11 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
<>
{description}
<CertificateDetailsCard certificate={rootCertificate} />
<CertificateDetailsCard certificate={error.data.certificate} />
{error.data.reason === "hostnameMismatch" && <HostnameMismatchAlert />}
{!rootCertificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />}
{!error.data.certificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />}
{showRetryButton && (
<Button
@@ -128,7 +127,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
</Button>
)}
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
{(error.data.reason === "untrusted" && error.data.certificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<Button
variant="default"
@@ -138,7 +137,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{tError("certificate.action.trust.label")}
</Button>
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
{error.data.reason === "untrusted" && !error.data.certificate.isSelfSigned ? (
<Button
variant="default"
fullWidth
@@ -326,8 +325,3 @@ const PemContentModal = createModal<{ content: string }>(({ actions, innerProps
},
size: "lg",
});
const getHeighestCertificate = (certificate: MappedCertificate): MappedCertificate => {
if (certificate.issuerCertificate) return getHeighestCertificate(certificate.issuerCertificate);
return certificate;
};
@@ -1,27 +1,23 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
import { z } from "zod/v4";
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { integrationUpdateSchema } from "@homarr/validation/integration";
import { SecretCard } from "../../_components/secrets/integration-secret-card";
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
import { SecretKindsSegmentedControl } from "../../_components/secrets/integration-secret-segmented-control";
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
@@ -29,41 +25,23 @@ interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"];
}
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
z.object({
app: z
.object({
id: z.string(),
name: z.string(),
iconUrl: z.string(),
href: z.string().nullable(),
})
.nullable(),
}),
);
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind);
const initialSecretsKinds =
const secretsKinds =
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(integration.kind);
const hasUrlSecret = initialSecretsKinds.includes("url");
const router = useRouter();
const form = useZodForm(formSchema, {
const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
initialValues: {
name: integration.name,
url: integration.url,
secrets: initialSecretsKinds.map((kind) => ({
secrets: secretsKinds.map((kind) => ({
kind,
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})),
app: integration.app ?? null,
},
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
@@ -71,20 +49,15 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(
{
id: integration.id,
...values,
url,
secrets: values.secrets.map((secret) => ({
kind: secret.kind,
value: secret.value === "" ? null : secret.value,
})),
appId: app?.id ?? null,
},
{
onSuccess: (data) => {
@@ -114,80 +87,48 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
);
};
const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length;
return (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl
defaultKinds={initialSecretsKinds}
secretKinds={allSecretKinds}
form={form}
/>
)}
{!isInitialSecretKinds
? null
: form.values.secrets.map((secret, index) => (
<SecretCard
key={secret.kind}
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
resolve(true);
},
});
})
{secretsKinds.map((kind, index) => (
<SecretCard
key={kind}
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
return resolve(true);
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
resolve(true);
},
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${kind}.newLabel`)}
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
</Stack>
</Fieldset>
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
<Group justify="end" align="center">
@@ -203,80 +144,4 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
);
};
type FormType = z.infer<typeof formSchema>;
interface IntegrationAppSelectProps {
value: FormType["app"];
onChange: (app: FormType["app"]) => void;
}
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
const handleChange = () =>
openModal(
{
onSelect: onChange,
withCreate: canCreateApps,
},
{
title: t("integration.page.edit.app.action.select"),
},
);
if (!value) {
return (
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
fullWidth
onClick={handleChange}
>
{t("integration.page.edit.app.action.add")}
</Button>
);
}
return (
<Fieldset legend={t("integration.field.app.sectionTitle")}>
<Group justify="space-between">
<Group gap="sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={value.iconUrl} alt={value.name} width={32} height={32} />
<Stack gap={0}>
<Text size="sm" fw="bold">
{value.name}
</Text>
{value.href !== null && (
<Anchor href={value.href} target="_blank" rel="noopener noreferrer" size="sm">
{value.href}
</Anchor>
)}
</Stack>
</Group>
<ButtonGroup>
<Button
variant="subtle"
color="gray"
leftSection={<IconUnlink size={16} stroke={1.5} />}
onClick={() => onChange(null)}
>
{t("integration.page.edit.app.action.remove")}
</Button>
<Button
variant="subtle"
color="gray"
leftSection={<IconPencil size={16} stroke={1.5} />}
onClick={handleChange}
>
{t("common.action.change")}
</Button>
</ButtonGroup>
</Group>
</Fieldset>
);
};
type FormType = Omit<z.infer<typeof integrationUpdateSchema>, "id">;
@@ -2,27 +2,23 @@
import type { ChangeEvent } from "react";
import React, { useMemo, useState } from "react";
import Link from "next/link";
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, Link } from "@homarr/ui";
import { IntegrationAvatar } from "@homarr/ui";
interface IntegrationCreateDropdownContentProps {
enableMockIntegration: boolean;
}
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
export const IntegrationCreateDropdownContent = () => {
const t = useI18n();
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds
.filter((kind) => enableMockIntegration || kind !== "mock")
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
}, [search, enableMockIntegration]);
return integrationKinds.filter((kind) =>
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
);
}, [search]);
const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
@@ -1,6 +1,7 @@
"use client";
import { startTransition, useState } from "react";
import { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Alert,
@@ -9,38 +10,26 @@ import {
Collapse,
Fieldset,
Group,
Loader,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
import { z } from "zod/v4";
import { IconInfoCircle } from "@tabler/icons-react";
import { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { Modify } from "@homarr/common/types";
import type { IntegrationKind } from "@homarr/definitions";
import {
getAllSecretKindOptions,
getIconUrl,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
} from "@homarr/definitions";
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { appHrefSchema } from "@homarr/validation/app";
import { integrationCreateSchema } from "@homarr/validation/integration";
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
import { SecretKindsSegmentedControl } from "../_components/secrets/integration-secret-segmented-control";
import { IntegrationTestConnectionError } from "../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
@@ -50,77 +39,51 @@ interface NewIntegrationFormProps {
};
}
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
const formSchema = integrationCreateSchema.omit({ kind: true }).and(
z.object({
hasApp: z.boolean(),
createApp: z.boolean(),
appHref: appHrefSchema,
appId: z.string().nullable(),
}),
);
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter();
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
if (hasUrlSecret) {
// Placeholder Url, replaced with origin of the secret Url on submit
url = "http://localhost";
}
const [opened, setOpened] = useState(false);
const form = useZodForm(formSchema, {
initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind),
url,
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
attemptSearchEngineCreation: true,
hasApp: false,
appHref: url,
appId: null,
createApp: false,
appHref: "",
},
onValuesChange(values, previous) {
if (values.createApp !== previous.createApp) {
setOpened(values.createApp);
}
},
});
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/integrations");
},
});
const { mutateAsync: createIntegrationAsync, isPending: isPendingIntegration } =
clientApi.integration.create.useMutation();
const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation();
const isPending = isPendingIntegration || isPendingApp;
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
const app = hasApp
? appId !== null
? { id: appId }
: {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(searchParams.kind),
description: null,
pingUrl: url,
}
: undefined;
const handleSubmitAsync = async (values: FormType) => {
await createIntegrationAsync(
{
kind: searchParams.kind,
...values,
url,
app,
},
{
onSuccess(data) {
async onSuccess(data) {
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
@@ -136,7 +99,32 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
message: t("integration.page.create.notification.success.message"),
});
router.push("/manage/integrations");
if (!values.createApp) {
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
return;
}
const hasCustomHref = values.appHref !== null && values.appHref.trim().length >= 1;
await createAppAsync(
{
name: values.name,
href: hasCustomHref ? values.appHref : values.url,
iconUrl: getIconUrl(searchParams.kind),
description: null,
pingUrl: values.url,
},
{
async onSettled() {
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError() {
showErrorNotification({
title: t("app.page.create.notification.error.title"),
message: t("app.page.create.notification.error.message"),
});
},
},
);
},
onError: () => {
showErrorNotification({
@@ -155,9 +143,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
@@ -190,7 +176,15 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
/>
)}
<AppForm form={form} canCreateApps={canCreateApps} />
<Checkbox
{...form.getInputProps("createApp", { type: "checkbox" })}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
/>
<Collapse in={opened}>
<TextInput placeholder={t("integration.field.appHref.placeholder")} {...form.getInputProps("appHref")} />
</Collapse>
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
@@ -205,115 +199,40 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
);
};
type FormType = z.infer<typeof formSchema>;
interface SecretKindsSegmentedControlProps {
secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
}
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => {
const t = useI18n();
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => {
const t = useScopedI18n("integration.secrets");
return (
<>
<Checkbox
{...checkboxInputProps}
onChange={(event) => {
startTransition(() => {
form.setFieldValue("appHref", event.currentTarget.checked ? form.values.url : null);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
checkboxInputProps.onChange(event);
});
}}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
/>
const secretKindGroups = secretKinds.map((kinds) => ({
label:
kinds.length === 0
? t("noSecretsRequired.segmentTitle")
: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
value: kinds.length === 0 ? "empty" : kinds.join("-"),
}));
<Collapse in={form.values.hasApp}>
<Fieldset legend={t("integration.field.app.sectionTitle")}>
<Stack gap="sm">
{canCreateApps && (
<SegmentedControl
data={(["new", "existing"] as const).map((value) => ({
value,
label: t(`integration.page.create.app.option.${value}.title`),
}))}
value={form.values.appHref === null ? "existing" : "new"}
onChange={(value) => {
if (value === "existing") {
form.setFieldValue("appId", null);
form.setFieldValue("appHref", null);
} else {
form.setFieldValue("appId", null);
form.setFieldValue("appHref", form.values.url);
}
}}
/>
)}
{typeof form.values.appHref === "string" && canCreateApps ? (
<TextInput
placeholder={t("integration.field.appHref.placeholder")}
withAsterisk
label={t("integration.page.create.app.option.new.url.label")}
description={t("integration.page.create.app.option.new.url.description")}
{...form.getInputProps("appHref")}
/>
) : (
<IntegrationAppSelect {...form.getInputProps("appId")} />
)}
</Stack>
</Fieldset>
</Collapse>
</>
);
};
type IntegrationAppSelectProps = Modify<
GetInputPropsReturnType,
{
value?: string | null;
onChange: (value: string | null) => void;
}
>;
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
const { data, isPending } = clientApi.app.selectable.useQuery();
const t = useI18n();
const appMap = new Map(data?.map((app) => [app.id, app] as const));
return (
<Select
withAsterisk
label={t("integration.page.create.app.option.existing.label")}
searchable
clearable
leftSection={
// eslint-disable-next-line @next/next/no-img-element
value ? <img width={20} height={20} src={appMap.get(value)?.iconUrl} alt={appMap.get(value)?.name} /> : null
const onChange = useCallback(
(value: string) => {
if (value === "empty") {
form.setFieldValue("secrets", []);
return;
}
renderOption={({ option, checked }) => (
<Group flex="1" gap="xs">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img width={20} height={20} src={appMap.get(option.value)?.iconUrl} alt={option.label} />
<Stack gap={0}>
<Text>{option.label}</Text>
<Text size="xs" c="dimmed">
{appMap.get(option.value)?.href}
</Text>
</Stack>
{checked && (
<IconCheck
style={{ marginInlineStart: "auto" }}
stroke={1.5}
color="currentColor"
opacity={0.6}
size={18}
/>
)}
</Group>
)}
{...props}
data={data?.map((app) => ({ value: app.id, label: app.name }))}
rightSection={isPending ? <Loader size="sm" /> : null}
/>
const kinds = value.split("-") as IntegrationSecretKind[];
const secrets = kinds.map((kind) => ({
kind,
value: "",
}));
form.setFieldValue("secrets", secrets);
},
[form],
);
return <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>;
};
type FormType = z.infer<typeof formSchema>;
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation";
import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod/v4";
import { z } from "zod";
import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
@@ -1,5 +1,6 @@
import { Fragment } from "react";
import type { PropsWithChildren } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import {
AccordionControl,
@@ -35,12 +36,11 @@ import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -114,7 +114,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
<IntegrationCreateDropdownContent />
</MenuDropdown>
</Menu>
);
@@ -14,7 +14,7 @@ export const UploadMediaButton = () => {
};
return (
<UploadMedia onSettled={onSettledAsync} multiple>
<UploadMedia onSettled={onSettledAsync}>
{({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
@@ -1,3 +1,4 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ActionIcon,
@@ -15,7 +16,7 @@ import {
Tooltip,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { z } from "zod/v4";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -24,7 +25,7 @@ import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
+76 -21
View File
@@ -1,16 +1,25 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
export async function generateMetadata(): Promise<Metadata> {
interface LinkProps {
title: string;
subtitle: string;
count: number;
href: string;
hidden?: boolean;
}
export async function generateMetadata() {
const t = await getScopedI18n("management");
return {
@@ -20,32 +29,78 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function ManagementPage() {
const statistics = await api.home.getStats();
const session = await auth();
const t = await getScopedI18n("management.page.home");
const links: LinkProps[] = [
{
count: statistics.countBoards,
href: "/manage/boards",
subtitle: t("statisticLabel.boards"),
title: t("statistic.board"),
},
{
count: statistics.countUsers,
href: "/manage/users",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.user"),
hidden: !session?.user.permissions.includes("admin"),
},
{
count: statistics.countInvites,
href: "/manage/users/invites",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.invite"),
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
},
{
count: statistics.countIntegrations,
href: "/manage/integrations",
subtitle: t("statisticLabel.resources"),
title: t("statistic.integration"),
},
{
count: statistics.countApps,
href: "/manage/apps",
subtitle: t("statisticLabel.resources"),
title: t("statistic.app"),
hidden: !session?.user,
},
{
count: statistics.countGroups,
href: "/manage/users/groups",
subtitle: t("statisticLabel.authorization"),
title: t("statistic.group"),
hidden: !session?.user.permissions.includes("admin"),
},
];
return (
<>
<DynamicBreadcrumb />
<HeroBanner />
<Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => (
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="2.4rem" fw="bolder">
{statistic.count}
</Text>
<Stack gap={0}>
<Text c="red" size="xs">
{t(`statisticLabel.${statistic.subtitleKey}`)}
</Text>
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
))}
{links.map(
(link) =>
!link.hidden && (
<Card component={Link} href={link.href} key={link.href} radius="lg">
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="2.4rem" fw="bolder">
{link.count}
</Text>
<Stack gap={0}>
<Text c="red" size="xs">
{link.subtitle}
</Text>
<Text fw="bold">{link.title}</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
),
)}
</SimpleGrid>
</>
);
@@ -1,9 +1,10 @@
"use client";
import Link from "next/link";
import type { SegmentedControlItem } from "@mantine/core";
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
import type { z } from "zod/v4";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions";
@@ -11,7 +12,6 @@ import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { searchEngineManageSchema } from "@homarr/validation/search-engine";
type FormType = z.infer<typeof searchEngineManageSchema>;
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod/v4";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod/v4";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -1,14 +1,15 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react";
import { z } from "zod/v4";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { SearchInput, TablePagination } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -24,6 +24,15 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
return;
}
if (
!updatedValues.enableGeneral &&
(updatedValues.enableWidgetData || updatedValues.enableIntegrationData || updatedValues.enableUserData)
) {
updatedValues.enableIntegrationData = false;
updatedValues.enableUserData = false;
updatedValues.enableWidgetData = false;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
@@ -45,6 +54,30 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
<SwitchSetting
form={form}
formKey="enableIntegrationData"
ms="xl"
title={t("integrationData.title")}
text={t("integrationData.text")}
disabled={!form.values.enableGeneral}
/>
<SwitchSetting
form={form}
formKey="enableWidgetData"
ms="xl"
title={t("widgetData.title")}
text={t("widgetData.text")}
disabled={!form.values.enableGeneral}
/>
<SwitchSetting
form={form}
formKey="enableUserData"
ms="xl"
title={t("usersData.title")}
text={t("usersData.text")}
disabled={!form.values.enableGeneral}
/>
</Stack>
</Card>
</>
@@ -1,26 +0,0 @@
"use client";
import { Switch } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
const tUser = useScopedI18n("management.page.settings.section.user");
return (
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
{(form) => (
<>
<Switch
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
label={tUser("enableGravatar.label")}
description={tUser("enableGravatar.description")}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -12,7 +12,6 @@ import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -43,10 +42,6 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.user.title")}</Title>
<UserSettingsForm defaultValues={serverSettings.user} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} />
@@ -15,11 +15,7 @@ interface RemoveCertificateProps {
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
const t = useI18n();
const handleClick = () => {
@@ -31,11 +27,12 @@ export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
await mutateAsync(
{ fileName },
{
onSuccess() {
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.remove.notification.success.title"),
message: t("certificate.action.remove.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
},
onError() {
showErrorNotification({
@@ -15,11 +15,7 @@ interface RemoveHostnameActionIconProps {
}
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
},
});
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation();
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
@@ -30,7 +26,8 @@ export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) =
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(input, {
onSuccess() {
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
showSuccessNotification({
title: t("certificate.action.removeHostname.notification.success.title"),
message: t("certificate.action.removeHostname.notification.success.message"),
@@ -1,4 +1,5 @@
import { X509Certificate } from "node:crypto";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
Button,
@@ -16,9 +17,8 @@ import {
import { IconCertificateOff } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { getTrustedCertificateHostnamesAsync } from "@homarr/core/infrastructure/certificates";
import { getTrustedCertificateHostnamesAsync } from "@homarr/certificates/server";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
@@ -1,15 +1,15 @@
import { X509Certificate } from "node:crypto";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Button, Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconCertificate, IconCertificateOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import { auth } from "@homarr/auth/next";
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
import { getMantineColor } from "@homarr/common";
import { loadCustomRootCertificatesAsync } from "@homarr/core/infrastructure/certificates";
import type { SupportedLanguage } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
@@ -76,7 +76,6 @@ const createColumns = (
accessorKey: "ports",
header: t("docker.field.ports.label"),
Cell({ cell }) {
if (!cell.row.original.ports.length) return null;
return (
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} />
);
@@ -207,16 +206,15 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
});
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const handleClickAsync = async () => {
await mutateAsync(
{ ids: props.selectedIds },
{
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${props.action}.notification.success.title`),
@@ -1,10 +1,10 @@
"use client";
import Link from "next/link";
import { Anchor, Center, Stack, Text } from "@mantine/core";
import { IconShipOff } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
export default function DockerErrorPage() {
const t = useI18n();
@@ -3,7 +3,7 @@
import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { createId } from "@homarr/db/client";
import type { KubernetesLabelResourceType } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -1,10 +1,10 @@
"use client";
import Link from "next/link";
import { Anchor, Center, Stack, Text } from "@mantine/core";
import { IconCubeOff } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
export default function KubernetesErrorPage() {
const t = useI18n();
@@ -1,11 +1,11 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { Card, Group, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { KubernetesLabelResourceType } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import classes from "./resource-tile.module.css";
@@ -10,7 +10,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { createId } from "@homarr/db/client";
import type { KubernetesIngress } from "@homarr/definitions";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
@@ -8,7 +8,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { createId } from "@homarr/db/client";
import type { KubernetesService } from "@homarr/definitions";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
@@ -1,26 +0,0 @@
"use client";
import { Select } from "@mantine/core";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
import { useI18n } from "@homarr/translation/client";
import { useLogContext } from "./log-context";
export const LogLevelSelection = () => {
const { level, setLevel } = useLogContext();
const t = useI18n();
return (
<Select
data={logLevels.map((level) => ({
value: level,
label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`,
}))}
value={level}
onChange={(value) => setLevel(value as LogLevel)}
checkIconPosition="right"
/>
);
};
@@ -1,32 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
const LogContext = createContext<{
level: LogLevel;
setLevel: (level: LogLevel) => void;
activeLevels: LogLevel[];
} | null>(null);
interface LogContextProviderProps extends PropsWithChildren {
defaultLevel: LogLevel;
}
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
const [level, setLevel] = useState(defaultLevel);
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
return <LogContext.Provider value={{ level, setLevel, activeLevels }}>{children}</LogContext.Provider>;
};
export const useLogContext = () => {
const context = useContext(LogContext);
if (!context) {
throw new Error("useLogContext must be used within a LogContextProvider");
}
return context;
};
@@ -1,4 +1,4 @@
import { Box, Group } from "@mantine/core";
import { Box } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
@@ -7,14 +7,11 @@ import "@xterm/xterm/css/xterm.css";
import { notFound } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { logsEnv } from "@homarr/core/infrastructure/logs/env";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client";
import { LogLevelSelection } from "./level-selection";
import { LogContextProvider } from "./log-context";
export async function generateMetadata() {
const session = await auth();
@@ -35,14 +32,11 @@ export default async function LogsManagementPage() {
}
return (
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb />
<LogLevelSelection />
</Group>
<>
<DynamicBreadcrumb />
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent />
</Box>
</LogContextProvider>
</>
);
}
@@ -8,29 +8,22 @@ import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client";
import { useLogContext } from "./log-context";
import classes from "./terminal.module.css";
export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels } = useLogContext();
const terminalRef = useRef<Terminal>(null);
clientApi.log.subscribe.useSubscription(
{
levels: activeLevels,
clientApi.log.subscribe.useSubscription(undefined, {
onData(data) {
terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
},
{
onData(data) {
terminalRef.current?.writeln(data.message);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
},
onError(err) {
// This makes sense as logging might cause an infinite loop
alert(err);
},
onError(err) {
// This makes sense as logging might cause an infinite loop
alert(err);
},
);
});
useEffect(() => {
if (!ref.current) {
@@ -0,0 +1,95 @@
"use client";
import React from "react";
import { ActionIcon, Badge, Card, Group, Stack, Text } from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlayerPlay } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { TaskStatus } from "@homarr/cron-job-status";
import type { TranslationKeys } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
interface JobsListProps {
initialJobs: RouterOutputs["cronJobs"]["getJobs"];
}
interface JobState {
job: JobsListProps["initialJobs"][number];
status: TaskStatus | null;
}
export const JobsList = ({ initialJobs }: JobsListProps) => {
const t = useScopedI18n("management.page.tool.tasks");
const [jobs, handlers] = useListState<JobState>(
initialJobs.map((job) => ({
job,
status: null,
})),
);
clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, {
onData: (data) => {
const jobByName = jobs.find((job) => job.job.name === data.name);
if (!jobByName) {
return;
}
handlers.applyWhere(
(job) => job.job.name === data.name,
(job) => ({ ...job, status: data }),
);
},
});
const { mutateAsync } = clientApi.cronJobs.triggerJob.useMutation();
const handleJobTrigger = React.useCallback(
async (job: JobState) => {
if (job.status?.status === "running") {
return;
}
await mutateAsync(job.job.name);
},
[mutateAsync],
);
return (
<Stack>
{jobs.map((job) => (
<Card key={job.job.name} withBorder>
<Group justify={"space-between"} gap={"md"}>
<Stack gap={0}>
<Group>
<Text>{t(`job.${job.job.name}.label` as TranslationKeys)}</Text>
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
</Group>
{job.status && <TimeAgo timestamp={job.status.lastExecutionTimestamp} />}
</Stack>
{!job.job.preventManualExecution && (
<ActionIcon
onClick={() => handleJobTrigger(job)}
disabled={job.status?.status === "running"}
variant={"default"}
size={"xl"}
radius={"xl"}
>
<IconPlayerPlay stroke={1.5} />
</ActionIcon>
)}
</Group>
</Card>
))}
</Stack>
);
};
const TimeAgo = ({ timestamp }: { timestamp: string }) => {
const timeAgo = useTimeAgo(new Date(timestamp));
return (
<Text size={"sm"} c={"dimmed"}>
{timeAgo}
</Text>
);
};
@@ -1,457 +0,0 @@
"use client";
import { ActionIcon, Badge, Button, Group, Select, Text } from "@mantine/core";
import { useMap } from "@mantine/hooks";
import { IconPlayerPlay, IconPower, IconRefresh } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { TaskStatus } from "@homarr/cron-job-status";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ScopedTranslationFunction, TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { IconPowerOff } from "@homarr/ui/icons";
const cronExpressions = [
{
value: "*/1 * * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 1 }),
},
{
value: "*/5 * * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 5 }),
},
{
value: "*/10 * * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 10 }),
},
{
value: "*/20 * * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 20 }),
},
{
value: "*/30 * * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 30 }),
},
{
value: "* * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.minutes", { interval: 1 }),
},
{
value: "*/5 * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.minutes", { interval: 5 }),
},
{
value: "*/10 * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.minutes", { interval: 10 }),
},
{
value: "*/15 * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.minutes", { interval: 15 }),
},
// Every hour
{
value: "0 * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.hours", { interval: 1 }),
},
// Every two hours
{
value: "0 */2 * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.hours", { interval: 2 }),
},
// Every four hours
{
value: "0 */4 * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.hours", { interval: 4 }),
},
// Every midnight
{
value: "0 0 * * */1",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.midnight"),
},
{
value: "0 0 * * 1",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.weeklyMonday"),
},
] satisfies { value: string; label: (t: TranslationFunction) => string }[];
type JobData = RouterOutputs["cronJobs"]["getJobs"][number] & {
status?: TaskStatus | null;
lastExecutionTime?: string;
};
const createColumns = (
t: TranslationFunction,
tTasks: ScopedTranslationFunction<"management.page.tool.tasks">,
jobStatusMap: Map<string, TaskStatus | null>,
triggerMutation: ReturnType<typeof clientApi.cronJobs.triggerJob.useMutation>,
updateIntervalMutation: ReturnType<typeof clientApi.cronJobs.updateJobInterval.useMutation>,
enableMutation: ReturnType<typeof clientApi.cronJobs.enableJob.useMutation>,
disableMutation: ReturnType<typeof clientApi.cronJobs.disableJob.useMutation>,
loadingStates: Map<string, { toggle: boolean; trigger: boolean; interval: boolean }>,
): MRT_ColumnDef<JobData>[] => [
{
accessorKey: "name",
header: tTasks("field.name.label"),
Cell({ row }) {
const status = jobStatusMap.get(row.original.name);
return (
<Group gap="xs">
<Text fw={500}>{tTasks(`job.${row.original.name}.label`)}</Text>
<StatusBadge isEnabled={row.original.isEnabled} status={status ?? null} />
{status?.lastExecutionStatus === "error" && (
<Badge color="red" size="sm">
{tTasks("status.error")}
</Badge>
)}
</Group>
);
},
},
{
accessorKey: "cron",
header: tTasks("field.interval.label"),
size: 200,
Cell({ row }) {
if (row.original.preventCustomInterval) return null;
const handleIntervalChange = (newCron: string | null) => {
if (!newCron || newCron === row.original.cron) return;
const currentStates = loadingStates.get(row.original.name) ?? {
toggle: false,
trigger: false,
interval: false,
};
loadingStates.set(row.original.name, {
...currentStates,
interval: true,
});
try {
updateIntervalMutation.mutate({
name: row.original.name,
cron: newCron,
});
} finally {
const updatedStates = loadingStates.get(row.original.name) ?? {
toggle: false,
trigger: false,
interval: false,
};
loadingStates.set(row.original.name, {
...updatedStates,
interval: false,
});
}
};
return (
<Select
value={row.original.cron}
onChange={handleIntervalChange}
data={cronExpressions.map(({ value, label }) => ({
value,
label: label(t),
}))}
size="sm"
disabled={loadingStates.get(row.original.name)?.interval ?? false}
style={{ minWidth: 180 }}
/>
);
},
},
{
accessorKey: "lastExecutionTime",
header: tTasks("field.lastExecution.label"),
size: 150,
Cell({ row }) {
const status = jobStatusMap.get(row.original.name);
if (!status?.lastExecutionTimestamp) {
return (
<Text size="sm" c="dimmed">
—
</Text>
);
}
return <TimeAgo timestamp={status.lastExecutionTimestamp} />;
},
},
{
id: "actions",
header: tTasks("field.actions.label"),
size: 120,
enableSorting: false,
Cell({ row }) {
const status = jobStatusMap.get(row.original.name);
const handleToggleEnabled = () => {
const currentStates = loadingStates.get(row.original.name) ?? {
toggle: false,
trigger: false,
interval: false,
};
loadingStates.set(row.original.name, {
...currentStates,
toggle: true,
});
try {
if (row.original.isEnabled) {
disableMutation.mutate(row.original.name);
} else {
enableMutation.mutate(row.original.name);
}
} finally {
const updatedStates = loadingStates.get(row.original.name) ?? {
toggle: false,
trigger: false,
interval: false,
};
loadingStates.set(row.original.name, {
...updatedStates,
toggle: false,
});
}
};
const handleTrigger = () => {
if (status?.status === "running") return;
const currentStates = loadingStates.get(row.original.name) ?? {
toggle: false,
trigger: false,
interval: false,
};
loadingStates.set(row.original.name, {
...currentStates,
trigger: true,
});
try {
triggerMutation.mutate(row.original.name);
} finally {
const updatedStates = loadingStates.get(row.original.name) ?? {
toggle: false,
trigger: false,
interval: false,
};
loadingStates.set(row.original.name, {
...updatedStates,
trigger: false,
});
}
};
return (
<Group gap="xs">
{!row.original.preventManualExecution && (
<ActionIcon
onClick={handleTrigger}
disabled={status?.status === "running"}
loading={loadingStates.get(row.original.name)?.trigger ?? false}
variant="light"
color="green"
size="md"
>
<IconPlayerPlay size={16} />
</ActionIcon>
)}
<ActionIcon
onClick={handleToggleEnabled}
loading={loadingStates.get(row.original.name)?.toggle ?? false}
variant="light"
color={row.original.isEnabled ? "green" : "gray"}
size="md"
>
{row.original.isEnabled ? <IconPower size={16} /> : <IconPowerOff size={16} />}
</ActionIcon>
</Group>
);
},
},
];
interface TasksTableProps {
initialJobs: RouterOutputs["cronJobs"]["getJobs"];
}
export const TasksTable = ({ initialJobs }: TasksTableProps) => {
const t = useI18n();
const tTasks = useScopedI18n("management.page.tool.tasks");
const { data: jobs } = clientApi.cronJobs.getJobs.useQuery(undefined, {
initialData: initialJobs,
refetchOnMount: false,
});
const jobStatusMap = useMap<string, TaskStatus | null>(initialJobs.map(({ name }) => [name, null] as const));
const loadingStates = useMap<string, { toggle: boolean; trigger: boolean; interval: boolean }>();
clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, {
onData: (data) => {
jobStatusMap.set(data.name, data);
},
});
const triggerMutation = clientApi.cronJobs.triggerJob.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: tTasks("trigger.error.message"),
});
},
onSuccess() {
showSuccessNotification({
title: t("common.success"),
message: tTasks("trigger.success.message"),
});
},
});
const updateIntervalMutation = clientApi.cronJobs.updateJobInterval.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: tTasks("interval.update.error.message"),
});
},
onSuccess: async () => {
await utils.cronJobs.getJobs.invalidate();
showSuccessNotification({
title: t("common.success"),
message: tTasks("interval.update.success.message"),
});
},
});
const enableMutation = clientApi.cronJobs.enableJob.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: tTasks("toggle.error.message"),
});
},
onSuccess: async () => {
await utils.cronJobs.getJobs.invalidate();
showSuccessNotification({
title: t("common.success"),
message: tTasks("enable.success.message"),
});
},
});
const disableMutation = clientApi.cronJobs.disableJob.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: tTasks("toggle.error.message"),
});
},
onSuccess: async () => {
await utils.cronJobs.getJobs.invalidate();
showSuccessNotification({
title: t("common.success"),
message: tTasks("disable.success.message"),
});
},
});
// Utils for refresh functionality
const utils = clientApi.useUtils();
const handleRefreshAsync = async () => {
try {
await utils.cronJobs.getJobs.invalidate();
showSuccessNotification({
title: t("common.success"),
message: tTasks("refresh.success.message"),
});
} catch {
showErrorNotification({
title: t("common.error"),
message: tTasks("refresh.error.message"),
});
}
};
const table = useTranslatedMantineReactTable({
data: jobs,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: false,
enableTableFooter: false,
enableBottomToolbar: false,
enableRowActions: false,
enableColumnOrdering: false,
enableSorting: false,
enableSortingRemoval: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tTasks("table.search", { count: String(jobs.length) }),
style: { minWidth: 300 },
},
initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={handleRefreshAsync}>
{tTasks("action.refresh.label")}
</Button>
),
columns: createColumns(
t,
tTasks,
jobStatusMap,
triggerMutation,
updateIntervalMutation,
enableMutation,
disableMutation,
loadingStates,
),
});
return <MantineReactTable table={table} />;
};
interface StatusBadgeProps {
isEnabled: boolean;
status: TaskStatus | null;
}
const StatusBadge = ({ isEnabled, status }: StatusBadgeProps) => {
const tTasks = useScopedI18n("management.page.tool.tasks");
if (!isEnabled) {
return (
<Badge color="yellow" size="sm">
{tTasks("status.disabled")}
</Badge>
);
}
if (!status) return null;
if (status.status === "running") {
return (
<Badge color="green" size="sm">
{tTasks("status.running")}
</Badge>
);
}
return (
<Badge variant="default" size="sm">
{tTasks("status.idle")}
</Badge>
);
};
const TimeAgo = ({ timestamp }: { timestamp: string }) => {
const timeAgo = useTimeAgo(new Date(timestamp));
return (
<Text size="sm" c="dimmed">
{timeAgo}
</Text>
);
};
@@ -1,13 +1,12 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { Box, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { TasksTable } from "./_components/tasks-table";
import { JobsList } from "./_components/jobs-list";
export async function generateMetadata() {
const session = await auth();
@@ -28,15 +27,10 @@ export default async function TasksPage() {
}
const jobs = await api.cronJobs.getJobs();
const tTasks = await getScopedI18n("management.page.tool.tasks");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tTasks("title")}</Title>
<TasksTable initialJobs={jobs} />
</Stack>
</>
<Box>
<Title mb={"md"}>Tasks</Title>
<JobsList initialJobs={jobs} />
</Box>
);
}
@@ -1,7 +1,7 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -1,7 +1,7 @@
"use client";
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -4,7 +4,7 @@ import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { z } from "zod/v4";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -1,7 +1,7 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -18,12 +18,7 @@ interface UserProfileAvatarForm {
}
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
const { mutate } = clientApi.user.setProfileImage.useMutation({
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
},
});
const { mutate } = clientApi.user.setProfileImage.useMutation();
const [opened, { toggle }] = useDisclosure(false);
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
@@ -43,7 +38,9 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
image: base64Url,
},
{
onSuccess() {
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar("changeImage.notification.success.message"),
});
@@ -77,7 +74,9 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
image: null,
},
{
onSuccess() {
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
@@ -93,24 +92,38 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
const isCredentialsUser = user.provider === "credentials";
return (
<Box pos="relative">
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
<Menu
opened={opened}
keepMounted
onChange={isCredentialsUser ? toggle : undefined}
position="bottom-start"
withArrow
>
<Menu.Target>
<UnstyledButton onClick={toggle}>
<UnstyledButton
component={isCredentialsUser ? undefined : "div"}
style={{ cursor: !isCredentialsUser ? "default" : undefined }}
onClick={isCredentialsUser ? toggle : undefined}
>
<UserAvatar user={user} size={200} />
<Button
component="div"
pos="absolute"
bottom={0}
left={0}
size="compact-md"
fw="normal"
variant="default"
leftSection={<IconPencil size={18} stroke={1.5} />}
>
{t("common.action.edit")}
</Button>
{isCredentialsUser && (
<Button
component="div"
pos="absolute"
bottom={0}
left={0}
size="compact-md"
fw="normal"
variant="default"
leftSection={<IconPencil size={18} stroke={1.5} />}
>
{t("common.action.edit")}
</Button>
)}
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
@@ -9,7 +10,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import { UserAvatar } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
interface UserListComponentProps {
@@ -1,6 +1,6 @@
"use client";
import { startTransition, useCallback, useState } from "react";
import { startTransition, useCallback, useMemo, useState } from "react";
import {
Badge,
Button,
@@ -17,12 +17,11 @@ import {
} from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { z } from "zod/v4";
import { z } from "zod";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
@@ -114,9 +113,11 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
},
);
const allForms = [generalForm, securityForm, groupsForm];
const isValidCallback: IsValid<unknown> | undefined = allForms[active]?.isValid;
const currentFormValid = isValidCallback?.() ?? true;
const allForms = useMemo(() => [generalForm, securityForm, groupsForm], [generalForm, securityForm, groupsForm]);
const activeForm = allForms[active];
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
const canNavigateToNextStep = isCurrentFormValid();
const controlledGoToNextStep = useCallback(async () => {
if (active + 1 === stepperMax) {
@@ -200,10 +201,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl" shadow="md" withBorder>
<Stack maw={300} align="center" mx="auto">
<UserAvatar
size="xl"
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
/>
<UserAvatar size="xl" user={{ name: generalForm.values.username, image: null }} />
<Text tt="uppercase" fw="bolder" size="xl">
{generalForm.values.username}
</Text>
@@ -220,7 +218,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Stepper.Completed>
</Stepper>
<StepperNavigationComponent
hasNext={hasNext && currentFormValid}
hasNext={hasNext && canNavigateToNextStep}
hasPrevious={hasPrevious}
isComplete={active === stepperMax}
isLoadingNextStep={isPending}
@@ -1,8 +1,8 @@
import Link from "next/link";
import { Button, Card, Group } from "@mantine/core";
import { IconArrowBackUp, IconArrowLeft, IconArrowRight, IconRotate } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
interface StepperNavigationComponentProps {
hasPrevious: boolean;
Loaded 100 of 859 files, more files were not shown because too many files have changed in this diff. Show more