Compare commits

..
Author SHA1 Message Date
ajnart 6c3b584bd2 style: auto-fix lint and format 2026-07-21 08:08:53 +00:00
ajnart e3cf680cbd fix(integrations): support UniFi controller default ports 2026-07-21 10:07:25 +02:00
ajnart 24989ddd16 style: auto-fix lint and format 2026-07-06 00:07:03 +00:00
ajnart 8cd9e8c826 fix(integrations): use Unifi default port 8443 when no port is given
The node-unifi library hardcodes https:// in its base URL and the
Unifi controller API only speaks HTTPS. When a user adds the
integration with a bare http:// or https:// URL, getPortFromUrl
returned 80 or 443, and the library attempted a TLS handshake on
the wrong port. The server's plain HTTP response caused
'EPROTO ... wrong version number'.

Derive the port from the user's explicit port, or fall back to 8443
(the integration's defaultPort). Closes #6207.
2026-07-06 02:05:44 +02:00
314 changed files with 3786 additions and 13941 deletions

No files matched your search

-7
View File
@@ -6,14 +6,7 @@ npm-debug.log
README.md
.next
.turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git
.github
dev
.build
e2e
+1 -8
View File
@@ -59,11 +59,4 @@ TURBO_TELEMETRY_DISABLED=1
# ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
# DEMO_MODE=true
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
# Set to false for writable preview deployments so the demo user can modify the dashboard.
# DEMO_READ_ONLY=false
UNSAFE_ENABLE_MOCK_INTEGRATION=true
-2
View File
@@ -33,8 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.70.0
- 1.69.2
- 1.69.0
- 1.68.0
- 1.67.0
-2
View File
@@ -1,2 +0,0 @@
.secrets
!.secrets.example
-4
View File
@@ -1,4 +0,0 @@
TURBO_API=
TURBO_TEAM=
TURBO_TOKEN=
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
-36
View File
@@ -1,36 +0,0 @@
# Running CI locally
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
```bash
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
```
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
## PR preview images
Successful non-draft pull requests from this repository publish an amd64 image:
```bash
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
```
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
## Dokploy preview cache
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
+1 -1
View File
@@ -11,7 +11,7 @@ outputs:
runs:
using: "composite"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Docker Buildx
-8
View File
@@ -11,11 +11,3 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
-48
View File
@@ -1,48 +0,0 @@
name: Auto-fix lint and format
on:
pull_request:
types: [opened, synchronize, reopened]
branches: ["*"]
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
autofix:
name: Auto-fix
if: github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
-347
View File
@@ -1,347 +0,0 @@
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: ["*"]
pull_request_target:
types: [closed]
branches: ["*"]
push:
branches: [main, dev, beta, next]
merge_group:
workflow_dispatch:
inputs:
operation:
description: Operation to run
required: true
default: validate
type: choice
options:
- validate
- publish
- cleanup
pr_number:
description: PR number for publish or cleanup
required: false
type: number
ref:
description: Git ref to validate or publish
required: false
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_TELEMETRY_DISABLED: 1
REGISTRY: ghcr.io
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
jobs:
fast-gate:
name: Fast gate
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Validate manual inputs
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
env:
PR_NUMBER: ${{ inputs.pr_number }}
BUILD_REF: ${{ inputs.ref }}
run: |
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
- name: Validate workspace configuration
run: pnpm lint:ws
- name: Run unit tests
run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container:
name: Container, E2E, and memory
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: '!env.ACT'
uses: docker/setup-buildx-action@v4
- name: Build container once
if: '!env.ACT'
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
cache-from: type=gha,scope=homarr-linux-amd64
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
- name: Build container once with Act
if: env.ACT
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
run: |
secret_args=()
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
if [ -n "${!secret:-}" ]; then
secret_args+=(--secret "id=${secret},env=${secret}")
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: '!env.ACT'
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag
id: preview
if: |
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
env:
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: steps.preview.outputs.tag != ''
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push preview image
if: steps.preview.outputs.tag != ''
env:
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
run: |
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
- name: Show Act publication result
if: env.ACT
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
cleanup:
name: Clean up preview image
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
packages: write
env:
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Validate cleanup input
if: github.event_name == 'workflow_dispatch'
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: '!env.ACT'
uses: actions/github-script@v8
with:
script: |
const packageName = "homarr-test";
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{ package_type: "container", package_name: packageName, org, per_page: 100 },
);
const target = versions.find((version) =>
version.metadata?.container?.tags?.includes(tag),
);
if (!target) {
core.info(`No package version found with tag "${tag}"`);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
org,
package_version_id: target.id,
});
core.info(`Deleted package version ${target.id} (${tag})`);
- name: Show Act cleanup result
if: env.ACT
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
+227
View File
@@ -0,0 +1,227 @@
name: "[Quality] Code Analysis"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs:
lint-format-autofix:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Lint
run: pnpm turbo lint --affected && pnpm lint:ws
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm turbo format --affected --continue
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: Test
run: pnpm test
- name: "Report Coverage"
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-e2e
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
memory-regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-memory-test
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Start container and measure memory
run: |
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
# Wait for container to be healthy (up to 60s)
healthy=false
for i in $(seq 1 30); do
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs homarr-mem-test --tail 50
exit 1
fi
# Warm up: hit the dashboard page a few times
for i in $(seq 1 5); do
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
sleep 2
done
# Measure RSS after warmup
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
echo "Raw memory: $MEM_KB"
# Convert to numeric MB (handles GiB and MiB suffixes)
MEM_NUM=$(echo "$MEM_KB" | awk '{
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
else { print $1 / 1048576 }
}')
echo "Memory usage: ${MEM_NUM} MB"
MAX_MB=700
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
docker logs homarr-mem-test --tail 50
exit 1
fi
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Obtain token
id: obtainToken
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Upload Crowdin translations
uses: crowdin/github-action@v2
@@ -26,7 +26,7 @@ jobs:
with:
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get Next Version
+3 -13
View File
@@ -57,7 +57,7 @@ jobs:
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
- uses: actions/checkout@v7
- uses: actions/checkout@v6
if: env.SKIP_RELEASE == 'false'
with:
persist-credentials: false
@@ -119,7 +119,7 @@ jobs:
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
ref: ${{ needs.release.outputs.git_ref }}
@@ -148,11 +148,6 @@ jobs:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
build-arm64:
@@ -163,7 +158,7 @@ jobs:
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
ref: ${{ needs.release.outputs.git_ref }}
@@ -192,11 +187,6 @@ jobs:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
extract-asset-amd64:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: Build docs
@@ -29,7 +29,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@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get Next Version
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
ref: dev
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
ref: dev
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Validate prebuilt dependencies for amd64
id: validate-amd64
uses: ./.github/actions/prebuilt-debian
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Validate prebuilt dependencies for arm64
id: validate-arm64
uses: ./.github/actions/prebuilt-debian
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- run: |
npx --yes --package renovate -- \
renovate-config-validator --strict .github/renovate.json5
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
token: ${{ steps.obtainToken.outputs.token }}
ref: dev
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
@@ -28,7 +28,7 @@ jobs:
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Setup
-8
View File
@@ -1,11 +1,3 @@
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
### Bug Fixes
* **layout:** raise AppShellHeader z-index above Indicator dots ([687edb9](https://github.com/homarr-labs/homarr/commit/687edb9226307cb83986097abb24fe7f672e1778)), closes [#6194](https://github.com/homarr-labs/homarr/issues/6194)
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
### Bug Fixes
-27
View File
@@ -19,14 +19,6 @@ packages/integrations/src/anchor @ohmzi
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Bazarr - introduced in https://github.com/homarr-labs/homarr/pull/6156 by @Ayush7614
apps/docs/docs/integrations/bazarr @Ayush7614
apps/docs/docs/widgets/bazarr @Ayush7614
packages/api/src/router/widgets/bazarr.ts @Ayush7614
packages/integrations/src/bazarr @Ayush7614
packages/request-handler/src/bazarr.ts @Ayush7614
packages/widgets/src/bazarr @Ayush7614
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
@@ -135,13 +127,6 @@ packages/integrations/src/overseerr @manuel-rw
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PatchMon - introduced in https://github.com/homarr-labs/homarr/pull/6119 by @mrjoshuap
apps/docs/docs/integrations/patchmon @mrjoshuap
packages/integrations/src/patchmon @mrjoshuap
packages/widgets/src/patchmon @mrjoshuap
packages/api/src/router/widgets/patchmon.ts @mrjoshuap
packages/request-handler/src/patchmon.ts @mrjoshuap
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
@@ -174,10 +159,6 @@ packages/integrations/src/search-ch @Meierschlumpf
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
packages/integrations/src/speedtest-tracker @kylegrieder
# Synology - introduced in https://github.com/homarr-labs/homarr/pull/6121 by @mrjoshuap
apps/docs/docs/integrations/synology @mrjoshuap
packages/integrations/src/synology @mrjoshuap
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch
@@ -206,14 +187,6 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician
packages/api/src/router/widgets/traefik.ts @Nerdy-Technician
packages/integrations/src/traefik @Nerdy-Technician
packages/request-handler/src/traefik.ts @Nerdy-Technician
packages/widgets/src/traefik @Nerdy-Technician
# Exempt Renovate-managed files (no owners)
package.json
**/package.json
+11 -30
View File
@@ -1,43 +1,24 @@
# syntax=docker/dockerfile:1.25
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash && apk update
RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat)
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.60.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

@@ -1,54 +0,0 @@
---
title: 'Bazarr'
description: "Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { bazarrIntegration } from '.';
import { bazarrWidget } from '@site/docs/widgets/bazarr';
<IntegrationHeader
integration={bazarrIntegration}
categories={['Subtitles', 'Media']}
/>
Bazarr complements Sonarr and Radarr by managing subtitle downloads. The Homarr integration uses your Bazarr API key to fetch missing subtitle counts and health badges.
### Screenshots
import apiKeySettings from './img/api-key-settings.png';
import widgetOverview from '@site/docs/widgets/bazarr/img/widget-overview.png';
<div className="flex flex-col gap-4">
<div>
<p>API key location in Bazarr (<strong>Settings → General → Security</strong>):</p>
<img src={apiKeySettings} alt="bazarr-api-key-settings" width={128 * 4} />
</div>
<div>
<p>Bazarr widget on a Homarr board:</p>
<img src={widgetOverview} alt="bazarr-widget-on-board" width={128 * 4} />
</div>
</div>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{ widget: bazarrWidget }]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
You can find your Bazarr API key under **Settings → General → Security**.
<IntegrationSecrets secrets={[{
credentials: ['apiKey'],
steps: [
"Open Bazarr and go to Settings → General → Security.",
"Copy the API key and paste it into Homarr when creating the integration.",
],
}]} />
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const bazarrIntegration: IntegrationDefinition = {
name: "Bazarr",
description:
"Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles for your media library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
path: "../../integrations/bazarr",
};
+1 -1
View File
@@ -58,7 +58,7 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span>
),
),Ï
'Create the integration in Homarr and copy the API key to there'
]
}]} />
@@ -1,43 +0,0 @@
---
title: 'PatchMon'
description: "PatchMon is a patch management and monitoring solution for Linux hosts."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { patchmonIntegration } from '.';
import { patchmonWidget } from '@site/docs/widgets/patchmon';
<IntegrationHeader
integration={patchmonIntegration}
categories={['Health Monitoring']}
/>
PatchMon provides host and package update statistics including total hosts, hosts needing updates, and security update counts.
The PatchMon widget requires users to be **signed in to Homarr**; see the [PatchMon widget documentation](/docs/widgets/patchmon#authentication) for details on authentication and public boards.
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: patchmonWidget },
]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['patchmonApiKey', 'patchmonApiSecret'],
steps: [
'Open your PatchMon instance',
'Navigate to Settings and create a new GetHomepage API key',
'Copy the API key and secret from the success dialog',
'Paste them into Homarr — Basic authentication is handled automatically',
]
}]} />
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const patchmonIntegration: IntegrationDefinition = {
name: "PatchMon",
description:
"PatchMon is a patch management and monitoring solution for Linux hosts, tracking package updates and security patches across your infrastructure.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/patchmon.svg",
path: "../../integrations/patchmon",
};
@@ -1,96 +0,0 @@
---
title: "Synology DiskStation"
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { synologyIntegration } from ".";
import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
import { systemDisksWidget } from "@site/docs/widgets/system-disks";
import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring";
import Alert from "@theme/Admonition";
<IntegrationHeader integration={synologyIntegration} categories={["NAS", "Hardware"]} />
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: healthMonitoringWidget },
{ widget: systemResourcesWidget },
{ widget: systemDisksWidget },
]}
/>
### Supported metrics
| Widget section | DSM API source | May be unavailable |
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
| CPU ring / chart | `SYNO.Core.System.Utilization` | — |
| Memory ring / chart | `SYNO.Core.System.Utilization` | — |
| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor |
| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations |
| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — |
| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — |
| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups |
| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent |
| GPU | Not available | Typical DiskStation models |
### Adding the integration
<AddingIntegration />
<Alert type="info" title="Use the correct DSM URL">
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
<a href="/docs/management/certificates">Management &gt; Tools &gt; Certificates</a> so Homarr can connect securely.
</Alert>
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["username", "password"],
steps: [
"Create a dedicated DSM user with a strong password for Homarr.",
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
"Under User Groups and Permissions, deny access to shared folders and most applications.",
"Allow login to DSM only, optionally restricted by source IP address.",
"Log in once with the service account in DSM to accept terms and conditions.",
],
body: (
<>
<Alert type="caution" title="Administrators group required">
Homarr reads system, storage, and utilization data from APIs that require membership in the default
Administrators group.
</Alert>
<Alert type="warning" title="Two-factor authentication is not supported">
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
account without 2FA.
</Alert>
<Alert type="tip" title="First-time setup">
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
&gt; Security &gt; Protection, then re-enable it after Homarr connects successfully.
</Alert>
</>
),
},
]}
/>
### Storage volume selection
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
<Alert type="note" title="Synology-only option">
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
</Alert>
<Alert type="note" title="Volume ordering">
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
</Alert>
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const synologyIntegration: IntegrationDefinition = {
name: "Synology DiskStation",
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg",
path: "../../integrations/synology",
};
@@ -1,57 +0,0 @@
---
title: "Traefik"
description: "Cloud-native reverse proxy and load balancer"
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { traefikIntegration } from ".";
import { traefikWidget } from "../../widgets/traefik";
<IntegrationHeader integration={traefikIntegration} categories={["Reverse proxy", "Monitoring"]} />
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: traefikWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the URL where Traefik exposes its dashboard/API, for example `http://traefik:8080` or `https://traefik.example.com`. Homarr calls Traefik endpoints under `/api` automatically.
:::
:::warning API requirement
Traefik must have the API enabled. For local or internal setups this is commonly exposed through `api.insecure=true`; for production setups, expose the `api@internal` service through an authenticated router.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [
"Choose this option when the Traefik API is only reachable from a trusted internal network and does not require authentication.",
],
},
{
credentials: ["username", "password"],
steps: [
"Use this option when your Traefik dashboard route is protected by Basic Auth.",
"Enter the same username and password required to open the Traefik dashboard.",
],
},
{
credentials: ["apiKey"],
steps: [
"Use this option when your Traefik dashboard route accepts a bearer token.",
"Paste the token into the API Key field in Homarr.",
],
},
]}
/>
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const traefikIntegration: IntegrationDefinition = {
name: "Traefik",
description: "Cloud-native reverse proxy and load balancer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
path: "../../integrations/traefik",
};
@@ -1,40 +1,48 @@
---
title: 'Unifi Controller'
title: "Unifi Controller"
description: "Unifi Controller is a network management platform for Unifi devices."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import {IntegrationSecrets} from '@site/src/components/integrations/secrets';
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { IconTopologyFull } from "@tabler/icons-react";
import { unifiControllerIntegration } from '.';
import { networkControllerSummaryWidget } from '@site/docs/widgets/network-controller-summary';
import { networkControllerStatusWidget } from '@site/docs/widgets/network-controller-status';
import { unifiControllerIntegration } from ".";
import { networkControllerSummaryWidget } from "@site/docs/widgets/network-controller-summary";
import { networkControllerStatusWidget } from "@site/docs/widgets/network-controller-status";
<IntegrationHeader
integration={unifiControllerIntegration}
categories={['Network controller']}
/>
<IntegrationHeader integration={unifiControllerIntegration} categories={["Network controller"]} />
This integration is using the [node-unifi](https://www.npmjs.com/package/node-unifi) library under the hood to connect to the Unifi Controller.
Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers.
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: networkControllerSummaryWidget },
{ widget: networkControllerStatusWidget}
]}
items={[{ widget: networkControllerSummaryWidget }, { widget: networkControllerStatusWidget }]}
/>
### Adding the integration
<AddingIntegration />
Use the local HTTPS address of your controller:
- UniFi OS consoles such as Dream Router, Dream Machine, Cloud Gateway, and CloudKey use `https://<host>` (port 443).
- Self-hosted UniFi Network Servers normally use `https://<host>:8443`.
- If you omit the port, Homarr tries port 443 first and then port 8443 when the first port cannot be reached.
Use a local administrator account. Cloud-only accounts and accounts with multi-factor authentication are not supported by the underlying client.
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['username', 'password'],
steps: []
}]} />
<IntegrationSecrets
secrets={[
{
credentials: ["username", "password"],
steps: [],
},
]}
/>
+1 -13
View File
@@ -70,16 +70,4 @@ API key authentication enables programmatic access for:
- Automation scripts and CI/CD pipelines
- Third-party integrations
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
- Create, duplicate, rename and delete boards
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
- Container orchestration systems that need to sync app definitions
-1
View File
@@ -141,7 +141,6 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

-41
View File
@@ -1,41 +0,0 @@
---
title: 'Bazarr'
description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { bazarrWidget } from '.';
import { bazarrIntegration } from '@site/docs/integrations/bazarr';
<WidgetHeader
widget={bazarrWidget}
categories={['Subtitles', 'Media']}
/>
This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings.
### Screenshots
import widgetOverview from './img/widget-overview.png';
<div className="flex flex-col gap-2">
<img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: bazarrIntegration,
note: "Displays missing subtitle counts and health indicators"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={bazarrWidget.configuration} />
-37
View File
@@ -1,37 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBadgeCc } from "@tabler/icons-react";
export const bazarrWidget: WidgetDefinition = {
icon: IconBadgeCc,
name: "Bazarr",
description: "Displays missing subtitle counts and health indicators from your Bazarr instance.",
path: "../../widgets/bazarr",
configuration: {
items: [
{
name: "Show missing episodes",
description: "Displays the number of episodes with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show missing movies",
description: "Displays the number of movies with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show provider issues",
description: "Displays the number of subtitle providers with issues",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show health issues",
description: "Displays the number of Bazarr health issues",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -18,9 +18,7 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
### Screenshots
@@ -1,21 +1,24 @@
---
title: "Beszel System Stats"
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
title: 'Beszel System Stats'
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { beszelSystemStatsWidget } from ".";
import { beszelIntegration } from "@site/docs/integrations/beszel";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { beszelSystemStatsWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
import beszelSystemStats from "./img/beszel-system-stats.png";
import beszelSystemStats from './img/beszel-system-stats.png';
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
<WidgetHeader
widget={beszelSystemStatsWidget}
categories={['System Monitoring', 'Charts']}
/>
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
### Screenshots
@@ -23,19 +26,14 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
},
]}
/>
<WidgetIntegrations items={[{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
@@ -17,9 +17,7 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
### Supported Integrations
@@ -1,71 +1,61 @@
---
title: "System Health Monitoring"
description: "Displays information showing the health and status of your system(s)."
title: 'System Health Monitoring'
description: 'Displays information showing the health and status of your system(s).'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { healthMonitoringWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
import { glancesIntegration } from "@site/docs/integrations/glances";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { healthMonitoringWidget } from '.';
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
import { glancesIntegration } from '@site/docs/integrations/glances';
<WidgetHeader widget={healthMonitoringWidget} categories={["Health monitoring"]} />
<WidgetHeader
widget={healthMonitoringWidget}
categories={['Health monitoring']}
/>
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
### Screenshots
import systemView from "./img/system-view.png";
import clusterView from "./img/cluster-view.png";
import { truenasIntegration } from "@site/docs/integrations/truenas";
import { synologyIntegration } from "@site/docs/integrations/synology";
import systemView from './img/system-view.png';
import clusterView from './img/cluster-view.png';
import { truenasIntegration } from '@site/docs/integrations/truenas';
<img src={systemView} alt="health-monitoring system-view" width={256} height={256} />
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
note: "Will display information on the system tab",
},
{
integration: openMediaVaultIntegration,
note: "Will display information on the system tab",
},
{
integration: truenasIntegration,
note: "Will display information on the system tab",
},
{
integration: synologyIntegration,
note: "Will display information on the system tab",
},
{
integration: glancesIntegration,
note: "Will display information on the system tab",
},
{
integration: proxmoxIntegration,
note: "Will display information on the cluster tab, only supports one integration at a time",
},
]}
/>
<WidgetIntegrations items={[{
integration: dashDotIntegration,
note: "Will display information on the system tab"
}, {
integration: openMediaVaultIntegration,
note: "Will display information on the system tab"
}, {
integration: truenasIntegration,
note: "Will display information on the system tab"
}, {
integration: glancesIntegration,
note: "Will display information on the system tab"
}, {
integration: proxmoxIntegration,
note: "Will display information on the cluster tab, only supports one integration at a time"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
@@ -38,13 +38,6 @@ export const healthMonitoringWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Visible storage volumes",
description:
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
values: "List of storage volumes",
defaultValue: "Empty list",
},
{
name: "Default tab",
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

@@ -1,65 +0,0 @@
---
title: 'Media Missing & Queue'
description: 'Track missing media and active downloads from Radarr and Sonarr'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { mediaMissingWidget } from '.';
import { radarrIntegration } from '@site/docs/integrations/radarr';
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
<WidgetHeader
widget={mediaMissingWidget}
categories={['Media', 'Downloads']}
/>
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
### Screenshots
import widgetMissing from './img/missing.png';
import widgetQueued from './img/queued.png';
import widgetCompact from './img/compact.png';
<div className="flex gap-2 flex-wrap">
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
<img src={widgetQueued} alt="media-missing tab showing download queue with progress bars" width={256} />
<img src={widgetCompact} alt="media-missing widget in a compact wide-and-short size" width={256} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: radarrIntegration,
}, {
integration: sonarrIntegration,
}]} />
### Tabs
| Tab | Description |
|---|---|
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
### Sizing
The widget adapts to its container size, so it looks good at any grid size:
- **Wide** — fits as many columns of cards as the width allows, filling larger widgets instead of stretching a single column.
- **Short** — packs items into more, tighter columns so they stay visible in the limited height.
- **Narrow (1×N)** — tab labels collapse to counts only and posters scale down.
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaMissingWidget.configuration} />
@@ -1,31 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconMovie } from "@tabler/icons-react";
export const mediaMissingWidget: WidgetDefinition = {
icon: IconMovie,
name: "Media Missing & Queue",
description: "Track missing media and active downloads from Radarr and Sonarr",
path: "../../widgets/media-missing",
configuration: {
items: [
{
name: "Show Missing",
description: "Display the missing media tab, listing movies or episodes not yet downloaded",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Show Queued",
description: "Display the download queue tab with progress for each item",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Page Size",
description: "Maximum number of items to fetch per integration",
values: { type: "string" },
defaultValue: "10",
},
],
},
};
+20 -40
View File
@@ -1,25 +1,29 @@
---
title: "Notebook"
description: "A simple notebook widget that supports markdown."
title: 'Notebook'
description: 'A simple notebook widget that supports markdown.'
hide_title: true
---
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from ".";
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { notebookWidget } from '.';
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
<WidgetHeader
widget={notebookWidget}
categories={['Notes']}
/>
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
It is based on Tiptap.dev and supports most of its features.
### Screenshots
import editOff from "./img/normal.png";
import editOn from "./img/edit.png";
import editOff from './img/normal.png';
import editOn from './img/edit.png';
<div className="flex gap-1">
<div className='flex gap-1'>
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
@@ -29,27 +33,24 @@ import editOn from "./img/edit.png";
<AddingWidget />
### Configuration
<WidgetConfig configuration={notebookWidget.configuration} />
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
![](./img/notebook-enter-editing.webp)
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions
The notebook offers a variety of functions to edit the text and more.
The notebook offers a variaty of functions to edit the text and more.
![](./img/notebook-toolbar.webp)
#### Common text formatting
| Function | Description |
| ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold |
@@ -61,51 +62,35 @@ The notebook offers a variety of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignment
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes
![](./img/notebook-toolbar-lists.webp)
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions
##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -117,20 +102,16 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls
![](./img/notebook-image-modal.webp)
##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
![](./img/notebook-table-modal.webp)
@@ -138,7 +119,6 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it.
##### Table controls
![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table.
@@ -154,4 +134,4 @@ All the following ones are for editing the wize of the table:
- Delete Column
- Add row on top
- Add row below
- Delete Row
- Delete Row
-89
View File
@@ -1,89 +0,0 @@
---
title: 'PatchMon'
description: 'Displays host patch statistics from PatchMon including update and security counts.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { patchmonWidget } from '.';
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
<WidgetHeader
widget={patchmonWidget}
categories={['Health Monitoring']}
/>
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
PatchMon does not render a built-in widget title. To add a label on your board, open **Edit item → Advanced options → Title** — the same optional overlay badge used by other Homarr widgets.
### Authentication
:::note
This widget requires a **signed-in Homarr session**. Anonymous visitors cannot load PatchMon data, even when the board is [public](/docs/management/boards#access-control). Placing this widget on a public board does not expose patch or security statistics to guests.
Stat visibility toggles in the widget settings control **display only**. Authenticated users with integration query access still receive the full API payload regardless of which stats are hidden in the UI.
:::
### Compliance hero
When **Show compliance hero** is enabled (default), the widget displays a ring showing the percentage of hosts that are fully up to date. The ring color follows the same threshold rules as the "Up-to-Date Hosts" stat. When the widget is **2 or more columns wide**, the hero also shows the "Patch Compliance" label and exact host count (e.g. "27 / 42") beside the ring.
In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a ring-only view so useful compliance data remains visible in tight spaces. If the compliance hero is disabled, compact widgets fall back to the next selected content: OS distribution when enabled, then the first enabled stat.
### Threshold colors
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
| Stat | Green | Yellow | Red |
|------|-------|--------|-----|
| Needs Updates | 0 | 1–4 | 5+ |
| Security Updates | 0 | 1–9 | 10+ |
| Hosts with Security Updates | 0 | — | 1+ |
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
| Outdated Packages | 0 | 1–49 | 50+ |
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
### OS distribution
When **Show OS distribution** is enabled, the widget shows a breakdown of monitored hosts by operating system. Two display modes are available:
- **Horizontal bars** (default) — a scrollable list of colored progress bars showing each OS's share
- **Donut chart** — a compact donut chart visualization that scales down in narrow layouts
The OS distribution limit defaults to **All**. A widget size of **2×3** or larger is recommended when using this section alongside the default stats, but compact 1×1 and 2×1 layouts can still show multiple OS rows when OS distribution is the primary selected content. Wider donut layouts can also show a compact legend beside the chart.
### Responsive sizing
The widget adapts its layout based on the available space:
| Widget size | Layout |
|-------------|--------|
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
### Last updated
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Supported Integrations
<WidgetIntegrations items={[{
integration: patchmonIntegration,
note: "Displays host patch and security update statistics"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={patchmonWidget.configuration} />
-200
View File
@@ -1,200 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconShieldCheck } from "@tabler/icons-react";
export const patchmonWidget: WidgetDefinition = {
icon: IconShieldCheck,
name: "PatchMon",
description:
"Displays host patch statistics including total hosts, hosts needing updates, and security update counts.",
path: "../../widgets/patchmon",
configuration: {
items: [
{
name: "Show compliance hero",
description: "Display a compliance ring showing the percentage of up-to-date hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show total hosts",
description: "Displays the total number of monitored hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show hosts needing updates",
description: "Displays hosts with at least one outdated package",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show security updates",
description: "Displays the total number of security updates available",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show up-to-date hosts",
description: "Displays hosts with zero outdated packages",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show hosts with security updates",
description: "Displays hosts requiring security patches",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show recent updates (24h)",
description: "Displays the number of updates applied in the last 24 hours",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show outdated packages",
description: "Displays the total number of outdated packages across all hosts",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show repositories",
description: "Displays the number of active repositories being monitored",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Enable threshold colors",
description: "Colors stat values green, yellow, or red based on built-in severity presets",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Advanced thresholds",
description: "Customize warning and critical thresholds per stat (absolute count or % of total hosts)",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Needs updates threshold mode",
description:
"Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Needs updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Needs updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "5",
},
{
name: "Security updates threshold mode",
description:
"Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "10",
},
{
name: "Hosts with security updates threshold mode",
description:
"Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Hosts with security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Hosts with security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Up-to-date hosts threshold mode",
description:
"Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Percent of total hosts",
},
{
name: "Up-to-date hosts warning threshold",
description: "Enter yellow when the value is at or above this threshold but below full coverage",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Up-to-date hosts critical threshold",
description: "Enter red when the value falls below this threshold",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Outdated packages threshold mode",
description:
"Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Outdated packages warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Outdated packages critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "50",
},
{
name: "Show OS distribution",
description: "Displays a horizontal bar breakdown of monitored hosts by operating system",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "OS display mode",
description:
"Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.",
values: { type: "select", options: ["Horizontal bars", "Donut chart"] },
defaultValue: "Horizontal bars",
},
{
name: "OS distribution limit",
description: "Maximum number of operating systems to display in the distribution section",
values: { type: "select", options: ["Top 3", "Top 5", "Top 10", "All"] },
defaultValue: "All",
},
{
name: "Show OS version",
description: "Append the OS version to each distribution label when available",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -1,51 +0,0 @@
---
title: "System disks"
description: "Disk usage of your system"
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { systemDisksWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
import { truenasIntegration } from "@site/docs/integrations/truenas";
import { unraidIntegration } from "@site/docs/integrations/unraid";
import { synologyIntegration } from "@site/docs/integrations/synology";
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
},
{
integration: openMediaVaultIntegration,
},
{
integration: truenasIntegration,
},
{
integration: unraidIntegration,
},
{
integration: synologyIntegration,
note: "Supports storage volume filtering when only Synology integrations are selected",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={systemDisksWidget.configuration} />
@@ -1,41 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconServer2 } from "@tabler/icons-react";
export const systemDisksWidget: WidgetDefinition = {
icon: IconServer2,
name: "System disks",
description: "Disk usage of your system",
path: "../../widgets/system-disks",
configuration: {
items: [
{
name: "Show temperature if available",
description: "Display disk temperature when the integration provides it",
values: { type: "boolean" },
defaultValue: "Yes",
},
{
name: "Display mode",
description: "How disk usage values are shown",
values: {
type: "select",
options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
},
defaultValue: "Percentage (e.g., 76%)",
},
{
name: "Show background usage bar",
description: "Fill the area below the usage value with a background bar",
values: { type: "boolean" },
defaultValue: "Yes",
},
{
name: "Visible storage volumes",
description:
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
values: "List of storage volumes",
defaultValue: "All volumes",
},
],
},
};
@@ -1,69 +1,57 @@
---
title: "System Resources"
description: "Displays CPU, RAM and network of your host in real time"
title: 'System Resources'
description: 'Displays CPU, RAM and network of your host in real time'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { systemResourcesWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { glancesIntegration } from "@site/docs/integrations/glances";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { systemResourcesWidget } from '.';
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { glancesIntegration } from '@site/docs/integrations/glances';
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
<WidgetHeader
widget={systemResourcesWidget}
categories={['CPU', 'RAM', 'Memory', 'Network', 'Interface']}
/>
Displays CPU, RAM and network of your host in real time
### Screenshots
import network from "./img/network.png";
import cpu from "./img/cpu.png";
import memory from "./img/memory.png";
import cpuAndMemory from "./img/cpu-and-memory.png";
import { truenasIntegration } from "@site/docs/integrations/truenas";
import { synologyIntegration } from "@site/docs/integrations/synology";
import network from './img/network.png';
import cpu from './img/cpu.png';
import memory from './img/memory.png';
import cpuAndMemory from './img/cpu-and-memory.png';
import { truenasIntegration } from '@site/docs/integrations/truenas';
<div className="flex gap-1">
<div className="flex flex-col gap-1">
<div className='flex gap-1'>
<div className='flex flex-col gap-1'>
<img src={network} alt="system-resources-widget network-only icon-label" width={256 + 4} height={128} />
<div className="flex gap-1">
<div className='flex gap-1'>
<img src={cpu} alt="system-resources-widget cpu-only no-label item-title" width={128} height={128} />
<img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
</div>
</div>
<img
src={cpuAndMemory}
alt="system-resources-widget cpu-and-memory icon-text-label"
width={256 + 4}
height={256 + 4}
/>
<img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
</div>
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
},
{
integration: openMediaVaultIntegration,
},
{
integration: truenasIntegration,
},
{
integration: synologyIntegration,
},
{
integration: glancesIntegration,
},
]}
/>
<WidgetIntegrations items={[{
integration: dashDotIntegration
}, {
integration: openMediaVaultIntegration
}, {
integration: truenasIntegration
}, {
integration: glancesIntegration
}]} />
### Adding the widget
@@ -71,4 +59,4 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
### Configuration
<WidgetConfig configuration={systemResourcesWidget.configuration} />
<WidgetConfig configuration={systemResourcesWidget.configuration} />
-35
View File
@@ -1,35 +0,0 @@
---
title: "Traefik"
description: "Displays Traefik router, service, middleware, and entry point counts."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { traefikWidget } from ".";
import { traefikIntegration } from "@site/docs/integrations/traefik";
<WidgetHeader widget={traefikWidget} categories={["Monitoring", "Reverse proxy"]} />
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: traefikIntegration,
note: "Reads data from Traefik's dashboard/API endpoints",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={traefikWidget.configuration} />
-31
View File
@@ -1,31 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRoute } from "@tabler/icons-react";
export const traefikWidget: WidgetDefinition = {
icon: IconRoute,
name: "Traefik",
description: "Overview of Traefik routers, services, middlewares, and entry points.",
path: "../../widgets/traefik",
configuration: {
items: [
{
name: "Show TCP",
description: "Display TCP routers, services, and middlewares.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show UDP",
description: "Display UDP routers and services.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show entry points",
description: "Display the configured Traefik entry point names.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+6 -18
View File
@@ -1,25 +1,18 @@
// Algolia DocSearch crawler for homarr.dev
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
// Paste this in the Algolia crawler editor.
// DocSearch-managed crawlers: paste this in the Algolia crawler editor or send to DocSearch support.
// Template: https://docsearch.algolia.com/docs/templates/#docusaurus-v3-template
// Markdown indexing: https://www.algolia.com/doc/guides/algolia-ai/askai/guides/markdown-indexing
new Crawler({
appId: "N69WSPZTID",
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
indexPrefix: "",
rateLimit: 32,
maxDepth: 4,
rateLimit: 8,
maxDepth: 10,
startUrls: ["https://homarr.dev/"],
sitemaps: ["https://homarr.dev/sitemap.xml"],
ignoreCanonicalTo: true,
discoveryPatterns: ["https://homarr.dev/**"],
exclusionPatterns: [
"https://homarr.dev/docs/tags/**",
"https://homarr.dev/blog/tags/**",
"https://homarr.dev/blog/authors/**",
"https://homarr.dev/blog/archive/**",
"https://homarr.dev/search/**",
"https://homarr.dev/docs/category/**",
],
actions: [
{
indexName: "Docusaurus",
@@ -112,6 +105,7 @@ new Crawler({
advancedSyntax: true,
attributeCriteriaComputedByMinProximity: true,
removeWordsIfNoResults: "allOptional",
separatorsToIndex: "_",
},
markdown: {
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
@@ -125,10 +119,4 @@ new Crawler({
advancedSyntax: false,
},
},
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
schedule: "on saturday",
renderJavaScript: false,
maxUrls: null,
cache: { enabled: false },
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
});
+2 -21
View File
@@ -8,7 +8,7 @@ const config: Config = {
tagline: "A simple yet powerful dashboard for your server.",
url: "https://homarr.dev",
baseUrl: "/",
trailingSlash: undefined,
trailingSlash: true,
favicon: "img/logo.png",
organizationName: "homarr-labs",
projectName: "homarr",
@@ -79,27 +79,8 @@ const config: Config = {
sitemap: {
changefreq: "weekly",
priority: 0.5,
ignorePatterns: ["/tags/**", "/docs/category/**"],
ignorePatterns: ["/tags/**"],
filename: "sitemap.xml",
createSitemapItems: async ({ routes, siteConfig, defaultCreateSitemapItems }) => {
const items = await defaultCreateSitemapItems({ routes, siteConfig });
return items.map((item) => {
const path = new URL(item.url).pathname;
if (path === "/" || path === "") {
return { ...item, priority: 1.0, changefreq: "weekly" };
}
if (path.startsWith("/docs/")) {
return { ...item, priority: 0.7, changefreq: "monthly" };
}
if (path.startsWith("/blog/")) {
return { ...item, priority: 0.3, changefreq: "never" };
}
if (path.startsWith("/about-us")) {
return { ...item, priority: 0.4, changefreq: "yearly" };
}
return item;
});
},
},
} satisfies Preset.Options,
],
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.2",
"swiper": "^12.2.0",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
@@ -47,16 +47,6 @@ const secretKinds = {
description: "The Secret part of the API Key for authentication.",
icon: IconPassword,
},
patchmonApiKey: {
name: "API Key",
description: "The API key from PatchMon's GetHomepage integration.",
icon: IconKey,
},
patchmonApiSecret: {
name: "API Secret",
description: "The API secret from PatchMon's GetHomepage integration.",
icon: IconPassword,
},
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
githubInstallationId: {
name: "Installation ID",
+2 -1
View File
@@ -1,7 +1,8 @@
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities);
@import url(https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap);
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme {
+1 -1
View File
@@ -1 +1 @@
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.1.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.0.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
+4 -5
View File
@@ -1,6 +1,9 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"target": "ES2022",
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@@ -9,14 +12,10 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@site/*": ["./*"]
},
},
"include": [
"**/*.ts",
+14 -12
View File
@@ -5,9 +5,21 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const homarrVersion = (() => {
for (const candidate of ["../../package.json", "./package.json"]) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
if (pkg.name === "homarr") return pkg.version as string;
} catch {}
}
return "unknown";
})();
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts",
@@ -15,7 +27,7 @@ const withNextIntl = createNextIntlPlugin({
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
HOMARR_VERSION: homarrVersion,
},
output: "standalone",
reactStrictMode: true,
@@ -31,19 +43,9 @@ const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
webpackMemoryOptimizations: true,
},
turbopack: {
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
// (see vercel/next.js#95125). Suppress until upstream fix lands.
ignoreIssue: [
{
path: "**/*",
title: "Encountered unexpected file in NFT list",
},
],
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: {
localPatterns: [
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "oxfmt .",
@@ -2,7 +2,6 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -11,7 +10,6 @@ import {
createWSClient,
httpBatchStreamLink,
httpLink,
httpSubscriptionLink,
isNonJsonSerializable,
loggerLink,
splitLink,
@@ -34,7 +32,6 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -92,13 +89,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
const def = definition as { refetchInterval?: number | false; queryKey?: QueryKey; kind: string };
if (def.refetchInterval === undefined) continue;
const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === false ? false : def.refetchInterval * 1000;
client.setQueryDefaults(key, { refetchInterval: interval });
}
return client;
});
@@ -111,17 +101,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
splitLink({
condition: ({ type }) => type === "subscription",
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
eventSourceOptions: { withCredentials: true },
}),
false: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
@@ -22,7 +22,6 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -41,7 +40,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -80,16 +79,14 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const userId = session?.user.id ?? "anonymous";
return (
<>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
</>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
);
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
@@ -126,9 +123,9 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null;
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
if (!persisted || persisted.buster !== queryCacheBuster) return null;
if (!persisted.clientState?.queries?.length) return null;
const persisted = superjson.parse<PersistedClient>(serialized);
if (persisted.buster !== queryCacheBuster) return null;
if (!persisted?.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />;
} catch (error) {
@@ -41,12 +41,13 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess) {
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
}
@@ -54,9 +55,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
<>
{isEditMode && <AddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} />
<EditModeMenu demoMode={demoMode ?? false} />
{!demoReadOnly && (
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
@@ -157,7 +158,7 @@ const AddMenu = () => {
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -187,11 +188,11 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const toggle = useCallback(() => {
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{formatBytes(file.size)}
{humanFileSize(file.size)}
</Text>
</Group>
<Button
+1 -1
View File
@@ -142,7 +142,7 @@ export default async function Layout(props: {
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<body className={["font-sans", fontSans.variable].join(" ")}>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
@@ -26,8 +26,6 @@ export const integrationSecretIcons = {
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { formatBytes } from "@homarr/common";
import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { formatBytes, useTimeAgo } from "@homarr/common";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{formatBytes(bytesUsage)}
{humanFileSize(bytesUsage)}
</Text>
);
},
@@ -17,7 +17,6 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelectModal } from "~/components/access/group-select-modal";
@@ -61,7 +60,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
const generalForm = useZodForm(
z.object({
username: z.string().min(1),
email: optionalEmailSchema,
email: z.string().email().or(z.string().length(0).optional()),
}),
{
initialValues: {
+8 -10
View File
@@ -1,13 +1,11 @@
import fs from "fs";
import path from "path";
export const findMigrationsFolder = () => {
const cwd = process.cwd();
const migrationsFolderCandidates = [
path.join(cwd, "db/migrations/sqlite"),
path.join(cwd, "packages/db/migrations/sqlite"),
path.resolve(cwd, "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
};
const migrationsFolderCandidates = [
path.join(process.cwd(), "db/migrations/sqlite"),
path.join(process.cwd(), "packages/db/migrations/sqlite"),
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
export const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
@@ -30,19 +30,7 @@ export const InheritAccessTable = <TPermission extends string>({
</TableTr>
</TableThead>
<TableTbody>
{Array.from(
accessQueryData.inherited
.reduce((map, entry) => {
const existing = map.get(entry.group.id);
if (!existing) {
map.set(entry.group.id, entry);
} else if (getPermissionsWithChildren([entry.permission]).includes(existing.permission)) {
map.set(entry.group.id, entry);
}
return map;
}, new Map<string, (typeof accessQueryData.inherited)[number]>())
.values(),
).map(({ group, permission }) => {
{accessQueryData.inherited.map(({ group, permission }) => {
const entityPermission =
permission in mapPermissions
? mapPermissions[permission]
@@ -2,10 +2,10 @@
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { Group, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -49,13 +49,9 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
}, [queryClient, widgetQueryKey]);
const options = useMemo(
@@ -227,15 +223,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
</Text>
</Group>
</Menu.Item>
@@ -273,24 +265,13 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
if (isFetching) return <Loader size={12} />;
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
@@ -298,8 +279,8 @@ const WidgetCacheAge = ({
.filter(Boolean);
if (timestamps.length === 0) return null;
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
const oldest = Math.min(...timestamps);
const seconds = Math.floor((Date.now() - oldest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
+1 -1
View File
@@ -18,7 +18,7 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
<AppShellHeader maw="100vw" style={{ overflowX: "hidden" }}>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
@@ -105,7 +105,7 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
documentationHref={createDocumentationLink("/docs/category/management")}
/>
),
},
-1
View File
@@ -4,7 +4,6 @@ export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
},
experimental__runtimeEnv: process.env,
});
+3 -3
View File
@@ -2,11 +2,11 @@
"name": "homarr-prebuilt-debian",
"private": true,
"dependencies": {
"better-sqlite3": "^12.11.1"
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.9.0"
}
+5 -5
View File
@@ -9,16 +9,16 @@ importers:
.:
dependencies:
better-sqlite3:
specifier: ^12.11.1
version: 12.11.1
specifier: ^12.10.0
version: 12.10.0
packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.11.1:
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
better-sqlite3@12.10.0:
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
bindings@1.5.0:
@@ -148,7 +148,7 @@ snapshots:
base64-js@1.5.1: {}
better-sqlite3@12.11.1:
better-sqlite3@12.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
+22 -50
View File
@@ -95,27 +95,20 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/bazarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg" alt="Bazarr" width="90" height="90" />
<br/>
<p align="center">Bazarr</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/beszel" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg" alt="Beszel" width="90" height="90" />
<br/>
<p align="center">Beszel</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
<br/>
<p align="center">Coolify</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
<br/>
@@ -156,15 +149,15 @@
<br/>
<p align="center">Gotify</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
<br/>
<p align="center">Home<br/>Assistant</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
<br/>
@@ -205,15 +198,15 @@
<br/>
<p align="center">Navidrome</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/nextcloud" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg" alt="Nextcloud" width="90" height="90" />
<br/>
<p align="center">Nextcloud</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
<br/>
@@ -254,13 +247,6 @@
<br/>
<p align="center">Paperless-ngx</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/patchmon" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg" alt="PatchMon" width="90" height="90" />
<br/>
<p align="center">PatchMon</p>
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
@@ -268,8 +254,8 @@
<br/>
<p align="center">PeaNUT</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/>
@@ -303,8 +289,8 @@
<br/>
<p align="center">qBittorrent</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
<br/>
@@ -317,8 +303,8 @@
<br/>
<p align="center">Readarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -352,13 +338,6 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/synology" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg" alt="Synology DiskStation" width="90" height="90" />
<br/>
<p align="center">Synology<br/>DiskStation</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
@@ -373,8 +352,8 @@
<br/>
<p align="center">Technitium<br/>DNS</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
@@ -382,13 +361,6 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/traefik" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg" alt="Traefik" width="90" height="90" />
<br/>
<p align="center">Traefik</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
<br/>
@@ -401,8 +373,8 @@
<br/>
<p align="center">TrueNAS</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
<br/>
+6 -10
View File
@@ -1,15 +1,11 @@
{
"name": "homarr",
"version": "1.70.0",
"version": "1.69.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
"ci:act:fast": "bash scripts/ci/act.sh fast",
"cli": "pnpm with-env tsx packages/cli/index.ts",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
@@ -37,9 +33,6 @@
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
"dependencies": {
"jsonpath-plus": "catalog:"
},
"devDependencies": {
"@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:",
@@ -69,7 +62,10 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.9.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
}
-2
View File
@@ -6,11 +6,9 @@ export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
},
});
-6
View File
@@ -9,8 +9,6 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
@@ -18,7 +16,6 @@ import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { patchmonRouter } from "./router/widgets/patchmon";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
@@ -31,8 +28,6 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
@@ -40,7 +35,6 @@ export const mcpRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
+1 -3
View File
@@ -6,7 +6,6 @@ import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
@@ -15,14 +14,13 @@ export const openApiRouter = createTRPCRouter({
boardRouter,
infoRouter,
inviteRouter,
serverSettingsRouter,
userRouter,
});
export const openApiDocument = (base: string) =>
generateOpenApiDocument(openApiRouter, {
title: "Homarr API documentation",
version: "1.1.0",
version: "1.0.0",
baseUrl: base,
docsUrl: "https://homarr.dev",
securitySchemes: {
+1 -1
View File
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
+36 -113
View File
@@ -35,7 +35,6 @@ import {
everyoneGroup,
getPermissionsWithChildren,
getPermissionsWithParents,
widgetDefaultSizes,
widgetKinds,
} from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
@@ -331,16 +330,7 @@ export const boardRouter = createTRPCRouter({
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
openapi: { method: "POST", path: "/api/boards/{id}/duplicate", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
},
})
.input(boardDuplicateSchema)
.output(z.object({ boardId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await noBoardWithSimilarNameAsync(ctx.db, input.name);
@@ -554,57 +544,33 @@ export const boardRouter = createTRPCRouter({
});
},
});
return { boardId: newBoardId };
}),
renameBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/name", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Rename a board by ID. Requires full permission on the board. REQUIRED: id (board ID), name (new unique board name)",
},
})
.input(boardRenameSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/visibility", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Change board visibility. Requires full permission on the board. REQUIRED: id (board ID), visibility ('public' or 'private'). Home boards cannot be made private",
},
})
.input(boardChangeVisibilitySchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure.input(boardChangeVisibilitySchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure
.meta({
openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
@@ -621,38 +587,16 @@ export const boardRouter = createTRPCRouter({
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's desktop home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/mobile-home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's mobile home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
@@ -808,16 +752,7 @@ export const boardRouter = createTRPCRouter({
}
}),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Update visual and behavior settings for a board. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, and disableStatus",
},
})
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
.output(z.void())
.input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
@@ -1503,33 +1438,21 @@ export const boardRouter = createTRPCRouter({
}
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const fitsAt = (x: number, y: number) => {
if (x + defaultSize.width > layout.columnCount) return false;
for (let dy = 0; dy < defaultSize.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < defaultSize.width; dx++) {
if (row[x + dx]) return false;
}
}
return true;
};
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (fitsAt(x, y)) {
if (!occupiedRow[x]) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: defaultSize.width,
height: defaultSize.height,
width: 1,
height: 1,
});
placed = true;
}
+1 -2
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -20,5 +20,4 @@ export const infoRouter = createTRPCRouter({
};
}),
isDemoMode: publicProcedure.query(() => isDemoMode),
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
});
+1 -79
View File
@@ -1,14 +1,6 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
import { boards, serverSettings } from "@homarr/db/schema";
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -16,15 +8,6 @@ import { settingsInitSchema } from "@homarr/validation/settings";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
@@ -32,67 +15,6 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "GET", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
},
})
.input(z.void())
.output(boardServerSettingsSchema)
.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "board");
}),
updateBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "PATCH", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
.output(boardServerSettingsSchema)
.mutation(async ({ ctx, input }) => {
const inputBoardIds = [input.homeBoardId, input.mobileHomeBoardId].filter(
(id) => id !== undefined && id !== null,
);
if (inputBoardIds.length > 0) {
const publicBoards = await ctx.db.query.boards.findMany({
columns: { id: true },
where: and(inArray(boards.id, inputBoardIds), eq(boards.isPublic, true)),
});
const publicBoardIds = new Set(publicBoards.map((board) => board.id));
const invalidBoardIds = inputBoardIds.filter((id) => !publicBoardIds.has(id));
if (invalidBoardIds.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Board settings home board IDs must reference public boards: ${invalidBoardIds.join(", ")}`,
});
}
}
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(
@@ -1,9 +1,9 @@
import { stringify } from "superjson";
import SuperJSON from "superjson";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, serverSettings } from "@homarr/db/schema";
import { serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -33,7 +33,7 @@ describe("getAll server settings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: stringify(defaultServerSettings.analytics),
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
@@ -67,7 +67,7 @@ describe("saveSettings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: stringify(defaultServerSettings.analytics),
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
@@ -82,7 +82,7 @@ describe("saveSettings", () => {
expect(dbSettings).toStrictEqual([
{
settingKey: "analytics",
value: stringify({
value: SuperJSON.stringify({
enableGeneral: true,
instanceId: null,
}),
@@ -90,91 +90,3 @@ describe("saveSettings", () => {
]);
});
});
describe("board settings API", () => {
test("getBoardSettings should return board defaults", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
const result = await caller.updateBoardSettings({
homeBoardId: boardId,
forceDisableStatus: true,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
forceDisableStatus: true,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should update existing settings", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "private", isPublic: false });
const actAsync = async () => await caller.updateBoardSettings({ homeBoardId: boardId });
await expect(actAsync()).rejects.toThrow("must reference public boards");
});
});
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff. Show more