Compare commits

...
Author SHA1 Message Date
Thomas Camlong c54a40dcda fix(ci): remove persist-credentials: false to fix git-auto-commit-action push 2026-07-15 10:32:36 +02:00
homarr-renovate[bot] c473e80243 chore(deps): update pnpm to v11.11.0 (#6342)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 02:31:21 +00:00
homarr-renovate[bot] bf55bcce92 fix(deps): update dependency swiper to ^14.0.5 (#6341)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 23:55:02 +00:00
homarr-renovate[bot] 49248cedaf chore(deps): update dependency posthog-js to ^1.399.1 (#6340)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 22:09:52 +00:00
homarr-renovate[bot] a0034b2cfc chore(deps): update dependency @immich/sdk to ^3.0.2 (#6339)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 21:01:58 +00:00
homarr-renovate[bot] 4a297180fd chore(deps): update dependency lucide-react to ^1.24.0 (#6334)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 19:23:26 +00:00
Thomas 5fa3a3313a Merge pull request #6337 from homarr-labs/fix/ts7-nextjs-build
fix(docker): pin TypeScript to v6 to fix Next.js 16.2.10 build in CI
2026-07-14 18:11:38 +02:00
Thomas Camlong 29ee87c4aa fix(docker): pin typescript to v6 via alias to fix Next.js 16.2.10 build 2026-07-14 18:11:15 +02:00
Thomas 94a247e24a fix(docker): remove --offline flag from pnpm install step (#6335) 2026-07-14 17:19:51 +02:00
homarr-renovate[bot] 6d37710fec chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.1.4 (#6333)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 11:26:55 +00:00
homarr-renovate[bot] c016beb2e5 chore(deps): update dependency vite to ^8.1.4 (#6332)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 09:06:17 +00:00
homarr-renovate[bot] f9e1b85c6a chore(deps): update dependency posthog-node to ^5.40.0 (#6314)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 06:14:41 +00:00
homarr-renovate[bot] 38ba19e665 chore(deps): update dependency oxlint to ^1.73.0 (#6306)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 03:22:16 +00:00
homarr-renovate[bot] f55c99d2d6 chore(deps): update dependency posthog-js to ^1.399.0 (#6331)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 00:00:38 +00:00
homarr-renovate[bot] c5e8b83d8a chore(deps): update vitest monorepo to ^4.1.10 (#6286)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 23:01:00 +00:00
homarr-renovate[bot] d52d9cfed2 chore(deps): update dependency posthog-js to ^1.398.7 (#6319)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 21:58:26 +00:00
Thomas 93325642aa Merge pull request #6324 from homarr-labs/renovate/major-github-artifact-actions 2026-07-13 22:12:32 +02:00
Thomas 165e94abbc Merge pull request #6328 from homarr-labs/fix-pnpm-fetch-build 2026-07-13 22:12:11 +02:00
homarr-renovate[bot] 2e3127e766 chore(deps): update docker/dockerfile docker tag to v1.25 (#6321) 2026-07-13 22:10:35 +02:00
Thomas d37326d905 Merge pull request #6322 from homarr-labs/renovate/actions-cache-6-x 2026-07-13 22:09:57 +02:00
homarr-renovate[bot] e51e39ff38 chore(deps): update dependency typescript to v7 (#6325) 2026-07-13 22:09:30 +02:00
homarr-renovate[bot] 6c075b6d74 chore(deps): update dependency mysql2 to v3.22.6 (#6310)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 18:45:08 +00:00
ajnart b2f63c6e0f fix(docker): drop --frozen-lockfile from install step per pnpm fetch docs
The pnpm fetch docs recommend `pnpm install -r --offline` without
--frozen-lockfile. The --offline flag already enforces no registry
access, making --frozen-lockfile redundant.

https://pnpm.io/cli/fetch
2026-07-13 20:24:59 +02:00
ajnart 8c2d73e8af fix(docker): resolve pnpm fetch crash with patchedDependencies
PR #6296 introduced pnpm fetch in the Dockerfile for Docker cache
optimization. However, pnpm fetch crashes with ENOENT when
patchedDependencies are configured with nodeLinker: hoisted because
applyPatchToDir tries to chdir into node_modules/<pkg> which doesn't
exist during fetch (only the content-addressable store is populated).

Fix: temporarily switch nodeLinker to isolated during pnpm fetch so
patches apply inside the virtual store (node_modules/.pnpm/...) which
IS created by pnpm fetch. Restore hoisted before the install step so
the final node_modules layout stays flat.

Refs: pnpm/pnpm#5268, pnpm/pnpm#8726
2026-07-13 20:06:28 +02:00
homarr-renovate[bot] 1c0ff8930e chore(deps): update actions/upload-artifact action to v7 2026-07-13 16:39:27 +00:00
homarr-renovate[bot] e2b8355639 chore(deps): update actions/cache action to v6 2026-07-13 16:39:10 +00:00
Thomas ba7718bde5 feat: rework the ci (#6296) 2026-07-13 18:32:48 +02:00
Thomas 06f1877ae1 Merge pull request #6282 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-07-13 18:11:10 +02:00
homarr-renovate[bot] 1d9e8aabf3 chore(deps): update turbo monorepo to ^2.10.4 (#6303)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 13:36:45 +00:00
homarr-renovate[bot] b111e5285b chore(deps): update dependency @scalar/api-reference-react to ^0.9.54 (#6317)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 10:38:14 +00:00
homarr-renovate[bot] f4c491b570 chore(deps): update dependency @types/node to ^24.13.3 (#6318)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 10:37:23 +00:00
homarr-renovate[bot] 29bd4fd70b chore(deps): update dependency posthog-js to ^1.398.1 (#6311)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 18:09:44 +00:00
homarr-renovate[bot] 269dadee8d fix(deps): update dependency swiper to ^14.0.2 (#6313)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 18:09:11 +00:00
Thomas 03f68a0421 fix(qbittorrent): sort torrents by progress so active items appear before limit cutoff (#6297) 2026-07-12 14:56:24 +02:00
Bergasha 807e5eb2a9 feat(docker-widget): replace footer timestamp text with compact refresh button (#6307) 2026-07-12 14:51:56 +02:00
Thomas 8e8c29979f fix(system-disks): format absolute storage values with shared byte helper (#6299) 2026-07-12 14:50:58 +02:00
Manuel 7d859c1af0 docs: update pr template (#6147) 2026-07-12 14:49:56 +02:00
homarr-renovate[bot] e9543dc30d chore(deps): update tiptap monorepo to v3.27.3 (#6308)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 09:12:55 +00:00
Nerdy Technician 9f313440ac fix(integrations): update PatchMon API key settings link (#6302) 2026-07-12 08:59:52 +02:00
homarr-renovate[bot] c2508dffea chore(deps): update dependency posthog-js to ^1.396.7 (#6285)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 06:18:17 +00:00
Artur d44bc06a93 feat(notebook): support text direction controls (#6293) 2026-07-12 07:58:02 +02:00
homarr-renovate[bot] c56b91570b chore(deps): update tiptap monorepo to v3.27.2 (#6292)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 03:01:23 +00:00
homarr-renovate[bot] d53c4e6513 chore(deps): update dependency hono@<4.6.5 to >=4.12.28 (#6291)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 02:58:51 +00:00
homarr-renovate[bot] 0426d600cc chore(deps): update dependency oxfmt to ^0.58.0 (#6305)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 00:06:59 +00:00
Thomas 576856d634 fix(beszel): add reliable live stats and filesystem UI (#6289) 2026-07-11 17:55:04 +02:00
Thomas def3ede3bc fix(db): restore widget_secret migration journal entries accidentally removed by #6206 (#6288) 2026-07-11 15:40:06 +02:00
github-actions[bot] 34bd574354 docs(helm): Update Helm chart documentation 2026-07-10 20:32:11 +00:00
83 changed files with 3535 additions and 2178 deletions

No files matched your search

+7
View File
@@ -6,7 +6,14 @@ npm-debug.log
README.md README.md
.next .next
.turbo .turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git .git
.github
dev dev
.build .build
e2e e2e
+2
View File
@@ -0,0 +1,2 @@
.secrets
!.secrets.example
+4
View File
@@ -0,0 +1,4 @@
TURBO_API=
TURBO_TEAM=
TURBO_TOKEN=
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
+36
View File
@@ -0,0 +1,36 @@
# 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.
+8
View File
@@ -11,3 +11,11 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] 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) - [ ] 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/). - [ ] 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)
+47
View File
@@ -0,0 +1,47 @@
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 }}
- 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"
+347
View File
@@ -0,0 +1,347 @@
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."
-165
View File
@@ -1,165 +0,0 @@
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' && github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
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@v7
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@v7
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@v7
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@v7
- 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@v7
- 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@v7
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
@@ -148,6 +148,11 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true 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: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
build-arm64: build-arm64:
@@ -187,6 +192,11 @@ jobs:
platforms: linux/arm64 platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true 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: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
extract-asset-amd64: extract-asset-amd64:
+30 -11
View File
@@ -1,24 +1,43 @@
# syntax=docker/dockerfile:1.25
FROM node:24.18.0-alpine AS base FROM node:24.18.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash RUN apk add --no-cache libc6-compat curl bash && apk update
RUN 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
COPY . . 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 SKIP_ENV_VALIDATION='true'
ARG CI='true' ARG CI='true'
ARG DISABLE_REDIS_LOGS='true' ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN corepack enable pnpm && pnpm build RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
FROM base AS runner FROM base AS runner
WORKDIR /app 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"> <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.21.0](https://img.shields.io/badge/Version-8.21.0-informational?style=flat) ![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.69.2](https://img.shields.io/badge/AppVersion-v1.69.2-informational?style=flat) ![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes 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 | | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion | | image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry | | imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress | | ingress.enabled | bool | `false` | Enable ingress |
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']} 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. 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.
### Screenshots ### Screenshots
@@ -1,24 +1,21 @@
--- ---
title: 'Beszel System Stats' title: "Beszel System Stats"
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.' description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { beszelSystemStatsWidget } from '.'; import { beszelSystemStatsWidget } from ".";
import { beszelIntegration } from '@site/docs/integrations/beszel'; import { beszelIntegration } from "@site/docs/integrations/beszel";
import beszelSystemStats from './img/beszel-system-stats.png'; import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader <WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
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. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days. 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.
### Screenshots ### Screenshots
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: beszelIntegration, items={[
note: "Displays time-series charts for a selected system" {
}]} /> integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
},
]}
/>
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} /> <WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']} 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. 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.
### Supported Integrations ### Supported Integrations
+38 -18
View File
@@ -1,29 +1,25 @@
--- ---
title: 'Notebook' title: "Notebook"
description: 'A simple notebook widget that supports markdown.' description: "A simple notebook widget that supports markdown."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from '.'; import { notebookWidget } from ".";
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
<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. 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. It is based on Tiptap.dev and supports most of its features.
### Screenshots ### Screenshots
import editOff from './img/normal.png'; import editOff from "./img/normal.png";
import editOn from './img/edit.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={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} /> <img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div> </div>
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={notebookWidget.configuration} /> <WidgetConfig configuration={notebookWidget.configuration} />
### Editing ### 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. 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) ![](./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. 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) ![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions ### Notebook toolbar functions
The notebook offers a variaty of functions to edit the text and more.
The notebook offers a variety of functions to edit the text and more.
![](./img/notebook-toolbar.webp) ![](./img/notebook-toolbar.webp)
#### Common text formatting #### Common text formatting
| Function | Description | | Function | Description |
| ---------------------- | ------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold | | Bold | Makes the selected text bold |
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text | | Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading #### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles. Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignement #### Text Alignment
Select the alignement on the line. Left, center or right.
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.
#### Misc functions #### Misc functions
##### Blockquote ##### Blockquote
Puts the current line in a quoting block. Puts the current line in a quoting block.
##### Horizontal line ##### Horizontal line
Adds a line that goes from the far right to the far left as a separator. Adds a line that goes from the far right to the far left as a separator.
#### Lists #### Lists
##### Bullet lists ##### Bullet lists
Simple list with a dot preceding each element. Simple list with a dot preceding each element.
##### Ordered lists ##### Ordered lists
List with numbers preceding each element. List with numbers preceding each element.
##### Check lists ##### Check lists
List with a checkbox on each element. List with a checkbox on each element.
##### Indentation changes ##### Indentation changes
![](./img/notebook-toolbar-lists.webp) ![](./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. 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 #### Insert functions
##### Link controls ##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text. 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. Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -102,16 +117,20 @@ 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. 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 ##### Image controls
![](./img/notebook-image-modal.webp) ![](./img/notebook-image-modal.webp)
##### Source ##### Source
You can use a normal link to an image or you can also use locally stored images. You can use a normal link to an image or you can also use locally stored images.
##### Width ##### Width
The aspect ratio will always be respected, so only the width field is necessary. 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. 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 #### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it. 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) ![](./img/notebook-table-modal.webp)
@@ -119,6 +138,7 @@ 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. Clicking that button while in a table will delete it.
##### Table controls ##### Table controls
![](./img/notebook-toolbar-table.webp) ![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table. While in a table, further controls will appear to help edit the table.
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7", "random-word-slugs": "^0.1.7",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"swiper": "^14.0.1", "swiper": "^14.0.5",
"tailwindcss": "^4.3.2" "tailwindcss": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {
@@ -10,6 +10,7 @@ import {
createWSClient, createWSClient,
httpBatchStreamLink, httpBatchStreamLink,
httpLink, httpLink,
httpSubscriptionLink,
isNonJsonSerializable, isNonJsonSerializable,
loggerLink, loggerLink,
splitLink, splitLink,
@@ -101,9 +102,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}), }),
splitLink({ splitLink({
condition: ({ type }) => type === "subscription", condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({ true: splitLink({
client: wsClient, condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
transformer: superjson, true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
eventSourceOptions: { withCredentials: true },
}),
false: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
}), }),
false: splitLink({ false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input), condition: ({ input }) => isNonJsonSerializable(input),
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types"; import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client"; import { DynamicClientBoard } from "./_dynamic-client";
@@ -40,7 +41,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => { }: Props<TParams>) => {
return { return {
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour: true, withTour: true,
}), }),
@@ -41,15 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) { if (!hasChangeAccess) {
return <SelectBoardsMenu />; return <SelectBoardsMenu />;
} }
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone"; import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react"; import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card"; import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name} {file.name}
</Text> </Text>
<Text visibleFrom="md" c="gray.6" size="sm"> <Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)} {formatBytes(file.size)}
</Text> </Text>
</Group> </Group>
<Button <Button
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common"; import { formatBytes } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local"; import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/> />
</TableTd> </TableTd>
<TableTd>{media.name}</TableTd> <TableTd>{media.name}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd> <TableTd>{formatBytes(media.size)}</TableTd>
<TableTd> <TableTd>
{media.creator ? ( {media.creator ? (
<Group gap="sm"> <Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common"; import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return ( return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}> <Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)} {formatBytes(bytesUsage)}
</Text> </Text>
); );
}, },
@@ -4,7 +4,7 @@ import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core"; import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -49,7 +49,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { gridstack } = useSectionContext().refs; const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]); const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => { const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
@@ -276,7 +279,7 @@ const WidgetCacheAge = ({
isFetching, isFetching,
}: { }: {
queryClient: QueryClient; queryClient: QueryClient;
queryKey: unknown[]; queryKey: QueryKey;
isFetching: boolean; isFetching: boolean;
}) => { }) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity // 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
@@ -295,8 +298,8 @@ const WidgetCacheAge = ({
.filter(Boolean); .filter(Boolean);
if (timestamps.length === 0) return null; if (timestamps.length === 0) return null;
const oldest = Math.min(...timestamps); const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - oldest) / 1000); const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now"; if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`; if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`; return `${Math.floor(seconds / 60)}m ago`;
+1
View File
@@ -4,6 +4,7 @@ export const env = createEnv({
server: { server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false), DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
}, },
experimental__runtimeEnv: process.env, experimental__runtimeEnv: process.env,
}); });
+2 -2
View File
@@ -6,7 +6,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.10.0" "pnpm": ">=11.11.0"
}, },
"packageManager": "pnpm@11.10.0" "packageManager": "pnpm@11.11.0"
} }
+9 -5
View File
@@ -6,6 +6,10 @@
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules", "clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean", "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", "cli": "pnpm with-env tsx packages/cli/index.ts",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate", "db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run", "db:migration:mysql:run": "pnpm -F db migration:mysql:run",
@@ -33,6 +37,9 @@
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --" "with-env": "dotenv -e .env --"
}, },
"dependencies": {
"jsonpath-plus": "catalog:"
},
"devDependencies": { "devDependencies": {
"@playwright/test": "catalog:", "@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:", "@semantic-release/changelog": "catalog:",
@@ -62,10 +69,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.10.0" "pnpm": ">=11.11.0"
}, },
"packageManager": "pnpm@11.10.0", "packageManager": "pnpm@11.11.0"
"dependencies": {
"jsonpath-plus": "catalog:"
}
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId; export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]); const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => { const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0]; const first = queryKey[0];
+39 -1
View File
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
return observable<LiveStatsEvent>((emit) => { return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController(); const controller = new AbortController();
let isActive = true; let isActive = true;
let emittedEventCount = 0;
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
});
void (async () => { void (async () => {
const integration = ctx.integrations[0]; const integration = ctx.integrations[0];
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics( await instance.subscribeRealtimeMetrics(
input.systemId, input.systemId,
(event) => { (event) => {
if (isActive) emit.next(event); if (!isActive) return;
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
emit.next(event);
}, },
controller.signal, controller.signal,
); );
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
} }
} catch (error) { } catch (error) {
if (isActive) { if (isActive) {
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
});
emit.error( emit.error(
error instanceof TRPCError error instanceof TRPCError
? error ? error
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
} }
if (isActive) { if (isActive) {
logger.debug("Beszel realtime subscription completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
});
emit.complete(); emit.complete();
} }
})(); })();
return () => { return () => {
isActive = false; isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort(); controller.abort();
}; };
}); });
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
[[["docker", "getContainers"], { type: "query" }], true], [[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true], [[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false], [[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
[[["app", "selectable"], { type: "query" }], false], [[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false], [[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false], [["widget"], false],
+104 -5
View File
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
}; };
/** /**
* Number of bytes to si format. (Division by 1024) * Number of bytes to a human-readable string using binary (KiB) suffixes.
* Does not accept floats, size in bytes should be an integer. * Does not accept floats; size in bytes should be an integer.
* Will return "NaI" and logs a warning if a float is passed. * Will return "NaI" and log a warning if a float is passed.
* Concat as parameters so it is not added if the returned value is "NaI" or "∞". * `concat` is appended after the unit so it is omitted when the returned
* Returns "∞" if the size is too large to be represented in the current format. * value is "NaI" or "∞". Returns "∞" if the size is too large to be
* represented in the current format.
*
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
* paired "used / total" displays, and `formatByteRate` for byte-per-second
* rates. This function is kept for backwards compatibility but should not
* be used in new code.
*/ */
export const humanFileSize = (size: number, concat = ""): string => { export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB //64bit limit for Number stops at EiB
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
return "∞"; return "∞";
}; };
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export type ByteUnitSystem = "binary" | "decimal";
export interface FormatBytesOptions {
/**
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
*/
unit?: ByteUnitSystem;
}
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
let index = 0;
let value = bytes;
while (value >= base && index < lastIndex) {
value /= base;
index++;
}
return index;
};
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
const resolveUnitConfig = (options: FormatBytesOptions) => {
const { unit = "binary" } = options;
return {
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
base: unit === "binary" ? 1024 : 1000,
};
};
/**
* Format a byte value as a human-readable string with a unit suffix.
*
* Picks the largest unit that keeps the scaled value below the base. Use
* `formatBytesPair` when two related values (e.g. used and available) should
* share a common unit.
*
* @example
* formatBytes(0); // "0.0 B"
* formatBytes(1024); // "1.0 KiB"
* formatBytes(985828802560); // "918.1 GiB"
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
*/
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
const { units, base } = resolveUnitConfig(options);
const safe = sanitizeBytes(bytes);
const index = pickUnitIndex(safe, base, units.length - 1);
const scaled = safe / base ** index;
return `${scaled.toFixed(1)} ${units[index]}`;
};
/**
* Format two related byte values (typically `used` and `available`) with a
* shared unit. The unit is picked from the sum of the two values so the
* combined display stays consistent.
*
* @example
* formatBytesPair(985828802560, 2858736793190);
* // { used: "0.9 TiB", available: "2.6 TiB" }
*/
export const formatBytesPair = (
used: number,
available: number,
options: FormatBytesOptions = {},
): { used: string; available: string } => {
const { units, base } = resolveUnitConfig(options);
const safeUsed = sanitizeBytes(used);
const safeAvailable = sanitizeBytes(available);
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
const suffix = units[index];
return {
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
};
};
/**
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
* string with a `/s` suffix. The value is formatted with the same rules as
* `formatBytes` and a trailing `/s` is appended.
*
* @example
* formatByteRate(0); // "0.0 B/s"
* formatByteRate(1024); // "1.0 KiB/s"
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
*/
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
`${formatBytes(bytes, options)}/s`;
const IMPERIAL_MULTIPLIER = 1.609344; const IMPERIAL_MULTIPLIER = 1.609344;
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER; export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
describe("formatBytes", () => {
it("returns bytes for small values", () => {
expect(formatBytes(0)).toBe("0.0 B");
expect(formatBytes(1)).toBe("1.0 B");
expect(formatBytes(1023)).toBe("1023.0 B");
});
it("scales binary units correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
});
it("scales decimal units when requested", () => {
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
});
it("formats a typical storage value with one decimal", () => {
expect(formatBytes(985828802560)).toBe("918.1 GiB");
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
});
it("caps at the largest unit to avoid overflow", () => {
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
});
it("returns the zero value for negative or non-finite input", () => {
expect(formatBytes(-1)).toBe("0.0 B");
expect(formatBytes(Number.NaN)).toBe("0.0 B");
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
});
});
describe("formatBytesPair", () => {
it("picks the unit of the larger total and applies it to both values", () => {
const { used, available } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("0.9 TiB");
expect(available).toBe("2.6 TiB");
});
it("uses a smaller unit when both values fit in it", () => {
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("100.0 MiB");
expect(available).toBe("200.0 MiB");
});
it("falls back to zero when both inputs are invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, -1);
expect(used).toBe("0.0 B");
expect(available).toBe("0.0 B");
});
it("uses the same unit across both values when one is invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TiB");
expect(available).toBe("1.0 TiB");
});
it("honours the decimal unit option", () => {
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
expect(used).toBe("0.5 TB");
expect(available).toBe("1.5 TB");
});
});
describe("formatByteRate", () => {
it("appends a /s suffix to the formatted value", () => {
expect(formatByteRate(0)).toBe("0.0 B/s");
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
});
it("honours the decimal unit option", () => {
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
});
it("returns zero for invalid input", () => {
expect(formatByteRate(-1)).toBe("0.0 B/s");
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
});
});
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https"; import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls"; import { checkServerIdentity } from "node:tls";
import axios from "axios"; import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici"; import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici"; import { fetch } from "undici";
import { import {
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
}; };
}; };
export const createCertificateAgentAsync = async (override?: { export const createCertificateAgentAsync = async (
ca: string | string[]; override?: {
checkServerIdentity: typeof checkServerIdentity; ca: string | string[];
}) => { checkServerIdentity: typeof checkServerIdentity;
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
) => {
return new UndiciHttpAgent({ return new UndiciHttpAgent({
...agentOptions,
connect: override ?? { connect: override ?? {
ca: await getAllTrustedCertificatesAsync(), ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
export const fetchWithTrustedCertificatesAsync = async ( export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo, url: RequestInfo,
options?: RequestInit & { timeout?: number }, options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => { ): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined); const agent = await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
);
if (options?.timeout) { if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
return await withTimeoutAsync( return await withTimeoutAsync(
async (signal) => async (signal) =>
fetch(url, { fetch(url, {
...options, ...fetchOptions,
signal, signal,
dispatcher: agent, dispatcher: agent,
}), }),
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
); );
} }
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
return fetch(url, { return fetch(url, {
...options, ...fetchOptions,
dispatcher: agent, dispatcher: agent,
}); });
}; };
@@ -295,6 +295,13 @@
"when": 1780764500000, "when": 1780764500000,
"tag": "0041_merge_custom_widget_url", "tag": "0041_merge_custom_widget_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
} }
] ]
} }
@@ -57,6 +57,13 @@
"when": 1780764500000, "when": 1780764500000,
"tag": "0007_merge_custom_widget_url", "tag": "0007_merge_custom_widget_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
} }
] ]
} }
@@ -281,6 +281,13 @@
"when": 1780764500000, "when": 1780764500000,
"tag": "0039_merge_custom_widget_url", "tag": "0039_merge_custom_widget_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -379,7 +379,7 @@ export const integrationDefs = {
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"), documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413, defaultPort: 3413,
apiKeySettingsPath: "/settings/api-keys", apiKeySettingsPath: "/settings/integrations",
}, },
tracearr: { tracearr: {
name: "Tracearr", name: "Tracearr",
@@ -15,6 +15,12 @@ describe("getIntegrationApiKeyUrl", () => {
); );
}); });
it("builds URL for patchmon", () => {
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
"http://patchmon.local:3413/settings/integrations",
);
});
it("returns null for integrations without apiKeySettingsPath", () => { it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull(); expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
}); });
@@ -19,15 +19,66 @@ import type {
BeszelSystemDetails, BeszelSystemDetails,
BeszelSystemdService, BeszelSystemdService,
BeszelSystemStatsRecord, BeszelSystemStatsRecord,
BeszelSystemStats,
BeszelContainerStats,
CreateAlertInput, CreateAlertInput,
LiveStatsEvent, LiveStatsEvent,
PocketBaseListResponse, PocketBaseListResponse,
UpdateAlertInput, UpdateAlertInput,
} from "./beszel-types"; } from "./beszel-types";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
const logger = createLogger({ module: "beszel-integration" }); const logger = createLogger({ module: "beszel-integration" });
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const realtimeRetryDelaysMs = [200, 300, 500, 1_000, 1_500, 2_000] as const;
const supportsRealtimeMetrics = (version: string) => {
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
return major > 0 || minor >= 13;
};
const waitForRetryAsync = async (delayMs: number, signal: AbortSignal) =>
await new Promise<void>((resolve) => {
if (signal.aborted) return resolve();
const timeout = setTimeout(done, delayMs);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timeout);
signal.removeEventListener("abort", done);
resolve();
}
});
interface BeszelRealtimeSnapshot {
stats?: BeszelSystemStats;
container?: BeszelContainerStats[];
}
export const normalizeRealtimeSnapshot = (
payload: BeszelRealtimeSnapshot,
systemId: string,
receivedAt = new Date(),
): LiveStatsEvent[] => {
const created = receivedAt.toISOString();
const id = `realtime-${receivedAt.getTime()}`;
const events: LiveStatsEvent[] = [];
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
events.push({
type: "system_stats",
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
});
}
if (Array.isArray(payload.container)) {
events.push({
type: "container_stats",
record: { id, system: systemId, stats: payload.container, type: "1m", created, updated: created },
});
}
return events;
};
interface BeszelSession { interface BeszelSession {
token: string; token: string;
@@ -285,123 +336,163 @@ export class BeszelIntegration extends Integration {
}); });
} }
/** /** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
* Subscribe to real-time system and container stats via PocketBase SSE.
* Connects to the PocketBase Realtime API and parses incoming SSE events,
* filtering by systemId. This provides live-updating metrics (≤1s latency
* from agent) instead of polling the REST API every 5s.
*/
public async subscribeRealtimeMetrics( public async subscribeRealtimeMetrics(
systemId: string, systemId: string,
onMessage: (event: LiveStatsEvent) => void, onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
const session = await this.authenticateAsync(); const systems = await this.getSystemsAsync();
const realtimeUrl = this.url("/api/realtime"); const system = systems.find((candidate) => candidate.id === systemId);
if (!system) throw new Error("Selected Beszel system was not found");
logger.debug("Opening Beszel SSE connection for realtime metrics", { if (system.status !== "up")
integrationId: this.integration.id, throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
systemId, if (!supportsRealtimeMetrics(system.info.v)) {
url: realtimeUrl.pathname, throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
});
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { Authorization: session.token },
signal,
});
if (!response.ok) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: response.status,
});
throw new ResponseError(response);
} }
const reader = response.body?.getReader(); const realtimeUrl = this.url("/api/realtime");
if (!reader) throw new Error("No response body in SSE stream"); const topic = createRealtimeMetricsTopic(systemId);
let retryAttempt = 0;
const decoder = new TextDecoder(); let emittedSnapshotCount = 0;
let buffer = "";
let subscribed = false;
const processLine = async (line: string) => {
if (!line.startsWith("data: ")) return;
while (!signal.aborted) {
try { try {
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>; let session = await this.authenticateAsync();
const openStream = async () =>
// PB_CONNECT delivers the clientId — use it to POST our subscriptions await fetchWithTrustedCertificatesAsync(realtimeUrl, {
if (!subscribed && typeof parsed.clientId === "string") {
subscribed = true;
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { headers: {
Accept: "text/event-stream",
Authorization: session.token, Authorization: session.token,
"Content-Type": "application/json", "Cache-Control": "no-cache",
Pragma: "no-cache",
}, },
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal, signal,
bodyTimeout: 0,
}); });
if (!subscribeResponse.ok) { logger.debug("Opening Beszel rt_metrics SSE connection", {
logger.warn("Beszel SSE subscription POST failed", { integrationId: this.integration.id,
systemId,
retryAttempt,
});
let response = await openStream();
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await openStream();
}
if (!response.ok) throw new ResponseError(response);
const reader = response.body?.getReader();
if (!reader) throw new Error("Beszel realtime response has no body");
const decoder = new TextDecoder();
const parser = new PocketBaseSseParser();
let clientId: string | undefined;
const processFrameAsync = async (frame: ReturnType<PocketBaseSseParser["push"]>[number]) => {
if (frame.event === "PB_CONNECT") {
let fallbackClientId: string | undefined;
try {
const connectData = JSON.parse(frame.data) as { clientId?: string };
fallbackClientId = connectData.clientId;
} catch {
// PocketBase normally provides the client ID in the SSE id field.
}
clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal,
});
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id, integrationId: this.integration.id,
systemId, systemId,
status: subscribeResponse.status, clientId,
}); });
throw new ResponseError(subscribeResponse); return;
} }
logger.debug("Beszel SSE subscribed to collections", { if (!frame.event.startsWith("rt_metrics")) return;
try {
const snapshot = JSON.parse(frame.data) as BeszelRealtimeSnapshot;
const events = normalizeRealtimeSnapshot(snapshot, systemId);
if (events.length === 0) return;
emittedSnapshotCount += 1;
retryAttempt = 0;
for (const event of events) onMessage(event);
if (emittedSnapshotCount === 1 || emittedSnapshotCount % 30 === 0) {
logger.debug("Forwarded Beszel rt_metrics snapshots", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
containerCount: snapshot.container?.length ?? 0,
});
}
} catch (error) {
logger.warn("Failed to parse Beszel rt_metrics payload", {
integrationId: this.integration.id,
systemId,
error: error instanceof Error ? error.message : String(error),
});
}
};
logger.debug("Beszel rt_metrics SSE connection established", {
integrationId: this.integration.id,
systemId,
contentType: response.headers.get("content-type"),
});
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
await processFrameAsync(frame);
}
}
for (const frame of parser.push(decoder.decode())) await processFrameAsync(frame);
for (const frame of parser.finish()) await processFrameAsync(frame);
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) {
if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1;
logger.debug("Retrying Beszel rt_metrics handshake after reauthentication", {
integrationId: this.integration.id, integrationId: this.integration.id,
systemId, systemId,
clientId: parsed.clientId,
}); });
return; continue;
} }
if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
const record = parsed.record as Record<string, unknown> | undefined; const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
if (!record || record.system !== systemId) return; retryAttempt += 1;
logger.warn("Beszel rt_metrics connection lost; retrying", {
if (Array.isArray(record.stats)) { integrationId: this.integration.id,
onMessage({ systemId,
type: "container_stats", retryAttempt,
record: record as unknown as BeszelContainerStatsRecord, delayMs,
}); error: error instanceof Error ? error.message : String(error),
} else if (record.stats && typeof record.stats === "object") { });
onMessage({ await waitForRetryAsync(delayMs, signal);
type: "system_stats",
record: record as unknown as BeszelSystemStatsRecord,
});
}
} catch (error) {
if (error instanceof ResponseError) throw error;
// skip malformed SSE data lines
} }
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
await processLine(line);
}
}
} finally {
reader.releaseLock();
logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
} }
logger.debug("Beszel rt_metrics subscription stopped", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
});
} }
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
/** disk usage (%) */ /** disk usage (%) */
dp: number; dp: number;
/** disk read (MB/s) */ /** disk read (MB/s) */
dr: number; dr?: number;
/** disk write (MB/s) */ /** disk write (MB/s) */
dw: number; dw?: number;
/** disk read max (MB/s) */ /** disk read max (MB/s) */
drm?: number; drm?: number;
/** disk write max (MB/s) */ /** disk write max (MB/s) */
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
/** disk IOPS max [read, write] */ /** disk IOPS max [read, write] */
diom?: [number, number]; diom?: [number, number];
/** network sent — all interfaces (bytes/s) */ /** network sent — all interfaces (bytes/s) */
ns: number; ns?: number;
/** network received — all interfaces (bytes/s) */ /** network received — all interfaces (bytes/s) */
nr: number; nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */ /** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number]; b?: [number, number];
/** network sent max (bytes/s) */ /** network sent max (bytes/s) */
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
} }
export interface BeszelExtraFsStats { export interface BeszelExtraFsStats {
/** total disk (bytes) */ /** total disk (GiB) */
d: number; d: number;
/** disk used (bytes) */ /** disk used (GiB) */
du: number; du: number;
/** read (bytes/s) */ /** read (bytes/s) */
r: number; r: number;
/** write (bytes/s) */ /** write (bytes/s) */
w: number; w: number;
/** read max (bytes/s) */ /** read max (bytes/s) */
rm: number; rm?: number;
/** write max (bytes/s) */ /** write max (bytes/s) */
wm: number; wm?: number;
/** read (IOPS) */ /** read (IOPS) */
rb: number; rb?: number;
/** write (IOPS) */ /** write (IOPS) */
wb: number; wb?: number;
/** read max (IOPS) */ /** read max (IOPS) */
rbm: number; rbm?: number;
/** write max (IOPS) */ /** write max (IOPS) */
wbm: number; wbm?: number;
} }
export interface BeszelGPUData { export interface BeszelGPUData {
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
memory: number; memory: number;
/** disk usage (%) */ /** disk usage (%) */
disk: number; disk: number;
/** extra filesystem usage (%) keyed by mount path */
extraFilesystems: Record<string, number>;
/** GPU usage (%) */ /** GPU usage (%) */
gpu: number; gpu: number;
/** load average [1m, 5m, 15m] */ /** load average [1m, 5m, 15m] */
@@ -0,0 +1,65 @@
export interface PocketBaseSseFrame {
event: string;
id?: string;
data: string;
}
export const createRealtimeMetricsTopic = (systemId: string) =>
`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: systemId } }))}`;
export class PocketBaseSseParser {
private buffer = "";
private event = "message";
private id: string | undefined;
private dataLines: string[] = [];
public push(chunk: string): PocketBaseSseFrame[] {
this.buffer += chunk;
const frames: PocketBaseSseFrame[] = [];
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const frame = this.processLine(line);
if (frame) frames.push(frame);
}
return frames;
}
public finish(): PocketBaseSseFrame[] {
const frames: PocketBaseSseFrame[] = [];
if (this.buffer !== "") {
const frame = this.processLine(this.buffer);
if (frame) frames.push(frame);
this.buffer = "";
}
const trailingFrame = this.dispatch();
if (trailingFrame) frames.push(trailingFrame);
return frames;
}
private processLine(line: string): PocketBaseSseFrame | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colonIndex = line.indexOf(":");
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
let value = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.event = value;
else if (field === "id") this.id = value;
else if (field === "data") this.dataLines.push(value);
return null;
}
private dispatch(): PocketBaseSseFrame | null {
if (this.dataLines.length === 0 && this.event === "message" && this.id === undefined) return null;
const frame = { event: this.event, id: this.id, data: this.dataLines.join("\n") };
this.event = "message";
this.id = undefined;
this.dataLines = [];
return frame;
}
}
@@ -40,7 +40,124 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
})); }));
import { BeszelIntegration } from "../beszel-integration"; import { BeszelIntegration, normalizeRealtimeSnapshot } from "../beszel-integration";
import type { LiveStatsEvent } from "../beszel-types";
import { createRealtimeMetricsTopic } from "../pocketbase-realtime";
describe("normalizeRealtimeSnapshot", () => {
test("normalizes current Beszel host and container snapshots", () => {
const receivedAt = new Date("2026-07-11T13:51:30.217Z");
const events = normalizeRealtimeSnapshot(
{
stats: {
cpu: 18.07,
m: 31.09,
mu: 16.53,
mp: 53.18,
mb: 14.32,
s: 4,
su: 3.9,
d: 938.8,
du: 455.81,
dp: 51.15,
efs: { sda: { d: 1906.8, du: 1656.45, r: 25.63, w: 0 } },
},
container: [{ n: "homarr", c: 1.65, m: 650.56, b: [356066, 19702867] }],
},
"system-1",
receivedAt,
);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
type: "system_stats",
record: { system: "system-1", created: receivedAt.toISOString(), stats: { cpu: 18.07 } },
});
expect(events[1]).toMatchObject({
type: "container_stats",
record: { system: "system-1", stats: [{ n: "homarr", m: 650.56 }] },
});
});
test("ignores snapshots without metric data", () => {
expect(normalizeRealtimeSnapshot({}, "system-1")).toEqual([]);
});
});
describe("subscribeRealtimeMetrics", () => {
test("subscribes to Beszel rt_metrics and forwards one-second snapshots", async () => {
const originalFetch = globalThis.fetch;
const topic = createRealtimeMetricsTopic("system-1");
const subscriptionBodies: unknown[] = [];
const encoder = new TextEncoder();
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/collections/users/auth-with-password")) {
return Response.json({ token: "test-token", record: { id: "user-1" } });
}
if (url.includes("/api/collections/systems/records")) {
return Response.json({
page: 1,
perPage: 500,
totalItems: 1,
totalPages: 1,
items: [
{
id: "system-1",
name: "Server",
host: "localhost",
port: "45876",
status: "up",
info: { cpu: 1, mp: 2, dp: 3, u: 4, v: "0.18.7" },
created: "2026-07-11T00:00:00.000Z",
updated: "2026-07-11T00:00:00.000Z",
},
],
});
}
if (url.endsWith("/api/realtime") && init?.method === "POST") {
subscriptionBodies.push(JSON.parse(String(init.body)));
expect(new Headers(init.headers).get("Authorization")).toBe("test-token");
return new Response(null, { status: 204 });
}
if (url.endsWith("/api/realtime")) {
const snapshot = JSON.stringify({
stats: { cpu: 5, m: 10, mu: 4, mp: 40, mb: 1, s: 0, su: 0, d: 20, du: 8, dp: 40 },
container: [{ n: "homarr", c: 1, m: 256 }],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: PB_CONNECT\nid: client-1\ndata: {}\n\n"));
controller.enqueue(encoder.encode(`event: ${topic}\ndata: ${snapshot}\n\n`));
controller.close();
},
});
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
}
throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`);
}) as typeof fetch;
try {
const integration = createBeszelIntegration();
const controller = new AbortController();
const events: LiveStatsEvent[] = [];
await integration.subscribeRealtimeMetrics(
"system-1",
(event) => {
events.push(event);
if (events.length === 2) controller.abort();
},
controller.signal,
);
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
function createBeszelIntegration() { function createBeszelIntegration() {
return new BeszelIntegration({ return new BeszelIntegration({
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "../pocketbase-realtime";
describe("PocketBaseSseParser", () => {
test("parses PB_CONNECT client IDs and CRLF-delimited frames split across chunks", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: PB_CONNECT\r\nid: client-")).toEqual([]);
expect(parser.push("123\r\ndata: {}\r\n\r\n")).toEqual([{ event: "PB_CONNECT", id: "client-123", data: "{}" }]);
});
test("joins multiline data and ignores keepalive comments", () => {
const parser = new PocketBaseSseParser();
expect(parser.push(': keepalive\n\nevent: rt_metrics\ndata: {"stats":\ndata: {}}\n\n')).toEqual([
{ event: "rt_metrics", id: undefined, data: '{"stats":\n{}}' },
]);
});
test("flushes a trailing frame when the stream ends without a blank line", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: rt_metrics\ndata: {}")).toEqual([]);
expect(parser.finish()).toEqual([{ event: "rt_metrics", id: undefined, data: "{}" }]);
});
});
describe("createRealtimeMetricsTopic", () => {
test("matches the PocketBase custom-topic options encoding used by Beszel", () => {
const topic = createRealtimeMetricsTopic("system-1");
expect(topic).toBe(`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: "system-1" } }))}`);
});
});
@@ -1,5 +1,3 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis"; import "@homarr/redis";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
.map((storage, index) => ({ .map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`, deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: humanFileSize(storageLoad[index]!), used: `${storageLoad[index]!}`,
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`, available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0, percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})), })),
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent"; const type = "torrent";
const client = await this.getClientAsync(); const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit }); const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce( const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }), ({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 }, { down: 0, up: 0 },
@@ -226,6 +226,7 @@ export class BeszelMockService {
u: randInt(86400, 2_000_000), u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100), mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100), dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB, bb: rand(200, 3000) * KB,
v: "0.8.2", v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0, g: i === 3 ? rand(15, 45) : 0,
@@ -1,7 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch"; import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server"; import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -59,7 +58,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597 cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({ fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name, deviceName: disk.name,
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes) available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})), })),
+1
View File
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
cpu: info.cpu, cpu: info.cpu,
memory: info.mp, memory: info.mp,
disk: info.dp, disk: info.dp,
extraFilesystems: info.efs ?? {},
gpu: info.g ?? 0, gpu: info.g ?? 0,
loadAvg: info.la ?? null, loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s) // bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
+7 -5
View File
@@ -1956,6 +1956,8 @@
"clear": "Clear formatting", "clear": "Clear formatting",
"heading": "Heading {level}", "heading": "Heading {level}",
"align": "Align text: {position}", "align": "Align text: {position}",
"directionLtr": "Set text direction left to right",
"directionRtl": "Set text direction right to left",
"blockquote": "Blockquote", "blockquote": "Blockquote",
"horizontalLine": "Horizontal line", "horizontalLine": "Horizontal line",
"bulletList": "Bullet list", "bulletList": "Bullet list",
@@ -4031,9 +4033,6 @@
}, },
"showDockerNetwork": { "showDockerNetwork": {
"label": "Show Docker network chart" "label": "Show Docker network chart"
},
"showStorage": {
"label": "Show storage chart"
} }
}, },
"selectSystem": "Select system", "selectSystem": "Select system",
@@ -4060,8 +4059,8 @@
}, },
"disk": { "disk": {
"title": "Disk Usage", "title": "Disk Usage",
"subtitle": "Usage of root partition", "subtitle": "Usage by filesystem",
"series": "Used" "series": "Root"
}, },
"diskIO": { "diskIO": {
"title": "Disk I/O", "title": "Disk I/O",
@@ -5206,6 +5205,9 @@
"title": "Containers", "title": "Containers",
"table": { "table": {
"updated": "Updated {when}", "updated": "Updated {when}",
"refresh": {
"lastUpdated": "Last refresh {when}"
},
"search": "Seach {count} containers", "search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected", "selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers", "footer": "Total {count} containers",
@@ -3,7 +3,7 @@
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common"; import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions"; import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
</Stack> </Stack>
); );
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s"); const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types"; import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
interface SizeConfig { interface SizeConfig {
iconSize: number; iconSize: number;
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight)); return Math.max(1, Math.floor(available / size.rowHeight));
}; };
const MIN_CELL_WIDTH = 120; const MIN_CELL_WIDTH = 180;
const MIN_CELL_HEIGHT = 80; const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, itemCount: number): number => { const getColCount = (width: number, height: number, itemCount: number): number => {
if (itemCount <= 1) return 1; if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
let best = 1; return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
}; };
interface MetricRowProps { interface MetricRowProps {
@@ -133,20 +133,25 @@ interface MetricRowProps {
} }
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => ( const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}> <Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right"> <Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{value} {value}
</Text> </Text>
{progress && ( {progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} /> <Progress
value={progress.value}
color={progress.color}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
)} )}
</Group> </Group>
); );
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}> <Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
{icon} {icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}> <Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{label} {label}
</Text> </Text>
<MetricValue value={value} progress={progress} size={size} /> <MetricValue value={value} progress={progress} size={size} />
@@ -195,14 +200,13 @@ const metricRenderers = [
{ {
key: "showDisk", key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow <Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
key="disk" <HardDrive size={sz.iconSize} />
icon={<HardDrive size={sz.iconSize} />} <Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
label={t("metric.disk")} {t("metric.disk")}
value={formatPercent(s.disk)} </Text>
progress={{ value: s.disk, color: thresholdColor(s.disk) }} <DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
size={sz} </Group>
/>
), ),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk, visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
}, },
@@ -399,7 +403,7 @@ export default function BeszelSystemGridWidget({
); );
} }
const cols = getColCount(width, filteredSystems.length); const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1; const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows; const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT; const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid, icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"], supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -1,54 +1,21 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react"; import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css"; import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDockerChartData,
useEfsChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { makeTooltipProps } from "../beszel/_shared/tooltip"; import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { useLiveStats } from "../beszel/_shared/use-live-stats";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const gridColumns = [1, 2] as const;
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
if (!show || !data) return undefined;
return data;
}
export default function BeszelSystemStatsWidget({ export default function BeszelSystemStatsWidget({
options, options,
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo( const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult], [systemsResult],
); );
const selectedSystem = options.systemId || systems[0]?.value || ""; const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label; const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((s) => s.value === selectedSystem); const systemExists = systems.some((system) => system.value === selectedSystem);
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
const handleSelectSystem = useCallback( const handleSelectSystem = useCallback(
(value: string) => { (value: string) => {
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions], [setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
); );
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m";
const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
},
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = statsResult;
if (isLive) {
activeStats = liveData;
}
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats;
const timePeriod = options.timePeriod as BeszelTimePeriod;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames,
"cpu",
timePeriod,
);
const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames,
"memory",
timePeriod,
);
const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames,
"network",
timePeriod,
);
const efsPaths = useMemo(() => {
if (!systemStats) return [];
const paths = new Set<string>();
for (const record of systemStats) {
if (record.stats.efs) {
for (const path of Object.keys(record.stats.efs)) {
paths.add(path);
}
}
}
return [...paths];
}, [systemStats]);
const storageData = useEfsChartData(statsWhenShown(options.showStorage, systemStats), efsPaths, timePeriod);
const containerSeries = useMemo(
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
[containerNames],
);
const storageSeries = useMemo(
() => efsPaths.map((path, i) => ({ name: path, color: containerColors[i % containerColors.length] as string })),
[efsPaths],
);
const cols = gridColumns[Number(width > 600)];
if (systemsError) throw systemsError; if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError; if (systemsPending)
if (systemsPending) {
return ( return (
<Center h="100%"> <Center h="100%">
<Loader size="sm" /> <Loader size="sm" />
</Center> </Center>
); );
}
if (systems.length === 0) { if (systems.length === 0) {
return ( return (
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
); );
} }
if (!systemsPending && !systemExists && systems.length > 0) { if (!systemExists) {
return ( return (
<Center h="100%"> <Center h="100%">
<Stack align="center" gap="sm" p="md"> <Stack align="center" gap="sm" p="md">
@@ -260,22 +99,6 @@ export default function BeszelSystemStatsWidget({
); );
} }
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}> <Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
<ScrollArea <ScrollArea
h="100%" h="100%"
className={classes.beszelStatsContainer} className={classes.beszelStatsContainer}
style={{ pointerEvents: (isEditMode && "none") || undefined }} style={{ pointerEvents: isEditMode ? "none" : undefined }}
> >
<Stack gap="md" p="sm"> <Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && ( {!isEditMode && systems.length > 1 && (
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
</Button> </Button>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{systems.map((s) => ( {systems.map((system) => (
<Menu.Item <Menu.Item
key={s.value} key={system.value}
fz="xs" fz="xs"
fw={s.value === selectedSystem ? 600 : 400} fw={system.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined} c={system.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)} onClick={() => handleSelectSystem(system.value)}
> >
{s.label} {system.label}
</Menu.Item> </Menu.Item>
))} ))}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
)} )}
<BeszelStatsView
{!isLive && !activeStats && systemReady && selectedSystem !== "" && ( integrationIds={integrationIds}
<Center h={CHART_HEIGHT}> systemId={selectedSystem}
<Loader size="sm" /> timePeriod={options.timePeriod as BeszelTimePeriod}
</Center> columns={width > 600 ? 2 : 1}
)} visibility={{
cpu: options.showCpu,
{!isLive && activeStats && "error" in activeStats && activeStats.error && ( memory: options.showMemory,
<Center h={CHART_HEIGHT}> disk: options.showDisk,
<Stack align="center" gap="xs"> diskIO: options.showDiskIO,
<IconServerOff size={24} opacity={0.5} /> network: options.showNetwork,
<Text size="sm" c="dimmed"> dockerCpu: options.showDockerCpu,
{activeStats.error} dockerMemory: options.showDockerMemory,
</Text> dockerNetwork: options.showDockerNetwork,
</Stack> }}
</Center> onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
)} />
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showStorage && storageData.length > 0 && storageSeries.length > 0 && (
<BeszelChartPanel
title={t("chart.storage.title")}
subtitle={t("chart.storage.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: storageData,
type: "stacked",
series: storageSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
)}
</Stack> </Stack>
</ScrollArea> </ScrollArea>
</Box> </Box>
@@ -16,6 +16,7 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", { export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine, icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"], supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDockerCpu: factory.switch({ defaultValue: true }), showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }), showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }), showDockerNetwork: factory.switch({ defaultValue: true }),
showStorage: factory.switch({ defaultValue: true }),
})); }));
}, },
errors: { errors: {
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types"; import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 }; const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -97,11 +105,16 @@ export default function BeszelSystemTableWidget({
}, [filteredSystems, sortStatus]); }, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => ( const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}> <Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}> <Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)} {formatPercent(value)}
</Text> </Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} /> <Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group> </Group>
); );
@@ -156,7 +169,15 @@ export default function BeszelSystemTableWidget({
</Group> </Group>
), ),
sortable: true, sortable: true,
render: (record) => <PercentCell value={record.disk} />, render: (record) => (
<DiskUsage
system={record}
fontSize={size.fontSize}
progressSize={size.progressSize}
valueMiw={size.valueMiw}
valueGap={8}
/>
),
}, },
options.showGpu && { options.showGpu && {
accessor: "gpu", accessor: "gpu",
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", { export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable, icon: IconTable,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"], supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
const record = (
created: string,
du: number,
efs: BeszelSystemStatsRecord["stats"]["efs"],
): BeszelSystemStatsRecord => ({
id: created,
system: "system-1",
type: "1m",
created,
updated: created,
stats: {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
s: 0,
su: 0,
d: 938.8,
du,
dp: 51.15,
efs,
},
});
describe("buildDiskChartData", () => {
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:46:00.000Z", 455.81, {
sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
}),
],
["sda", "sdb", "sdc"],
"Root",
"1h",
);
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
});
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
record("2026-07-11T13:46:00.000Z", 1, undefined),
],
["sda"],
"Root",
"1h",
);
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
{ Root: 1, sda: 0 },
{ Root: 2, sda: 4 },
]);
});
});
describe("padLiveTimeGrid", () => {
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
expect(data).toHaveLength(60);
expect(data[0]).not.toHaveProperty("CPU");
expect(data[58]).not.toHaveProperty("CPU");
expect(data[59]).toMatchObject({ CPU: 18.07 });
});
test("leaves null gaps for missing seconds instead of stretching existing points", () => {
const data = padLiveTimeGrid([
{ time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
]);
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
});
});
+62 -14
View File
@@ -30,6 +30,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
} }
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) { function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
const days = periodDays[timePeriod]; const days = periodDays[timePeriod];
if (!days) return data; if (!days) return data;
@@ -50,6 +51,38 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime()); return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
} }
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
if (data.length === 0) return data;
const end = dayjs(
data.reduce((latest, point) => {
const timestamp = new Date(point.rawTime as string).getTime();
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
}, 0),
).startOf("second");
const pointsBySecond = new Map(
data.map(
(point) =>
[
dayjs(point.rawTime as string)
.startOf("second")
.valueOf(),
point,
] as const,
),
);
return Array.from({ length: pointCount }, (_, index) => {
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
rawTime: timestamp.toISOString(),
}
);
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const; const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const; const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
@@ -174,24 +207,39 @@ const defaultContainerExtractors: Record<string, ContainerExtractor> = {
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)), network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
}; };
export const useEfsChartData = ( export const useDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined, systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[], efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h", timePeriod: BeszelTimePeriod = "1h",
) => ) =>
useMemo(() => { useMemo(
if (!systemStats?.length) return []; () => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
const { fmt, ordered } = prepareRecords(systemStats, timePeriod); [systemStats, efsPaths, rootSeriesName, timePeriod],
const mapped = ordered.map((record) => { );
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const efs = record.stats.efs ?? {}; export const buildDiskChartData = (
for (const path of efsPaths) { systemStats: BeszelSystemStatsRecord[] | undefined,
point[path] = efs[path]?.du ?? 0; efsPaths: string[],
} rootSeriesName: string,
return point; timePeriod: BeszelTimePeriod = "1h",
}); ) => {
return padTimeGrid(mapped, timePeriod); if (!systemStats?.length) return [];
}, [systemStats, efsPaths, timePeriod]); const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
rawTime: record.created,
[rootSeriesName]: record.stats.du,
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
};
export const useDockerChartData = ( export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined, containerStats: BeszelContainerStatsRecord[] | undefined,
@@ -0,0 +1,105 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
interface DiskUsageProps {
system: BeszelSystemRow;
fontSize: string;
progressSize: "xs" | "sm";
valueMiw: number;
valueGap?: number;
}
const mountLabel = (path: string) =>
path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
};
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
describe("Beszel storage formatting", () => {
test("keeps smaller values in GB", () => {
expect(formatGB(455.81)).toBe("455.81 GB");
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
});
test("promotes large GiB values to TB", () => {
expect(formatGB(3323)).toBe("3.25 TB");
expect(formatGB(3936.86)).toBe("3.84 TB");
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
});
test("maps progress sizes consistently", () => {
expect(getProgressTrackSize("xs")).toBe(6);
expect(getProgressTrackSize("sm")).toBe(9);
});
});
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
export const formatGB = (value: number): string => { export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB"; if (!value || !Number.isFinite(value)) return "0 GB";
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`; return `${value.toFixed(2)} GB`;
}; };
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`; export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const chartAxisFormatters = { export const chartAxisFormatters = {
percent: formatPercent, percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`), gb: (value: number) => {
const numericValue = Number(value);
if (!numericValue) return "0";
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
return `${numericValue.toFixed(0)}G`;
},
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`), mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"), bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"), rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
@@ -0,0 +1,339 @@
"use client";
import { useMemo } from "react";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDiskChartData,
useDockerChartData,
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
export interface BeszelStatsVisibility {
cpu: boolean;
memory: boolean;
disk: boolean;
diskIO: boolean;
network: boolean;
dockerCpu: boolean;
dockerMemory: boolean;
dockerNetwork: boolean;
}
interface BeszelStatsViewProps {
integrationIds: string[];
systemId: string;
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
integrationIds,
systemId,
timePeriod,
visibility,
columns,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = isLive ? liveData : historicalData;
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
for (const record of systemStats ?? []) {
for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
}
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
...efsPaths.map((path, index) => ({
name: path,
color: containerColors[(index + 1) % containerColors.length] as string,
})),
],
[efsPaths, rootSeriesName],
);
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
const containerSeries = useMemo(
() =>
containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
[containerNames],
);
const dockerCpuData = useDockerChartData(
whenVisible(visibility.dockerCpu, containerStats),
containerNames,
"cpu",
timePeriod,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
{onSwitchToHistorical && (
<Button size="xs" variant="light" onClick={onSwitchToHistorical}>
{t("error.switchToHistorical")}
</Button>
)}
</Stack>
</Center>
);
}
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
}
if ("error" in activeStats && activeStats.error) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
);
}
return (
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{visibility.memory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.disk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.diskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{visibility.network && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{visibility.dockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{visibility.dockerMemory && dockerMemoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{visibility.dockerNetwork && dockerNetworkData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
);
}
@@ -1,25 +1,15 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useState } from "react";
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core"; import { SegmentedControl, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelTimePeriod } from "./chart"; import type { BeszelTimePeriod } from "./chart";
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart"; import { BeszelStatsView } from "./stats-view";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
import { makeTooltipProps } from "./tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1m", label: "Live" },
{ value: "1h", label: "1H" }, { value: "1h", label: "1H" },
{ value: "12h", label: "12H" }, { value: "12h", label: "12H" },
{ value: "24h", label: "24H" }, { value: "24h", label: "24H" },
@@ -27,141 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "30d", label: "30D" }, { value: "30d", label: "30D" },
]; ];
const allCharts = {
cpu: true,
memory: true,
disk: true,
diskIO: true,
network: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
} as const;
interface BeszelSystemStatsModalProps { interface BeszelSystemStatsModalProps {
integrationId: string; integrationId: string;
systemId: string; systemId: string;
} }
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => { export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
const t = useScopedI18n("widget.beszelSystemStats");
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h"); const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
const { data } = clientApi.widget.beszel.getSystemStats.useQuery({
integrationIds: [innerProps.integrationId],
systemId: innerProps.systemId,
timePeriod,
includeDocker: false,
});
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = data?.systemStats;
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
return ( return (
<Stack gap="md"> <Stack gap="md">
<SegmentedControl <SegmentedControl
size="xs" size="xs"
value={timePeriod} value={timePeriod}
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)} onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
data={timePeriodOptions} data={timePeriodOptions}
/> />
<SimpleGrid cols={2} spacing="md"> <BeszelStatsView
{cpuData.length > 0 && ( integrationIds={[innerProps.integrationId]}
<BeszelChartPanel systemId={innerProps.systemId}
title={t("chart.cpu.title")} timePeriod={timePeriod}
chartProps={{ visibility={allCharts}
h: CHART_HEIGHT, columns={2}
data: cpuData, onSwitchToHistorical={() => setTimePeriod("1h")}
series: series.cpu, />
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</SimpleGrid>
</Stack> </Stack>
); );
}).withOptions({ }).withOptions({
@@ -1,12 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
// 120 records at ~1 per second = 2 minutes of rolling data for charts // Beszel emits roughly one record per second. Keep a sliding one-minute window
const MAX_BUFFER = 120; // without synthesizing points for missed updates.
const MAX_BUFFER = 60;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => { export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]); const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
@@ -29,9 +31,8 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
}, []); }, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription( clientApi.widget.beszel.subscribeSystemStats.useSubscription(
{ integrationIds, systemId }, enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
{ {
enabled: enabled && systemId !== "",
onData(event) { onData(event) {
setError(null); setError(null);
if (event.type === "system_stats") { if (event.type === "system_stats") {
@@ -48,7 +49,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
// Reset buffers when system changes or integration changes // Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>(""); const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}`; const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
useEffect(() => { useEffect(() => {
if (prevKeyRef.current !== currentKey) { if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey; prevKeyRef.current = currentKey;
+2 -1
View File
@@ -1,6 +1,6 @@
import type React from "react"; import type React from "react";
import type { LoaderComponent } from "next/dynamic"; import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -69,6 +69,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition { export interface WidgetDefinition {
icon: TablerIcon; icon: TablerIcon;
queryKey?: QueryKey;
supportedIntegrations?: IntegrationKind[]; supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean; integrationsRequired?: boolean;
maxIntegrations?: number; maxIntegrations?: number;
+23 -8
View File
@@ -3,13 +3,13 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react"; import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react"; import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common"; import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -110,7 +110,7 @@ const createColumns = (
return ( return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}> <Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)} {formatBytes(bytesUsage)}
</Text> </Text>
); );
}, },
@@ -184,7 +184,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker"); const t = useScopedI18n("docker");
const isTiny = width <= 256; const isTiny = width <= 256;
const { data } = clientApi.docker.getContainers.useQuery(undefined, { const {
data,
refetch,
isFetching,
} = clientApi.docker.getContainers.useQuery(undefined, {
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
const containers = data?.containers ?? []; const containers = data?.containers ?? [];
@@ -297,12 +301,23 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
</Text> </Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}> <Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })} {t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text> </Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}> <Tooltip
{t("table.updated", { when: relativeTime })} label={t("table.refresh.lastUpdated", { when: relativeTime })}
</Text> >
<ActionIcon
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group> </Group>
</Group> </Group>
)} )}
+1
View File
@@ -25,6 +25,7 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", { export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker, icon: IconBrandDocker,
queryKey: [["docker", "getContainers"]],
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({ columns: factory.multiSelect({
+16 -19
View File
@@ -43,7 +43,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize, useIntegrationConnected } from "@homarr/common"; import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -386,7 +386,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last", sortUndefined: "last",
Cell: ({ cell }) => { Cell: ({ cell }) => {
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>(); const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null; return downSpeed ? <Text size="xs">{formatByteRate(downSpeed)}</Text> : null;
}, },
}, },
{ {
@@ -466,7 +466,7 @@ export default function DownloadClientsWidget({
...columnsDefBase({ key: "received", showHeader: true }), ...columnsDefBase({ key: "received", showHeader: true }),
Cell: ({ cell }) => { Cell: ({ cell }) => {
const received = cell.getValue<ExtendedDownloadClientItem["received"]>(); const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
return <Text size="xs">{humanFileSize(received)}</Text>; return <Text size="xs">{formatBytes(received)}</Text>;
}, },
}, },
{ {
@@ -474,14 +474,14 @@ export default function DownloadClientsWidget({
sortUndefined: "last", sortUndefined: "last",
Cell: ({ cell }) => { Cell: ({ cell }) => {
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>(); const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
return sent && <Text size="xs">{humanFileSize(sent)}</Text>; return sent && <Text size="xs">{formatBytes(sent)}</Text>;
}, },
}, },
{ {
...columnsDefBase({ key: "size", showHeader: true }), ...columnsDefBase({ key: "size", showHeader: true }),
Cell: ({ cell }) => { Cell: ({ cell }) => {
const size = cell.getValue<ExtendedDownloadClientItem["size"]>(); const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
return <Text size="xs">{humanFileSize(size)}</Text>; return <Text size="xs">{formatBytes(size)}</Text>;
}, },
}, },
{ {
@@ -521,7 +521,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last", sortUndefined: "last",
Cell: ({ cell }) => { Cell: ({ cell }) => {
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>(); const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>; return upSpeed && <Text size="xs">{formatByteRate(upSpeed)}</Text>;
}, },
}, },
], ],
@@ -669,21 +669,21 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
{item.type !== "miscellaneous" && ( {item.type !== "miscellaneous" && (
<NormalizedLine <NormalizedLine
itemKey="upSpeed" itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")} values={item.upSpeed === undefined ? undefined : formatByteRate(item.upSpeed)}
/> />
)} )}
<NormalizedLine <NormalizedLine
itemKey="downSpeed" itemKey="downSpeed"
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")} values={item.downSpeed === undefined ? undefined : formatByteRate(item.downSpeed)}
/> />
{item.type !== "miscellaneous" && ( {item.type !== "miscellaneous" && (
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} /> <NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : formatBytes(item.sent)} />
)} )}
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} /> <NormalizedLine itemKey="received" values={formatBytes(item.received)} />
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} /> <NormalizedLine itemKey="size" values={formatBytes(item.size)} />
<NormalizedLine <NormalizedLine
itemKey="progress" itemKey="progress"
values={new Intl.NumberFormat("en", { values={new Intl.NumberFormat("en", {
@@ -744,10 +744,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{ paused: [] as string[], active: [] as string[] }, { paused: [] as string[], active: [] as string[] },
); );
const someInteract = clients.some(({ interact }) => interact); const someInteract = clients.some(({ interact }) => interact);
const totalSpeed = humanFileSize( const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0));
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
"/s",
);
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
@@ -849,21 +846,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{client.status.rates.up !== undefined ? ( {client.status.rates.up !== undefined ? (
<Group display="flex" justify="center" c="green" w="100%" gap={5}> <Group display="flex" justify="center" c="green" w="100%" gap={5}>
<Text flex={1} ta="right"> <Text flex={1} ta="right">
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`} {`↑ ${formatByteRate(client.status.rates.up)}`}
</Text> </Text>
<Text>{"-"}</Text> <Text>{"-"}</Text>
<Text flex={1} ta="left"> <Text flex={1} ta="left">
{humanFileSize(client.status.totalUp ?? 0)} {formatBytes(client.status.totalUp ?? 0)}
</Text> </Text>
</Group> </Group>
) : undefined} ) : undefined}
<Group display="flex" justify="center" c="blue" w="100%" gap={5}> <Group display="flex" justify="center" c="blue" w="100%" gap={5}>
<Text flex={1} ta="right"> <Text flex={1} ta="right">
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`} {`↓ ${formatByteRate(client.status.rates.down)}`}
</Text> </Text>
<Text>{"-"}</Text> <Text>{"-"}</Text>
<Text flex={1} ta="left"> <Text flex={1} ta="left">
{humanFileSize(Math.floor(client.status.totalDown ?? 0))} {formatBytes(Math.floor(client.status.totalDown ?? 0))}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>
@@ -16,7 +16,7 @@ import {
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration"; import duration from "dayjs/plugin/duration";
import { capitalize, humanFileSize } from "@homarr/common"; import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types"; import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -93,16 +93,18 @@ const RightSection = ({ label, value }: RightSectionProps) => {
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => { const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail"); const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const memory = formatBytesPair(item.memory.used, item.memory.total);
const storage = formatBytesPair(item.storage.used, item.storage.total);
return ( return (
<List> <List>
<List.Item icon={<IconCpu size={16} />}> <List.Item icon={<IconCpu size={16} />}>
{t("cpu")} - {item.cpu.cores} {t("cpu")} - {item.cpu.cores}
</List.Item> </List.Item>
<List.Item icon={<IconBrain size={16} />}> <List.Item icon={<IconBrain size={16} />}>
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)} {t("memory")} - {memory.used} / {memory.available}
</List.Item> </List.Item>
<List.Item icon={<IconDatabase size={16} />}> <List.Item icon={<IconDatabase size={16} />}>
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)} {t("storage")} - {storage.used} / {storage.available}
</List.Item> </List.Item>
<List.Item icon={<IconClockHour3 size={16} />}> <List.Item icon={<IconClockHour3 size={16} />}>
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)} {t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
@@ -121,6 +123,7 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const StorageResourceDetails = ({ item }: { item: StorageResource }) => { const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail"); const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const storagePercent = item.total ? (item.used / item.total) * 100 : 0; const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
const storage = formatBytesPair(item.used, item.total);
return ( return (
<Stack gap={0}> <Stack gap={0}>
<Center> <Center>
@@ -133,7 +136,7 @@ const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
/> />
<Group align="center" gap={0}> <Group align="center" gap={0}>
<Text> <Text>
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)} {t("storage")} - {storage.used} / {storage.available}
</Text> </Text>
</Group> </Group>
</Center> </Center>
@@ -152,11 +155,11 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconDatabase size={16} />}> <List.Item icon={<IconDatabase size={16} />}>
<Group gap="sm"> <Group gap="sm">
<Group gap={0}> <Group gap={0}>
<Text>{humanFileSize(item.storage.write)}</Text> <Text>{formatBytes(item.storage.write)}</Text>
<IconArrowNarrowDown size={14} /> <IconArrowNarrowDown size={14} />
</Group> </Group>
<Group gap={0}> <Group gap={0}>
<Text>{humanFileSize(item.storage.read)}</Text> <Text>{formatBytes(item.storage.read)}</Text>
<IconArrowNarrowUp size={14} /> <IconArrowNarrowUp size={14} />
</Group> </Group>
</Group> </Group>
@@ -172,11 +175,11 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconNetwork size={16} />}> <List.Item icon={<IconNetwork size={16} />}>
<Group gap="sm"> <Group gap="sm">
<Group gap={0}> <Group gap={0}>
<Text>{humanFileSize(item.network.in)}</Text> <Text>{formatBytes(item.network.in)}</Text>
<IconArrowNarrowDown size={14} /> <IconArrowNarrowDown size={14} />
</Group> </Group>
<Group gap={0}> <Group gap={0}>
<Text>{humanFileSize(item.network.out)}</Text> <Text>{formatBytes(item.network.out)}</Text>
<IconArrowNarrowUp size={14} /> <IconArrowNarrowUp size={14} />
</Group> </Group>
</Group> </Group>
@@ -32,7 +32,7 @@ import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common"; import { formatBytes } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -277,7 +277,7 @@ export const progressColor = (percentage: number) => {
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched. // them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
const formatFileSize = (value: string) => { const formatFileSize = (value: string) => {
const bytes = Number(value); const bytes = Number(value);
return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value; return Number.isFinite(bytes) ? formatBytes(Math.round(bytes)) : value;
}; };
interface FileSystem { interface FileSystem {
@@ -5,7 +5,7 @@ import { Group, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react"; import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common"; import { formatBytes } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
@@ -46,7 +46,7 @@ export default function ImmichServerStatsWidget({
<StatItem <StatItem
icon={<IconDatabase size={20} />} icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")} label={t("widget.immich-serverStats.storage")}
value={humanFileSize(stats.totalLibraryUsageInBytes)} value={formatBytes(stats.totalLibraryUsageInBytes)}
/> />
)} )}
</Stack> </Stack>
@@ -14,7 +14,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { formatBytes } from "@homarr/common";
import type { TdarrQueue } from "@homarr/integrations"; import type { TdarrQueue } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -72,7 +72,7 @@ export function QueuePanel(props: QueuePanelProps) {
</Group> </Group>
</TableTd> </TableTd>
<TableTd py={2}> <TableTd py={2}>
<Text size="xs">{humanFileSize(item.fileSize)}</Text> <Text size="xs">{formatBytes(item.fileSize)}</Text>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))}
@@ -3,7 +3,7 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common"; import { formatBytes } from "@homarr/common";
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations"; import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
@@ -41,7 +41,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
<StatisticItem <StatisticItem
icon={IconDatabaseHeart} icon={IconDatabaseHeart}
label={t("savedSpace")} label={t("savedSpace")}
value={humanFileSize(Math.floor(allLibs.totalSavedSpace))} value={formatBytes(Math.floor(allLibs.totalSavedSpace))}
/> />
</Group> </Group>
<Group justify="center" wrap="wrap" grow> <Group justify="center" wrap="wrap" grow>
@@ -39,6 +39,8 @@ import {
IconRowRemove, IconRowRemove,
IconTableOff, IconTableOff,
IconTablePlus, IconTablePlus,
IconTextDirectionLtr,
IconTextDirectionRtl,
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
@@ -66,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css"; import "@mantine/tiptap/styles.css";
import "./notebook.css"; import "./notebook.css";
@@ -171,6 +174,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
}), }),
TaskList.configure({ itemTypeName: "taskItem" }), TaskList.configure({ itemTypeName: "taskItem" }),
TextAlign.configure({ types: ["heading", "paragraph"] }), TextAlign.configure({ types: ["heading", "paragraph"] }),
NotebookTextDirection,
TextStyle, TextStyle,
], ],
shouldRerenderOnTransaction: true, shouldRerenderOnTransaction: true,
@@ -347,6 +351,22 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
position: t("widget.notebook.align.right"), position: t("widget.notebook.align.right"),
})} })}
/> />
<RichTextEditor.Control
title={tControls("directionLtr")}
aria-label={tControls("directionLtr")}
active={editor?.isActive({ dir: "ltr" })}
onClick={() => editor && setTextDirection(editor, "ltr").run()}
>
<IconTextDirectionLtr {...controlIconProps} />
</RichTextEditor.Control>
<RichTextEditor.Control
title={tControls("directionRtl")}
aria-label={tControls("directionRtl")}
active={editor?.isActive({ dir: "rtl" })}
onClick={() => editor && setTextDirection(editor, "rtl").run()}
>
<IconTextDirectionRtl {...controlIconProps} />
</RichTextEditor.Control>
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup>
@@ -0,0 +1,34 @@
import { Editor } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
describe("notebook text direction", () => {
it("persists right-to-left direction in the notebook HTML", () => {
const editor = new Editor({
content: "<p>יש לי 16GB</p>",
extensions: [StarterKit, NotebookTextDirection],
});
setTextDirection(editor, "rtl").run();
expect(editor.getHTML()).toBe('<p dir="rtl">יש לי 16GB</p>');
editor.destroy();
});
it("reads and changes an existing direction attribute", () => {
const editor = new Editor({
content: '<h2 dir="rtl">Heading</h2>',
extensions: [StarterKit, NotebookTextDirection],
});
expect(editor.getHTML()).toBe('<h2 dir="rtl">Heading</h2>');
editor.commands.setTextSelection(1);
setTextDirection(editor, "ltr").run();
expect(editor.getHTML()).toContain('<h2 dir="ltr">Heading</h2>');
editor.destroy();
});
});
@@ -0,0 +1,28 @@
import { Extension } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
export type TextDirection = "ltr" | "rtl";
export const NotebookTextDirection = Extension.create({
name: "notebookTextDirection",
addGlobalAttributes() {
return [
{
types: ["heading", "paragraph"],
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir"),
renderHTML: ({ dir }: { dir?: TextDirection }) => (dir ? { dir } : {}),
},
},
},
];
},
});
export function setTextDirection(editor: Editor, direction: TextDirection) {
const nodeType = editor.isActive("heading") ? "heading" : "paragraph";
return editor.chain().focus().updateAttributes(nodeType, { dir: direction });
}
@@ -5,7 +5,7 @@ import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantin
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common"; import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
@@ -20,11 +20,13 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
case "percentage": case "percentage":
return `${Math.round(item.percentage)}%`; return `${Math.round(item.percentage)}%`;
case "absolute": { case "absolute": {
const usedInBytes = Number(item.used);
const availableInBytes = Number(item.available); const availableInBytes = Number(item.available);
const availableText = Number.isFinite(availableInBytes) if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
? humanFileSize(Math.round(availableInBytes)) const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
: item.available; return `${used} / ${available}`;
return `${item.used} / ${availableText}`; }
return `${item.used} / ${item.available}`;
} }
case "free": case "free":
return `${Math.round(100 - item.percentage)}% free`; return `${Math.round(100 - item.percentage)}% free`;
@@ -130,16 +132,8 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
throw new NoIntegrationDataError(); throw new NoIntegrationDataError();
} }
const fileSystem = filterStorageVolumes( const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
rawFileSystem, const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
options.visibleStorageVolumes,
lastItem.integrationId,
);
const smart = filterStorageVolumes(
lastItem.healthInfo.smart,
options.visibleStorageVolumes,
lastItem.integrationId,
);
if (fileSystem.length === 0) return <WidgetEmptyState />; if (fileSystem.length === 0) return <WidgetEmptyState />;
@@ -1,7 +1,7 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core"; import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react"; import { IconNetwork } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from ".."; import type { LabelDisplayModeOption } from "..";
@@ -47,7 +47,7 @@ export const CombinedNetworkTrafficChart = ({
{payloadData.value === undefined ? ( {payloadData.value === undefined ? (
<>N/A</> <>N/A</>
) : ( ) : (
<>{humanFileSize(Math.round(Number(payloadData.value)))}/s</> formatByteRate(Math.round(Number(payloadData.value)))
)} )}
</Text> </Text>
</Group> </Group>
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core"; import { Paper, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react"; import { IconBrain } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { formatBytesPair } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from ".."; import type { LabelDisplayModeOption } from "..";
@@ -41,11 +41,11 @@ export const SystemResourceMemoryChart = ({
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0; const value = payload[0] ? Number(payload[0].value) : 0;
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
return ( return (
<Paper px={3} py={2} shadow="md"> <Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} ( {memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
{Math.round((value / totalCapacityInBytes) * 100)}%)
</Text> </Text>
</Paper> </Paper>
); );
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core"; import { Paper, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react"; import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from ".."; import type { LabelDisplayModeOption } from "..";
@@ -32,7 +32,7 @@ export const NetworkTrafficChart = ({
title={isUp ? t("up") : t("down")} title={isUp ? t("up") : t("down")}
icon={isUp ? IconArrowUp : IconArrowDown} icon={isUp ? IconArrowUp : IconArrowDown}
yAxisProps={{ domain: [0, upperBound] }} yAxisProps={{ domain: [0, upperBound] }}
lastValue={`${humanFileSize(Math.round(max))}/s`} lastValue={formatByteRate(Math.round(max))}
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
tooltipProps={{ tooltipProps={{
@@ -41,7 +41,7 @@ export const NetworkTrafficChart = ({
return ( return (
<Paper px={3} py={2} shadow="md"> <Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))}/s {formatByteRate(Math.round(value))}
</Text> </Text>
</Paper> </Paper>
); );
+1190 -1162
View File
File diff suppressed because it is too large. Load diff
+34 -34
View File
@@ -18,7 +18,7 @@ catalog:
"@gitbeaker/rest": ^43.8.0 "@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0 "@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0 "@homarr/node-unifi": ^2.6.0
"@immich/sdk": ^3.0.1 "@immich/sdk": ^3.0.2
"@jellyfin/sdk": ^0.13.0 "@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0 "@kubernetes/client-node": ^1.4.0
"@modelcontextprotocol/sdk": 1.29.0 "@modelcontextprotocol/sdk": 1.29.0
@@ -53,28 +53,28 @@ catalog:
"@testcontainers/mysql": ^12.0.4 "@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4 "@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4 "@testcontainers/redis": ^12.0.4
"@tiptap/extension-color": 3.27.1 "@tiptap/extension-color": 3.27.3
"@tiptap/extension-highlight": 3.27.1 "@tiptap/extension-highlight": 3.27.3
"@tiptap/extension-image": 3.27.1 "@tiptap/extension-image": 3.27.3
"@tiptap/extension-link": ^3.27.1 "@tiptap/extension-link": ^3.27.3
"@tiptap/extension-placeholder": ^3.27.1 "@tiptap/extension-placeholder": ^3.27.3
"@tiptap/extension-table": 3.27.1 "@tiptap/extension-table": 3.27.3
"@tiptap/extension-table-cell": 3.27.1 "@tiptap/extension-table-cell": 3.27.3
"@tiptap/extension-table-header": 3.27.1 "@tiptap/extension-table-header": 3.27.3
"@tiptap/extension-table-row": 3.27.1 "@tiptap/extension-table-row": 3.27.3
"@tiptap/extension-task-item": 3.27.1 "@tiptap/extension-task-item": 3.27.3
"@tiptap/extension-task-list": 3.27.1 "@tiptap/extension-task-list": 3.27.3
"@tiptap/extension-text-align": 3.27.1 "@tiptap/extension-text-align": 3.27.3
"@tiptap/extension-text-style": 3.27.1 "@tiptap/extension-text-style": 3.27.3
"@tiptap/extension-underline": 3.27.1 "@tiptap/extension-underline": 3.27.3
"@tiptap/react": ^3.27.1 "@tiptap/react": ^3.27.3
"@tiptap/starter-kit": ^3.27.1 "@tiptap/starter-kit": ^3.27.3
"@trpc/client": ^11.18.0 "@trpc/client": ^11.18.0
"@trpc/next": ^11.18.0 "@trpc/next": ^11.18.0
"@trpc/react-query": ^11.18.0 "@trpc/react-query": ^11.18.0
"@trpc/server": ^11.18.0 "@trpc/server": ^11.18.0
"@trpc/tanstack-react-query": ^11.18.0 "@trpc/tanstack-react-query": ^11.18.0
"@turbo/gen": ^2.10.2 "@turbo/gen": ^2.10.4
"@types/adm-zip": 0.5.8 "@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0 "@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13 "@types/better-sqlite3": 7.6.13
@@ -83,21 +83,21 @@ catalog:
"@types/crypto-js": ^4.2.2 "@types/crypto-js": ^4.2.2
"@types/css-modules": ^1.0.5 "@types/css-modules": ^1.0.5
"@types/dockerode": ^4.0.1 "@types/dockerode": ^4.0.1
"@types/node": ^24.13.2 "@types/node": ^24.13.3
"@types/node-unifi": ^2.5.1 "@types/node-unifi": ^2.5.1
"@types/pg": ^8.20.0 "@types/pg": ^8.20.0
"@types/prismjs": ^1.26.6 "@types/prismjs": ^1.26.6
"@types/react": 19.2.17 "@types/react": 19.2.17
"@types/react-dom": 19.2.3 "@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.51 "@scalar/api-reference-react": ^0.9.54
"@types/video.js": ^7.3.58 "@types/video.js": ^7.3.58
"@types/ws": ^8.18.1 "@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14 "@types/xml2js": ^0.4.14
posthog-js: ^1.396.4 posthog-js: ^1.399.1
posthog-node: ^5.39.2 posthog-node: ^5.40.0
"@vitejs/plugin-react": ^6.0.3 "@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9 "@vitest/coverage-v8": ^4.1.10
"@vitest/ui": ^4.1.9 "@vitest/ui": ^4.1.10
"@xterm/addon-canvas": ^0.7.0 "@xterm/addon-canvas": ^0.7.0
"@xterm/addon-fit": 0.11.0 "@xterm/addon-fit": 0.11.0
"@xterm/xterm": ^6.0.0 "@xterm/xterm": ^6.0.0
@@ -139,20 +139,20 @@ catalog:
jsonpath-plus: ^10.4.0 jsonpath-plus: ^10.4.0
ldapts: 8.1.8 ldapts: 8.1.8
lodash.clonedeep: ^4.5.0 lodash.clonedeep: ^4.5.0
lucide-react: ^1.23.0 lucide-react: ^1.24.0
"mantine-contextmenu": ^9.3.1 "mantine-contextmenu": ^9.3.1
mantine-datatable: ^9.3.1 mantine-datatable: ^9.3.1
mantine-react-table: npm:mantine-react-table-open@9.0.3 mantine-react-table: npm:mantine-react-table-open@9.0.3
mcp-handler: ^1.1.0 mcp-handler: ^1.1.0
maria2: ^0.4.1 maria2: ^0.4.1
mysql2: 3.22.5 mysql2: 3.22.6
next: 16.2.10 next: 16.2.10
next-auth: 5.0.0-beta.31 next-auth: 5.0.0-beta.31
next-intl: 4.13.1 next-intl: 4.13.1
node-loader: ^2.1.0 node-loader: ^2.1.0
octokit: ^5.0.5 octokit: ^5.0.5
oxfmt: ^0.57.0 oxfmt: ^0.58.0
oxlint: ^1.72.0 oxlint: ^1.73.0
pg: ^8.22.0 pg: ^8.22.0
postcss-preset-mantine: ^1.18.0 postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0 prismjs: ^1.30.0
@@ -176,14 +176,14 @@ catalog:
trpc-to-openapi: ^3.3.0 trpc-to-openapi: ^3.3.0
tsdav: ^2.3.0 tsdav: ^2.3.0
tsx: 4.20.4 tsx: 4.20.4
turbo: ^2.10.2 turbo: ^2.10.4
typescript: ^6.0.3 typescript: "npm:@typescript/typescript6@^6.0.2"
vite: ^8.1.2 vite: ^8.1.4
undici: 7.28.0 undici: 7.28.0
use-deep-compare-effect: ^1.8.1 use-deep-compare-effect: ^1.8.1
video.js: ^8.23.9 video.js: ^8.23.9
vite-tsconfig-paths: ^6.1.1 vite-tsconfig-paths: ^6.1.1
vitest: ^4.1.9 vitest: ^4.1.10
winston: 3.19.0 winston: 3.19.0
ws: ^8.21.0 ws: ^8.21.0
xml2js: ^0.6.2 xml2js: ^0.6.2
@@ -218,7 +218,7 @@ overrides:
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7" "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
"esbuild@<=0.24.2": ">=0.28.1" "esbuild@<=0.24.2": ">=0.28.1"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6" "form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.27" "hono@<4.6.5": ">=4.12.28"
"linkifyjs@<4.3.2": ">=4.3.3" "linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16" "nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
"prismjs@<1.30.0": ">=1.30.0" "prismjs@<1.30.0": ">=1.30.0"
@@ -229,7 +229,7 @@ overrides:
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.3" "tar-fs@>=3.0.0 <3.0.9": ">=3.1.3"
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.3" "tar-fs@>=2.0.0 <2.1.3": ">=3.1.3"
"tmp@<=0.2.3": ">=0.2.7" "tmp@<=0.2.3": ">=0.2.7"
"vite@>=5.0.0 <=5.4.18": ">=8.1.3" "vite@>=5.0.0 <=5.4.18": ">=8.1.4"
patchedDependencies: patchedDependencies:
"@types/node-unifi": "patches/@types__node-unifi.patch" "@types/node-unifi": "patches/@types__node-unifi.patch"
"trpc-to-openapi": "patches/trpc-to-openapi.patch" "trpc-to-openapi": "patches/trpc-to-openapi.patch"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
mode="${1:-all}"
case "$mode" in
fast) job="fast-gate" ;;
docker) job="container" ;;
all) job="" ;;
cleanup) job="cleanup" ;;
*)
echo "Usage: $0 {fast|docker|all|cleanup}" >&2
exit 2
;;
esac
command -v act >/dev/null 2>&1 || {
echo "act is required: https://nektosact.com/installation/index.html" >&2
exit 1
}
docker info >/dev/null 2>&1 || {
echo "Docker is not running" >&2
exit 1
}
if [[ -z "${DOCKER_HOST:-}" ]]; then
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)
export DOCKER_HOST
fi
head_sha=$(git rev-parse HEAD)
base_sha=$(git rev-parse HEAD~1 2>/dev/null || echo "$head_sha")
head_ref=$(git branch --show-current)
head_ref=${head_ref:-act-local}
event_file=$(mktemp "${TMPDIR:-/tmp}/homarr-act-event.XXXXXX")
trap 'rm -f "$event_file"' EXIT
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g'; }
if [[ "$mode" == "cleanup" ]]; then
event_name="workflow_dispatch"
cat >"$event_file" <<EOF
{
"inputs": { "operation": "cleanup", "pr_number": "1", "ref": "" },
"repository": { "full_name": "homarr-labs/homarr" }
}
EOF
else
event_name="pull_request"
safe_ref=$(json_escape "$head_ref")
cat >"$event_file" <<EOF
{
"action": "synchronize",
"number": 1,
"repository": { "full_name": "homarr-labs/homarr" },
"pull_request": {
"number": 1,
"draft": false,
"base": { "sha": "$base_sha", "ref": "dev" },
"head": {
"sha": "$head_sha",
"ref": "$safe_ref",
"repo": { "full_name": "homarr-labs/homarr" }
}
}
}
EOF
fi
runner_image="${ACT_RUNNER_IMAGE:-catthehacker/ubuntu:act-latest}"
args=(
"$event_name"
--workflows .github/workflows/ci.yml
--eventpath "$event_file"
--platform "ubuntu-latest=$runner_image"
--container-architecture linux/amd64
--bind
)
if [[ -n "$job" ]]; then
args+=(--job "$job")
fi
git_common_dir=$(cd "$(git rev-parse --git-common-dir)" && pwd)
container_options="--volume homarr-act-node-modules:$PWD/node_modules --volume homarr-act-pnpm-store:$PWD/.pnpm-store"
case "$git_common_dir" in
"$PWD" | "$PWD"/*) ;;
*) container_options="$container_options --volume $git_common_dir:$git_common_dir:ro" ;;
esac
args+=(--container-options "$container_options")
if [[ -n "${ACT_SECRET_FILE:-}" ]]; then
args+=(--secret-file "$ACT_SECRET_FILE")
fi
exec act "${args[@]}"
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://turborepo.org/schema.json", "$schema": "https://turborepo.org/schema.json",
"remoteCache": { "signature": false }, "remoteCache": { "signature": true },
"globalEnv": ["CI", "NODE_ENV", "SKIP_ENV_VALIDATION"], "globalEnv": ["CI", "NODE_ENV", "SKIP_ENV_VALIDATION"],
"ui": "stream", "ui": "stream",
"futureFlags": { "futureFlags": {
@@ -13,6 +13,7 @@
}, },
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"env": ["TURBO_PLATFORM"],
"inputs": [ "inputs": [
"$TURBO_DEFAULT$", "$TURBO_DEFAULT$",
"!**/*.test.ts", "!**/*.test.ts",