Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d04c8c437f | ||
|
|
28fefcd23f | ||
|
|
c08054736e | ||
|
|
2e20fd7da5 | ||
|
|
1856d20a47 | ||
|
|
6df2f92268 | ||
|
|
c30f525a83 | ||
|
|
dd23de7aaa | ||
|
|
f1602f67d4 | ||
|
|
c03cd97d3a | ||
|
|
a8e9a074d3 | ||
|
|
1e9d3e0656 | ||
|
|
01e0f3612d | ||
|
|
0015a6d88d | ||
|
|
77dc317da2 | ||
|
|
6aeda3faaf | ||
|
|
ca358d9f8b | ||
|
|
03e0683892 | ||
|
|
6ac4c6a7b9 | ||
|
|
a0e0bf65ff | ||
|
|
bc018e1fe0 | ||
|
|
5e39c8813a | ||
|
|
06719cdef0 | ||
|
|
5144e14da4 | ||
|
|
8528d5068b | ||
|
|
c6c4428f7d | ||
|
|
7e4cb0201b | ||
|
|
7887ce12c0 | ||
|
|
2d450239ac | ||
|
|
bf4ccd8cb3 | ||
|
|
c473e80243 | ||
|
|
bf55bcce92 | ||
|
|
49248cedaf | ||
|
|
a0034b2cfc | ||
|
|
4a297180fd | ||
|
|
5fa3a3313a | ||
|
|
29ee87c4aa | ||
|
|
94a247e24a | ||
|
|
6d37710fec | ||
|
|
c016beb2e5 | ||
|
|
f9e1b85c6a | ||
|
|
38ba19e665 | ||
|
|
f55c99d2d6 | ||
|
|
c5e8b83d8a | ||
|
|
d52d9cfed2 | ||
|
|
93325642aa | ||
|
|
165e94abbc | ||
|
|
2e3127e766 | ||
|
|
d37326d905 | ||
|
|
e51e39ff38 | ||
|
|
6c075b6d74 | ||
|
|
b2f63c6e0f | ||
|
|
8c2d73e8af | ||
|
|
1c0ff8930e | ||
|
|
e2b8355639 | ||
|
|
ba7718bde5 | ||
|
|
06f1877ae1 | ||
|
|
1d9e8aabf3 | ||
|
|
b111e5285b | ||
|
|
f4c491b570 | ||
|
|
29bd4fd70b | ||
|
|
269dadee8d | ||
|
|
03f68a0421 | ||
|
|
807e5eb2a9 | ||
|
|
8e8c29979f | ||
|
|
7d859c1af0 | ||
|
|
e9543dc30d | ||
|
|
9f313440ac | ||
|
|
c2508dffea | ||
|
|
d44bc06a93 | ||
|
|
c56b91570b | ||
|
|
d53c4e6513 | ||
|
|
0426d600cc | ||
|
|
576856d634 | ||
|
|
def3ede3bc | ||
|
|
34bd574354 |
No files matched your search
@@ -6,7 +6,14 @@ npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.turbo
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
coverage
|
||||
**/coverage
|
||||
**/.docusaurus
|
||||
**/build
|
||||
.git
|
||||
.github
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.secrets
|
||||
!.secrets.example
|
||||
@@ -0,0 +1,4 @@
|
||||
TURBO_API=
|
||||
TURBO_TEAM=
|
||||
TURBO_TOKEN=
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
|
||||
@@ -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.
|
||||
@@ -11,3 +11,11 @@
|
||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
|
||||
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
|
||||
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
|
||||
|
||||
**When contributing a new integration or widget:**
|
||||
|
||||
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
|
||||
tests (e.g. what happens if it is offline)
|
||||
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
|
||||
provide support and maintain the integration (e.g. in case of breaking or failure)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,264 @@
|
||||
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: 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
|
||||
|
||||
container:
|
||||
name: Container and E2E
|
||||
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: 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."
|
||||
@@ -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
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
build-arm64:
|
||||
@@ -187,6 +192,11 @@ jobs:
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
extract-asset-amd64:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
|
||||
|
||||
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
|
||||
|
||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
+30
-11
@@ -1,24 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.25
|
||||
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat curl bash
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||
|
||||
RUN corepack enable pnpm
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY patches ./patches
|
||||
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||
# content-addressable store is populated). By temporarily switching to the
|
||||
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
|
||||
# which IS created by pnpm fetch. The original hoisted linker is restored
|
||||
# before the install step so the final node_modules layout stays flat.
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
||||
pnpm fetch && \
|
||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||
|
||||
COPY . .
|
||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive
|
||||
|
||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||
|
||||
# Copy static data as it is not part of the build
|
||||
COPY static-data ./static-data
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
ARG DISABLE_REDIS_LOGS='true'
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN 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
|
||||
WORKDIR /app
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
},
|
||||
"options": {
|
||||
"reportUnusedDisableDirectives": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
|
||||
"settings": {
|
||||
"react": { "version": "19" }
|
||||
|
||||
@@ -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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.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 |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -50,12 +50,11 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
||||
<code>user.read</code>,{" "}
|
||||
<code>user.update</code>,{" "}
|
||||
<code>asset.read</code>,{" "}
|
||||
<code>asset.view</code>,{" "}
|
||||
<code>asset.download</code>,{" "}
|
||||
<code>asset.statistics</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>server.about</code>.
|
||||
<br />
|
||||
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
|
||||
</span>
|
||||
|
||||
),
|
||||
|
||||
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
---
|
||||
title: 'Beszel System Stats'
|
||||
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
|
||||
title: "Beszel System Stats"
|
||||
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { beszelSystemStatsWidget } from '.';
|
||||
import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { beszelSystemStatsWidget } from ".";
|
||||
import { beszelIntegration } from "@site/docs/integrations/beszel";
|
||||
|
||||
import beszelSystemStats from './img/beszel-system-stats.png';
|
||||
import beszelSystemStats from "./img/beszel-system-stats.png";
|
||||
|
||||
<WidgetHeader
|
||||
widget={beszelSystemStatsWidget}
|
||||
categories={['System Monitoring', 'Charts']}
|
||||
/>
|
||||
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
||||
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. 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
|
||||
|
||||
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays time-series charts for a selected system"
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays time-series charts for a selected system",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
|
||||
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
---
|
||||
title: 'Media Request List'
|
||||
description: 'See a list of all media requests from your integration'
|
||||
title: "Media Request List"
|
||||
description: "See a list of all media requests from your integration"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { mediaRequestListWidget } from '.';
|
||||
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
|
||||
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestListWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={mediaRequestListWidget}
|
||||
categories={['Media requests']}
|
||||
/>
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
|
||||
|
||||
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import mediaRequestList from './img/list.png';
|
||||
import mediaRequestList from "./img/list.png";
|
||||
|
||||
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: jellyseerrIntegration,
|
||||
}, {
|
||||
integration: overseerrIntegration,
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: jellyseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
|
||||
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Statuses to show",
|
||||
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
|
||||
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
|
||||
defaultValue: "All statuses",
|
||||
},
|
||||
{
|
||||
name: "Show only recent requests",
|
||||
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
|
||||
values: "0-365",
|
||||
defaultValue: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,29 +1,25 @@
|
||||
---
|
||||
title: 'Notebook'
|
||||
description: 'A simple notebook widget that supports markdown.'
|
||||
title: "Notebook"
|
||||
description: "A simple notebook widget that supports markdown."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { notebookWidget } from '.';
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { notebookWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
|
||||
|
||||
<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.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import editOff from './img/normal.png';
|
||||
import editOn from './img/edit.png';
|
||||
import editOff from "./img/normal.png";
|
||||
import editOn from "./img/edit.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
|
||||
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
|
||||
</div>
|
||||
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={notebookWidget.configuration} />
|
||||
|
||||
### Editing
|
||||
|
||||
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
|
||||
|
||||

|
||||
|
||||
|
||||
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
#### Common text formatting
|
||||
|
||||
| Function | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------- |
|
||||
| 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 |
|
||||
|
||||
#### Heading
|
||||
|
||||
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
|
||||
|
||||
#### Text Alignement
|
||||
Select the alignement on the line. Left, center or right.
|
||||
#### Text Alignment
|
||||
|
||||
Select the alignment on the line. Left, center or right.
|
||||
|
||||
#### Text direction
|
||||
|
||||
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
|
||||
|
||||
#### Misc functions
|
||||
|
||||
##### Blockquote
|
||||
|
||||
Puts the current line in a quoting block.
|
||||
|
||||
##### Horizontal line
|
||||
|
||||
Adds a line that goes from the far right to the far left as a separator.
|
||||
|
||||
#### Lists
|
||||
|
||||
##### Bullet lists
|
||||
|
||||
Simple list with a dot preceding each element.
|
||||
|
||||
##### Ordered lists
|
||||
|
||||
List with numbers preceding each element.
|
||||
|
||||
##### Check lists
|
||||
|
||||
List with a checkbox on each element.
|
||||
|
||||
##### Indentation changes
|
||||
|
||||

|
||||
|
||||
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
|
||||
|
||||
#### Insert functions
|
||||
|
||||
##### Link controls
|
||||
|
||||
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
|
||||
|
||||
Pasting a link directly into the text will automatically put the link anchor on it too.
|
||||
@@ -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.
|
||||
|
||||
##### Image controls
|
||||
|
||||

|
||||
|
||||
##### Source
|
||||
|
||||
You can use a normal link to an image or you can also use locally stored images.
|
||||
|
||||
##### Width
|
||||
|
||||
The aspect ratio will always be respected, so only the width field is necessary.
|
||||
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
|
||||
|
||||
#### Table
|
||||
|
||||
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
|
||||
|
||||

|
||||
@@ -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.
|
||||
|
||||
##### Table controls
|
||||
|
||||

|
||||
|
||||
While in a table, further controls will appear to help edit the table.
|
||||
@@ -134,4 +154,4 @@ All the following ones are for editing the wize of the table:
|
||||
- Delete Column
|
||||
- Add row on top
|
||||
- Add row below
|
||||
- Delete Row
|
||||
- Delete Row
|
||||
@@ -43,7 +43,7 @@
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^14.0.1",
|
||||
"swiper": "^14.0.5",
|
||||
"tailwindcss": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
httpLink,
|
||||
httpSubscriptionLink,
|
||||
isNonJsonSerializable,
|
||||
loggerLink,
|
||||
splitLink,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
|
||||
@@ -89,6 +92,13 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
},
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
for (const { definition } of Object.values(widgetImports)) {
|
||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
||||
if (def.refetchInterval === undefined) continue;
|
||||
const key = def.queryKey ?? [["widget", def.kind]];
|
||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
@@ -101,9 +111,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
true: wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
true: splitLink({
|
||||
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
|
||||
true: httpSubscriptionLink({
|
||||
url: getTrpcUrl(),
|
||||
transformer: superjson,
|
||||
eventSourceOptions: { withCredentials: true },
|
||||
}),
|
||||
false: wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
}),
|
||||
}),
|
||||
false: splitLink({
|
||||
condition: ({ input }) => isNonJsonSerializable(input),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
@@ -40,7 +41,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
|
||||
@@ -41,15 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||
{humanFileSize(file.size)}
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
|
||||
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { createLocalImageUrl } from "@homarr/icons/local";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||
<TableTd>{formatBytes(media.size)}</TableTd>
|
||||
<TableTd>
|
||||
{media.creator ? (
|
||||
<Group gap="sm">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
|
||||
return (
|
||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||
{humanFileSize(bytesUsage)}
|
||||
{formatBytes(bytesUsage)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
@@ -193,6 +193,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: false,
|
||||
enableBottomToolbar: false,
|
||||
// MRT's built-in full-screen mode does not stretch the table to fill the
|
||||
// viewport, so with few containers it renders as a thin strip (#6230). Disable
|
||||
// it, matching the other management tables (users, invites).
|
||||
enableFullScreenToggle: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
|
||||
+18
@@ -31,6 +31,7 @@ import {
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
@@ -51,6 +52,7 @@ const userGeneralSettingsSchema = z.object({
|
||||
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
@@ -78,6 +80,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
@@ -95,6 +98,7 @@ export const UserGeneralSettingsForm = ({
|
||||
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
@@ -114,6 +118,7 @@ export const UserGeneralSettingsForm = ({
|
||||
changeSearchPreferencesMutation,
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
changeEnableRightClickOnWidgetsMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
@@ -169,6 +174,14 @@ export const UserGeneralSettingsForm = ({
|
||||
action: () =>
|
||||
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
|
||||
},
|
||||
{
|
||||
when: changed("enableRightClickOnWidgets"),
|
||||
action: () =>
|
||||
changeEnableRightClickOnWidgetsMutation.mutateAsync({
|
||||
id: user.id,
|
||||
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
@@ -305,6 +318,11 @@ export const UserGeneralSettingsForm = ({
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.enableRightClickOnWidgets.label")}
|
||||
description={t("user.field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ImageProxy } from "@homarr/image-proxy";
|
||||
|
||||
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await props.params;
|
||||
|
||||
const imageProxy = new ImageProxy();
|
||||
const image = await imageProxy.forwardImageAsync(id);
|
||||
if (!image) {
|
||||
notFound();
|
||||
const result = await imageProxy.forwardImageAsync(id);
|
||||
|
||||
if ("error" in result) {
|
||||
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
|
||||
return new Response(null, {
|
||||
status,
|
||||
headers: {
|
||||
"X-Homarr-Image-Proxy-Error": result.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(image, {
|
||||
return new Response(result.image, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
|
||||
...(result.contentType ? { "Content-Type": result.contentType } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,20 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
@@ -38,6 +48,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -49,7 +60,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
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 handleRefetch = useCallback(() => {
|
||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||
@@ -155,6 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
|
||||
@@ -229,11 +244,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<Text size="xs" c="dimmed">
|
||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
||||
</Text>
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKey={widgetQueryKey}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
@@ -270,34 +288,65 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetCacheAge = ({
|
||||
queryClient,
|
||||
queryKey,
|
||||
isFetching,
|
||||
}: {
|
||||
interface WidgetQueryStatusProps {
|
||||
queryClient: QueryClient;
|
||||
queryKey: unknown[];
|
||||
queryKey: QueryKey;
|
||||
isFetching: boolean;
|
||||
}) => {
|
||||
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
if (isFetching) return <Loader size={12} />;
|
||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||
const ageLabel = useTimeAgo(new Date(latest));
|
||||
|
||||
const timestamps = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey })
|
||||
.map((q) => q.state.dataUpdatedAt)
|
||||
.filter(Boolean);
|
||||
if (timestamps.length === 0) return null;
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Loader size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.loading")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const oldest = Math.min(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - oldest) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (queries.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.idle")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
if (hasError) {
|
||||
const errorQuery = queries.find((q) => q.state.status === "error");
|
||||
const errorMessage =
|
||||
errorQuery?.state.error instanceof Error
|
||||
? errorQuery.state.error.message
|
||||
: String(errorQuery?.state.error ?? "Unknown error");
|
||||
|
||||
return (
|
||||
<Tooltip label={errorMessage} multiline position="left" w={250}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
{t("item.menu.status.error")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.success")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export const env = createEnv({
|
||||
server: {
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.10.0"
|
||||
"pnpm": ">=11.13.1"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0"
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
}
|
||||
+10
-6
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.70.0",
|
||||
"version": "1.71.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
"clean": "git clean -xdf node_modules",
|
||||
"clean:workspaces": "turbo clean",
|
||||
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
|
||||
"ci:act:docker": "bash scripts/ci/act.sh docker",
|
||||
"ci:act:fast": "bash scripts/ci/act.sh fast",
|
||||
"cli": "pnpm with-env tsx packages/cli/index.ts",
|
||||
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
||||
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
|
||||
@@ -33,6 +37,9 @@
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:",
|
||||
"@semantic-release/changelog": "catalog:",
|
||||
@@ -62,10 +69,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.10.0"
|
||||
"pnpm": ">=11.13.1"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
|
||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
||||
|
||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
|
||||
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
const first = queryKey[0];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { and, asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
@@ -130,9 +130,15 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
|
||||
return null;
|
||||
}),
|
||||
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
// Public dashboards have no session: restrict anonymous users to generic
|
||||
// (non-integration) engines so custom search engines work there too (#4132),
|
||||
// while integration-backed engines stay available only when signed in.
|
||||
where: and(
|
||||
like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
|
||||
),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
|
||||
@@ -357,6 +357,83 @@ describe("delete should delete user", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeEnableRightClickOnWidgets should toggle the right-click preference", () => {
|
||||
test("non-admin can toggle their own preference", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultOwnerId,
|
||||
name: "owner",
|
||||
});
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: false });
|
||||
|
||||
const updated = await db.query.users.findFirst({
|
||||
where: eq(users.id, defaultOwnerId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(updated?.enableRightClickOnWidgets).toBe(false);
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: true });
|
||||
|
||||
const restored = await db.query.users.findFirst({
|
||||
where: eq(users.id, defaultOwnerId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(restored?.enableRightClickOnWidgets).toBe(true);
|
||||
});
|
||||
|
||||
test("non-admin cannot toggle another user's preference", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const otherUserId = createId();
|
||||
await db.insert(users).values({ id: defaultOwnerId });
|
||||
await db.insert(users).values({ id: otherUserId, name: "other" });
|
||||
|
||||
await expect(
|
||||
caller.changeEnableRightClickOnWidgets({ id: otherUserId, enableRightClickOnWidgets: false }),
|
||||
).rejects.toThrow("User not found");
|
||||
|
||||
const other = await db.query.users.findFirst({
|
||||
where: eq(users.id, otherUserId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(other?.enableRightClickOnWidgets).toBe(true);
|
||||
});
|
||||
|
||||
test("admin can toggle another user's preference", async () => {
|
||||
const db = createDb();
|
||||
const adminSession = createSession(["admin"]);
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: adminSession,
|
||||
});
|
||||
|
||||
const targetUserId = createId();
|
||||
await db.insert(users).values({ id: defaultOwnerId });
|
||||
await db.insert(users).values({ id: targetUserId, name: "target" });
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: targetUserId, enableRightClickOnWidgets: false });
|
||||
|
||||
const target = await db.query.users.findFirst({
|
||||
where: eq(users.id, targetUserId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(target?.enableRightClickOnWidgets).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
|
||||
await db.insert(onboarding).values({
|
||||
id: createId(),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
userCreateSchema,
|
||||
userDdgBangsSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userInitSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
@@ -345,6 +346,7 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -381,6 +383,7 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -651,6 +654,32 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
changeEnableRightClickOnWidgets: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PATCH",
|
||||
path: "/api/users/right-click-widgets",
|
||||
tags: ["users"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changePingIconsEnabled: protectedProcedure
|
||||
.input(userPingIconsEnabledSchema.and(byIdSchema))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
|
||||
return observable<LiveStatsEvent>((emit) => {
|
||||
const controller = new AbortController();
|
||||
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 () => {
|
||||
const integration = ctx.integrations[0];
|
||||
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
|
||||
await instance.subscribeRealtimeMetrics(
|
||||
input.systemId,
|
||||
(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,
|
||||
);
|
||||
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
} catch (error) {
|
||||
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(
|
||||
error instanceof TRPCError
|
||||
? error
|
||||
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
logger.debug("Beszel realtime subscription completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
});
|
||||
emit.complete();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
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();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,7 +3,10 @@ import { z } from "zod/v4";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import {
|
||||
mediaRequestListInputSchema,
|
||||
mediaRequestListRequestHandler,
|
||||
} from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -16,13 +19,14 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
});
|
||||
return results
|
||||
|
||||
@@ -3,6 +3,7 @@ import { escapeForRegEx } from "@tiptap/react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, items, widgetSecrets } from "@homarr/db/schema";
|
||||
import { releaseProviderKinds } from "@homarr/definitions";
|
||||
@@ -11,6 +12,8 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
const logger = createLogger({ module: "releasesRouter" });
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
@@ -71,13 +74,13 @@ export const releasesRouter = createTRPCRouter({
|
||||
for (const secret of secrets) {
|
||||
try {
|
||||
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
|
||||
} catch {
|
||||
// Skip corrupt secrets
|
||||
} catch (error) {
|
||||
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Access denied or table not yet migrated -- proceed without tokens
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
|
||||
[[["docker", "getContainers"], { type: "query" }], true],
|
||||
[[["integration", "byIds"], { type: "query" }], true],
|
||||
[[["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],
|
||||
[[["board", "getBoardByName"], { type: "query" }], false],
|
||||
[["widget"], false],
|
||||
|
||||
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of bytes to si format. (Division by 1024)
|
||||
* Does not accept floats, size in bytes should be an integer.
|
||||
* Will return "NaI" and logs a warning if a float is passed.
|
||||
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
|
||||
* Returns "∞" if the size is too large to be represented in the current format.
|
||||
* Number of bytes to a human-readable string using binary (KiB) suffixes.
|
||||
* Does not accept floats; size in bytes should be an integer.
|
||||
* Will return "NaI" and log a warning if a float is passed.
|
||||
* `concat` is appended after the unit so it is omitted when the returned
|
||||
* 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 => {
|
||||
//64bit limit for Number stops at EiB
|
||||
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
|
||||
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;
|
||||
|
||||
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
|
||||
|
||||
@@ -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 { checkServerIdentity } from "node:tls";
|
||||
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 {
|
||||
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
|
||||
};
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async (override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
}) => {
|
||||
export const createCertificateAgentAsync = async (
|
||||
override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
},
|
||||
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
|
||||
) => {
|
||||
return new UndiciHttpAgent({
|
||||
...agentOptions,
|
||||
connect: override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
|
||||
|
||||
export const fetchWithTrustedCertificatesAsync = async (
|
||||
url: RequestInfo,
|
||||
options?: RequestInit & { timeout?: number },
|
||||
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
|
||||
): Promise<Response> => {
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
const agent = await createCertificateAgentAsync(
|
||||
undefined,
|
||||
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
||||
);
|
||||
if (options?.timeout) {
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
|
||||
return await withTimeoutAsync(
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -295,6 +295,20 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0041_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "5",
|
||||
"when": 1783176100870,
|
||||
"tag": "0042_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1783852800000,
|
||||
"tag": "0043_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,20 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0007_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1783176100870,
|
||||
"tag": "0008_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1783786677702,
|
||||
"tag": "0009_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -281,6 +281,20 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0039_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "6",
|
||||
"when": 1783176100870,
|
||||
"tag": "0040_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1783786658943,
|
||||
"tag": "0041_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export const users = mysqlTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ export const users = pgTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ export const users = sqliteTable("user", {
|
||||
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
|
||||
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
|
||||
|
||||
connection.end();
|
||||
await mysqlContainer.stop();
|
||||
}, 40_000);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -379,7 +379,7 @@ export const integrationDefs = {
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
|
||||
defaultPort: 3413,
|
||||
apiKeySettingsPath: "/settings/api-keys",
|
||||
apiKeySettingsPath: "/settings/integrations",
|
||||
},
|
||||
tracearr: {
|
||||
name: "Tracearr",
|
||||
@@ -467,7 +467,6 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
defaultUrl: "http://localhost:8001",
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
|
||||
@@ -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", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -19,6 +19,12 @@ const createUrlByIdChannel = (id: string) =>
|
||||
headers: `${string}.${string}`;
|
||||
}>(`image-proxy:url:${id}`);
|
||||
|
||||
export type ForwardImageResult =
|
||||
| { image: ArrayBuffer; contentType: string | null }
|
||||
| { error: "not-found" }
|
||||
| { error: "upstream-error"; statusCode: number }
|
||||
| { error: "fetch-error" };
|
||||
|
||||
export class ImageProxy {
|
||||
private static hmacKey: Buffer<ArrayBuffer> | null = null;
|
||||
|
||||
@@ -47,17 +53,34 @@ export class ImageProxy {
|
||||
return this.createImageUrl(id);
|
||||
}
|
||||
|
||||
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
|
||||
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
|
||||
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
|
||||
if (!urlAndHeaders) {
|
||||
return null;
|
||||
return { error: "not-found" };
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||
headers: urlAndHeaders.headers ?? {},
|
||||
});
|
||||
|
||||
const proxyUrl = this.createImageUrl(id);
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||
try {
|
||||
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||
headers: urlAndHeaders.headers ?? {},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to connect to image source",
|
||||
{
|
||||
id,
|
||||
url: this.redactUrl(urlAndHeaders.url),
|
||||
headers: this.redactHeaders(urlAndHeaders.headers),
|
||||
proxyUrl,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return { error: "fetch-error" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to fetch image", {
|
||||
@@ -68,7 +91,7 @@ export class ImageProxy {
|
||||
statusCode: response.status,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
return { error: "upstream-error", statusCode: response.status };
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
@@ -80,7 +103,7 @@ export class ImageProxy {
|
||||
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
|
||||
});
|
||||
|
||||
return arrayBuffer;
|
||||
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
|
||||
}
|
||||
|
||||
private createImageUrl(id: string): string {
|
||||
|
||||
@@ -19,15 +19,66 @@ import type {
|
||||
BeszelSystemDetails,
|
||||
BeszelSystemdService,
|
||||
BeszelSystemStatsRecord,
|
||||
BeszelSystemStats,
|
||||
BeszelContainerStats,
|
||||
CreateAlertInput,
|
||||
LiveStatsEvent,
|
||||
PocketBaseListResponse,
|
||||
UpdateAlertInput,
|
||||
} from "./beszel-types";
|
||||
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
|
||||
|
||||
const logger = createLogger({ module: "beszel-integration" });
|
||||
|
||||
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 {
|
||||
token: string;
|
||||
@@ -285,123 +336,163 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
|
||||
public async subscribeRealtimeMetrics(
|
||||
systemId: string,
|
||||
onMessage: (event: LiveStatsEvent) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const session = await this.authenticateAsync();
|
||||
const realtimeUrl = this.url("/api/realtime");
|
||||
|
||||
logger.debug("Opening Beszel SSE connection for realtime metrics", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
url: realtimeUrl.pathname,
|
||||
});
|
||||
|
||||
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 systems = await this.getSystemsAsync();
|
||||
const system = systems.find((candidate) => candidate.id === systemId);
|
||||
if (!system) throw new Error("Selected Beszel system was not found");
|
||||
if (system.status !== "up")
|
||||
throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
|
||||
if (!supportsRealtimeMetrics(system.info.v)) {
|
||||
throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body in SSE stream");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let subscribed = false;
|
||||
|
||||
const processLine = async (line: string) => {
|
||||
if (!line.startsWith("data: ")) return;
|
||||
const realtimeUrl = this.url("/api/realtime");
|
||||
const topic = createRealtimeMetricsTopic(systemId);
|
||||
let retryAttempt = 0;
|
||||
let emittedSnapshotCount = 0;
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
|
||||
|
||||
// PB_CONNECT delivers the clientId — use it to POST our subscriptions
|
||||
if (!subscribed && typeof parsed.clientId === "string") {
|
||||
subscribed = true;
|
||||
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
method: "POST",
|
||||
let session = await this.authenticateAsync();
|
||||
const openStream = async () =>
|
||||
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
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,
|
||||
bodyTimeout: 0,
|
||||
});
|
||||
|
||||
if (!subscribeResponse.ok) {
|
||||
logger.warn("Beszel SSE subscription POST failed", {
|
||||
logger.debug("Opening Beszel rt_metrics SSE connection", {
|
||||
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,
|
||||
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,
|
||||
systemId,
|
||||
clientId: parsed.clientId,
|
||||
});
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = parsed.record as Record<string, unknown> | undefined;
|
||||
if (!record || record.system !== systemId) return;
|
||||
|
||||
if (Array.isArray(record.stats)) {
|
||||
onMessage({
|
||||
type: "container_stats",
|
||||
record: record as unknown as BeszelContainerStatsRecord,
|
||||
});
|
||||
} else if (record.stats && typeof record.stats === "object") {
|
||||
onMessage({
|
||||
type: "system_stats",
|
||||
record: record as unknown as BeszelSystemStatsRecord,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) throw error;
|
||||
// skip malformed SSE data lines
|
||||
if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
|
||||
const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
|
||||
retryAttempt += 1;
|
||||
logger.warn("Beszel rt_metrics connection lost; retrying", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
retryAttempt,
|
||||
delayMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await waitForRetryAsync(delayMs, signal);
|
||||
}
|
||||
};
|
||||
|
||||
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> {
|
||||
|
||||
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
|
||||
/** disk usage (%) */
|
||||
dp: number;
|
||||
/** disk read (MB/s) */
|
||||
dr: number;
|
||||
dr?: number;
|
||||
/** disk write (MB/s) */
|
||||
dw: number;
|
||||
dw?: number;
|
||||
/** disk read max (MB/s) */
|
||||
drm?: number;
|
||||
/** disk write max (MB/s) */
|
||||
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
|
||||
/** disk IOPS max [read, write] */
|
||||
diom?: [number, number];
|
||||
/** network sent — all interfaces (bytes/s) */
|
||||
ns: number;
|
||||
ns?: number;
|
||||
/** network received — all interfaces (bytes/s) */
|
||||
nr: number;
|
||||
nr?: number;
|
||||
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
|
||||
b?: [number, number];
|
||||
/** network sent max (bytes/s) */
|
||||
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
|
||||
}
|
||||
|
||||
export interface BeszelExtraFsStats {
|
||||
/** total disk (bytes) */
|
||||
/** total disk (GiB) */
|
||||
d: number;
|
||||
/** disk used (bytes) */
|
||||
/** disk used (GiB) */
|
||||
du: number;
|
||||
/** read (bytes/s) */
|
||||
r: number;
|
||||
/** write (bytes/s) */
|
||||
w: number;
|
||||
/** read max (bytes/s) */
|
||||
rm: number;
|
||||
rm?: number;
|
||||
/** write max (bytes/s) */
|
||||
wm: number;
|
||||
wm?: number;
|
||||
/** read (IOPS) */
|
||||
rb: number;
|
||||
rb?: number;
|
||||
/** write (IOPS) */
|
||||
wb: number;
|
||||
wb?: number;
|
||||
/** read max (IOPS) */
|
||||
rbm: number;
|
||||
rbm?: number;
|
||||
/** write max (IOPS) */
|
||||
wbm: number;
|
||||
wbm?: number;
|
||||
}
|
||||
|
||||
export interface BeszelGPUData {
|
||||
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
|
||||
memory: number;
|
||||
/** disk usage (%) */
|
||||
disk: number;
|
||||
/** extra filesystem usage (%) keyed by mount path */
|
||||
extraFilesystems: Record<string, number>;
|
||||
/** GPU usage (%) */
|
||||
gpu: number;
|
||||
/** 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([]),
|
||||
}));
|
||||
|
||||
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() {
|
||||
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 dayjs from "dayjs";
|
||||
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
||||
.map((storage, index) => ({
|
||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||
// 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}`,
|
||||
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> {
|
||||
const type = "torrent";
|
||||
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(
|
||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||
{ down: 0, up: 0 },
|
||||
|
||||
@@ -117,6 +117,12 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
state: null,
|
||||
positionMs: null,
|
||||
durationMs: null,
|
||||
},
|
||||
location: null,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAlbumInfo,
|
||||
getAllAlbums,
|
||||
getMyUser,
|
||||
@@ -75,7 +76,9 @@ export class ImmichIntegration extends Integration {
|
||||
assets: await Promise.all(
|
||||
albumAssets.map(async (asset) => {
|
||||
const publicLink = await imageProxy.createImageAsync(
|
||||
this.url(`/api/assets/${asset.id}/original`).toString(),
|
||||
// This is the URL generated by the SDK's viewAsset call. Preview images are
|
||||
// browser-compatible while avoiding the large original asset download.
|
||||
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
|
||||
{
|
||||
"x-api-key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
|
||||
@@ -13,6 +13,12 @@ export interface StreamSession {
|
||||
episodeName?: string | null;
|
||||
albumName?: string | null;
|
||||
episodeCount?: number | null;
|
||||
playback: {
|
||||
state: "playing" | "paused" | "buffering" | null;
|
||||
positionMs: number | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
location: "lan" | "wan" | null;
|
||||
metadata: {
|
||||
video: {
|
||||
resolution: {
|
||||
|
||||
@@ -17,6 +17,10 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
|
||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
|
||||
|
||||
function ticksToMs(ticks: number | null | undefined): number | null {
|
||||
return ticks ? Math.round(ticks / 10_000) : null;
|
||||
}
|
||||
|
||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
@@ -50,6 +54,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
@@ -57,6 +64,12 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location: null,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution:
|
||||
|
||||
@@ -226,6 +226,7 @@ export class BeszelMockService {
|
||||
u: randInt(86400, 2_000_000),
|
||||
mp: clamp(sys.baseMem + rand(-5, 5), 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,
|
||||
v: "0.8.2",
|
||||
g: i === 3 ? rand(15, 45) : 0,
|
||||
|
||||
@@ -13,6 +13,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -27,6 +29,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: "Ozymandias",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -41,6 +45,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -55,6 +61,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: "The We We Are",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -69,6 +77,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: "Random Access Memories",
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -83,6 +93,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -153,9 +153,9 @@ export class OverseerrIntegration
|
||||
);
|
||||
} else if (pendingResults.length > 0) requests = pendingResults;
|
||||
else if (allResults.length > 0) requests = allResults;
|
||||
else return Promise.all([]);
|
||||
else return [];
|
||||
|
||||
return await Promise.all(
|
||||
const settled = await Promise.allSettled(
|
||||
requests.map(async (request): Promise<MediaRequest> => {
|
||||
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
|
||||
|
||||
@@ -184,6 +184,10 @@ export class OverseerrIntegration
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return settled
|
||||
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
@@ -301,6 +305,10 @@ export class OverseerrIntegration
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (type === "tv") {
|
||||
const series = (await response.json()) as TvInformation;
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,54 @@ interface MediaContainer {
|
||||
Track?: Session[];
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
$: {
|
||||
streamType: string;
|
||||
codec?: string;
|
||||
language?: string;
|
||||
selected?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
frameRate?: string;
|
||||
channels?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Part {
|
||||
Stream?: Stream[];
|
||||
$: {
|
||||
container?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Media {
|
||||
Part?: Part[];
|
||||
$: {
|
||||
videoResolution?: string;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
bitrate?: string;
|
||||
container?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TranscodeSession {
|
||||
$: {
|
||||
throttled?: string;
|
||||
progress?: string;
|
||||
speed?: string;
|
||||
videoDecision?: string;
|
||||
audioDecision?: string;
|
||||
sourceVideoCodec?: string;
|
||||
sourceAudioCodec?: string;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
container?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Session {
|
||||
User?: {
|
||||
$: {
|
||||
@@ -15,13 +63,20 @@ interface Session {
|
||||
$: {
|
||||
product: string;
|
||||
title: string;
|
||||
address?: string;
|
||||
state?: string;
|
||||
local?: string;
|
||||
};
|
||||
}[];
|
||||
Session?: {
|
||||
$: {
|
||||
id: string;
|
||||
bandwidth?: string;
|
||||
location?: string;
|
||||
};
|
||||
}[];
|
||||
TranscodeSession?: TranscodeSession[];
|
||||
Media?: Media[];
|
||||
$: {
|
||||
grandparentTitle?: string;
|
||||
parentTitle?: string;
|
||||
@@ -29,6 +84,8 @@ interface Session {
|
||||
index?: number;
|
||||
type: string;
|
||||
live?: string;
|
||||
viewOffset?: string;
|
||||
duration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,32 @@ import type { PlexResponse } from "./interface";
|
||||
|
||||
const logger = createLogger({ module: "plexIntegration" });
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function parseLocation(value: string | undefined): "lan" | "wan" | null {
|
||||
return value === "lan" || value === "wan" ? value : null;
|
||||
}
|
||||
|
||||
function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null {
|
||||
return value === "playing" || value === "paused" || value === "buffering" ? value : null;
|
||||
}
|
||||
|
||||
function parseResolution(
|
||||
width: string | undefined,
|
||||
height: string | undefined,
|
||||
): { width: number; height: number } | null {
|
||||
const parsedWidth = parseOptionalNumber(width);
|
||||
const parsedHeight = parseOptionalNumber(height);
|
||||
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
|
||||
}
|
||||
|
||||
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const token = super.getSecretValue("apiKey");
|
||||
@@ -43,11 +69,24 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
|
||||
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
|
||||
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
|
||||
const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined;
|
||||
const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined;
|
||||
|
||||
if (!playerElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const positionMs = parseOptionalNumber(mediaElement.$.viewOffset);
|
||||
const durationMs = parseOptionalNumber(mediaElement.$.duration);
|
||||
|
||||
const playbackState = parsePlaybackState(playerElement.$.state);
|
||||
|
||||
const location = parseLocation(sessionElement?.$.location);
|
||||
|
||||
const streams = mediaInfoElement?.Part?.[0]?.Stream ?? [];
|
||||
const videoStream = streams.find((stream) => stream.$.streamType === "1");
|
||||
const audioStream = streams.find((stream) => stream.$.streamType === "2");
|
||||
|
||||
return {
|
||||
sessionId: sessionElement?.$.id ?? "unknown",
|
||||
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
|
||||
@@ -63,7 +102,30 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
episodeName: mediaElement.$.title ?? null,
|
||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||
episodeCount: mediaElement.$.index ?? null,
|
||||
metadata: null,
|
||||
playback: {
|
||||
state: playbackState,
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution: parseResolution(videoStream?.$.width, videoStream?.$.height),
|
||||
frameRate: parseOptionalNumber(videoStream?.$.frameRate),
|
||||
},
|
||||
audio: {
|
||||
channelCount: parseOptionalNumber(audioStream?.$.channels),
|
||||
codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null,
|
||||
},
|
||||
transcoding: {
|
||||
container: transcodeElement?.$.container ?? null,
|
||||
resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height),
|
||||
target: {
|
||||
audioCodec: transcodeElement?.$.audioCodec ?? null,
|
||||
videoCodec: transcodeElement?.$.videoCodec ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { fetch as undiciFetch } from "undici/types/fetch";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
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
|
||||
fileSystem: systemInfo.array.disks.map((disk) => ({
|
||||
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)
|
||||
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
||||
})),
|
||||
|
||||
@@ -23,6 +23,8 @@ type OptionMapping = {
|
||||
const optionMapping: OptionMapping = {
|
||||
"mediaRequests-requestList": {
|
||||
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
|
||||
statusFilter: () => undefined,
|
||||
recentDays: () => undefined,
|
||||
},
|
||||
"mediaRequests-requestStats": {},
|
||||
bookmarks: {
|
||||
|
||||
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
|
||||
cpu: info.cpu,
|
||||
memory: info.mp,
|
||||
disk: info.dp,
|
||||
extraFilesystems: info.efs ?? {},
|
||||
gpu: info.g ?? 0,
|
||||
loadAvg: info.la ?? null,
|
||||
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
|
||||
|
||||
@@ -300,10 +300,7 @@ async function getContainersWithStatsAsync() {
|
||||
|
||||
const stats = await instance
|
||||
.getContainer(container.Id)
|
||||
// Not in one-shot mode: the daemon then includes precpu_stats (the
|
||||
// previous reading), which is required to compute the current CPU usage
|
||||
// as a delta. One-shot omits it and would only expose lifetime totals.
|
||||
.stats({ stream: false })
|
||||
.stats({ stream: false, "one-shot": true })
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
@@ -339,22 +336,12 @@ export function calculateCpuUsage(stats: ContainerStats): number {
|
||||
}
|
||||
|
||||
const numberOfCpus = stats.cpu_stats.online_cpus;
|
||||
|
||||
// Docker's own formula for the current (instantaneous) CPU usage: it compares
|
||||
// the latest reading against the previous one (precpu_stats). Dividing the
|
||||
// cumulative total_usage by the cumulative system_cpu_usage instead would
|
||||
// report the container's lifetime average, which stays high long after a load
|
||||
// spike has ended. precpu_stats is only present when the stats are NOT fetched
|
||||
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
|
||||
// cumulative totals, matching the previous behaviour.
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
||||
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
||||
|
||||
if (systemDelta <= 0 || cpuDelta < 0) {
|
||||
const usage = stats.cpu_stats.system_cpu_usage;
|
||||
if (!usage || usage === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (cpuDelta / systemDelta) * numberOfCpus * 100;
|
||||
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
|
||||
}
|
||||
|
||||
export function calculateMemoryUsage(stats: ContainerStats): number {
|
||||
|
||||
@@ -13,6 +13,7 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
@@ -32,6 +33,7 @@ export const createIntegrationRequestHandler = <
|
||||
return options.requestAsync(integration, input.options);
|
||||
},
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
@@ -35,7 +36,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
@@ -55,6 +55,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
if (options.fallbackToStaleOnError && cached) {
|
||||
return cached;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { MediaRequest } from "@homarr/integrations/types";
|
||||
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const mediaRequestStatusValues = [
|
||||
"pending",
|
||||
"approved",
|
||||
"declined",
|
||||
"failed",
|
||||
"completed",
|
||||
] as const satisfies readonly MediaRequestStatus[];
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const mediaRequestListInputSchema = z.object({
|
||||
statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(),
|
||||
recentDays: z.number().min(0).max(365),
|
||||
});
|
||||
|
||||
export type MediaRequestListInput = z.infer<typeof mediaRequestListInputSchema>;
|
||||
|
||||
export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
|
||||
MediaRequest[],
|
||||
IntegrationKindByCategory<"mediaRequest">,
|
||||
Record<string, never>
|
||||
MediaRequestListInput
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getRequestsAsync();
|
||||
const requests = await integrationInstance.getRequestsAsync();
|
||||
const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null;
|
||||
const statusSet = new Set<MediaRequestStatus>(input.statuses);
|
||||
return requests.filter((request) => {
|
||||
if (!statusSet.has(request.status)) return false;
|
||||
if (cutoff !== null && request.createdAt.getTime() < cutoff) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
@@ -134,7 +134,33 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
|
||||
baseUrl,
|
||||
auth: token,
|
||||
request: { fetch: fetchWithTrustedCertificatesAsync },
|
||||
throttle: { enabled: true },
|
||||
throttle: {
|
||||
enabled: true,
|
||||
onRateLimit: (
|
||||
retryAfter: number,
|
||||
options: { url?: string; method?: string },
|
||||
_octokit: unknown,
|
||||
retryCount: number,
|
||||
) => {
|
||||
logger.warn(`GitHub rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
});
|
||||
return retryCount < 1;
|
||||
},
|
||||
onSecondaryRateLimit: (
|
||||
retryAfter: number,
|
||||
options: { url?: string; method?: string },
|
||||
_octokit: unknown,
|
||||
retryCount: number,
|
||||
) => {
|
||||
logger.warn(`GitHub secondary rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
});
|
||||
return retryCount < 1;
|
||||
},
|
||||
},
|
||||
userAgent,
|
||||
});
|
||||
|
||||
@@ -719,6 +745,19 @@ const linuxServerIOReleasesSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// Parse a github.com project URL (as returned by the LSIO registry) into an
|
||||
// "owner/repo" identifier, or null if it is not a github.com repository URL.
|
||||
const parseGithubRepo = (url: string): string | null => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== "github.com") return null;
|
||||
const [owner, repo] = parsed.pathname.replace(/^\/+/, "").split("/");
|
||||
return owner && repo ? `${owner}/${repo}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLinuxServerIOReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
@@ -748,6 +787,29 @@ const getLinuxServerIOReleaseAsync = async (
|
||||
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
|
||||
};
|
||||
|
||||
// LSIO's /api/v1/images only exposes the single latest version, so a version
|
||||
// filter can never surface an older release (#4351). When the image has a GitHub
|
||||
// repository, delegate to the GitHub release provider to fetch the real release
|
||||
// history and apply the filter, while keeping LSIO's own image metadata. Any
|
||||
// GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
|
||||
const githubRepo = parseGithubRepo(release.github_url);
|
||||
if (githubRepo) {
|
||||
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
|
||||
if (githubResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...githubResult.data,
|
||||
projectUrl: release.github_url,
|
||||
projectDescription: release.description,
|
||||
isArchived: release.deprecated,
|
||||
starsCount: release.stars,
|
||||
createdAt: release.initial_date ?? githubResult.data.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return {
|
||||
|
||||
@@ -75,26 +75,6 @@ describe("calculateCpuUsage", () => {
|
||||
// (500 / 100000) * 2 * 100 = 1
|
||||
expect(calculateCpuUsage(stats)).toBe(1);
|
||||
});
|
||||
|
||||
test("should use the delta against precpu_stats for the current usage", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
|
||||
});
|
||||
// Despite a large lifetime total_usage, only the recent delta counts:
|
||||
// (2000 / 10000) * 4 * 100 = 80
|
||||
expect(calculateCpuUsage(stats)).toBe(80);
|
||||
});
|
||||
|
||||
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
|
||||
});
|
||||
// No CPU delta since the last reading -> current usage is 0, even though the
|
||||
// lifetime average would be high. This is the regression the fix addresses.
|
||||
expect(calculateCpuUsage(stats)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateMemoryUsage", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type SettingsContextProps = Pick<
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["user"], "enableGravatar">;
|
||||
@@ -32,6 +33,7 @@ export type UserSettings = Pick<
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
>;
|
||||
|
||||
export const createSettings = ({
|
||||
@@ -48,6 +50,7 @@ export const createSettings = ({
|
||||
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
enableGravatar: serverSettings.user.enableGravatar,
|
||||
|
||||
@@ -56,6 +56,13 @@ export const userPreferenceDefinitions = [
|
||||
mutationGroup: "pingIconsEnabled",
|
||||
aliases: ["ping", "icon", "status"],
|
||||
},
|
||||
{
|
||||
key: "enableRightClickOnWidgets",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "enableRightClickOnWidgets",
|
||||
aliases: ["right click", "context menu", "tile", "widget menu"],
|
||||
},
|
||||
{
|
||||
key: "fullPreferencesPage",
|
||||
kind: "link",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
IconActivity,
|
||||
IconCalendarWeek,
|
||||
IconClick,
|
||||
IconDeviceDesktop,
|
||||
IconExternalLink,
|
||||
IconHome,
|
||||
@@ -46,6 +47,7 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
|
||||
homeBoardId: IconHome,
|
||||
mobileHomeBoardId: IconLayoutBoard,
|
||||
pingIconsEnabled: IconActivity,
|
||||
enableRightClickOnWidgets: IconClick,
|
||||
fullPreferencesPage: IconSettings,
|
||||
};
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export const useUserPreferences = () => {
|
||||
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
|
||||
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
|
||||
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
|
||||
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
|
||||
|
||||
const getEffective = (key: UserPreferenceKey): unknown =>
|
||||
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
|
||||
@@ -116,6 +117,8 @@ export const useUserPreferences = () => {
|
||||
}),
|
||||
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
|
||||
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
|
||||
enableRightClickOnWidgets: (uid, _key, value) =>
|
||||
rightClickMutation.mutateAsync({ id: uid, enableRightClickOnWidgets: value as boolean }),
|
||||
};
|
||||
|
||||
const groupPending: Record<string, boolean> = {
|
||||
@@ -123,6 +126,7 @@ export const useUserPreferences = () => {
|
||||
homeBoards: homeBoardsMutation.isPending,
|
||||
firstDayOfWeek: firstDayMutation.isPending,
|
||||
pingIconsEnabled: pingMutation.isPending,
|
||||
enableRightClickOnWidgets: rightClickMutation.isPending,
|
||||
};
|
||||
|
||||
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
|
||||
|
||||
@@ -1598,10 +1598,16 @@
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"settings": "Settings",
|
||||
"settings": "",
|
||||
"refresh": "",
|
||||
"options": "",
|
||||
"actions": ""
|
||||
},
|
||||
"status": {
|
||||
"success": "",
|
||||
"error": "",
|
||||
"loading": "",
|
||||
"idle": ""
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
|
||||
@@ -198,6 +198,10 @@
|
||||
"pingIconsEnabled": {
|
||||
"label": "Use icons for pings"
|
||||
},
|
||||
"enableRightClickOnWidgets": {
|
||||
"label": "Enable right-click on widgets",
|
||||
"description": "Show Homarr's context menu when right-clicking a widget. Disable to use the browser's default menu (e.g. \"Open link in new tab\")."
|
||||
},
|
||||
"defaultSearchEngine": {
|
||||
"label": "Default search engine"
|
||||
},
|
||||
@@ -1622,6 +1626,12 @@
|
||||
"refresh": "Refresh",
|
||||
"options": "Options",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"status": {
|
||||
"success": "OK",
|
||||
"error": "Error",
|
||||
"loading": "Loading",
|
||||
"idle": "Idle"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -1956,6 +1966,8 @@
|
||||
"clear": "Clear formatting",
|
||||
"heading": "Heading {level}",
|
||||
"align": "Align text: {position}",
|
||||
"directionLtr": "Set text direction left to right",
|
||||
"directionRtl": "Set text direction right to left",
|
||||
"blockquote": "Blockquote",
|
||||
"horizontalLine": "Horizontal line",
|
||||
"bulletList": "Bullet list",
|
||||
@@ -2709,6 +2721,11 @@
|
||||
"user": "User",
|
||||
"name": "Name",
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direct play",
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
"metadata": {
|
||||
"title": "Stats for nerds",
|
||||
"video": {
|
||||
@@ -3193,6 +3210,12 @@
|
||||
"option": {
|
||||
"linksTargetNewTab": {
|
||||
"label": "Open links in new tab"
|
||||
},
|
||||
"statusFilter": {
|
||||
"label": "Statuses to show"
|
||||
},
|
||||
"recentDays": {
|
||||
"label": "Show only recent requests"
|
||||
}
|
||||
},
|
||||
"pending": {
|
||||
@@ -4031,9 +4054,6 @@
|
||||
},
|
||||
"showDockerNetwork": {
|
||||
"label": "Show Docker network chart"
|
||||
},
|
||||
"showStorage": {
|
||||
"label": "Show storage chart"
|
||||
}
|
||||
},
|
||||
"selectSystem": "Select system",
|
||||
@@ -4060,8 +4080,8 @@
|
||||
},
|
||||
"disk": {
|
||||
"title": "Disk Usage",
|
||||
"subtitle": "Usage of root partition",
|
||||
"series": "Used"
|
||||
"subtitle": "Usage by filesystem",
|
||||
"series": "Root"
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "Disk I/O",
|
||||
@@ -5206,6 +5226,9 @@
|
||||
"title": "Containers",
|
||||
"table": {
|
||||
"updated": "Updated {when}",
|
||||
"refresh": {
|
||||
"lastUpdated": "Last refresh {when}"
|
||||
},
|
||||
"search": "Seach {count} containers",
|
||||
"selected": "{selectCount} of {totalCount} containers selected",
|
||||
"footer": "Total {count} containers",
|
||||
@@ -5934,6 +5957,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableRightClickOnWidgets": {
|
||||
"label": "Enable right-click on widgets",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Enable right-click on widgets"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fullPreferencesPage": {
|
||||
"label": "Open full preferences page"
|
||||
}
|
||||
|
||||
@@ -130,6 +130,10 @@ export const userPingIconsEnabledSchema = z.object({
|
||||
pingIconsEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const userEnableRightClickOnWidgetsSchema = z.object({
|
||||
enableRightClickOnWidgets: z.boolean(),
|
||||
});
|
||||
|
||||
export const userDdgBangsSchema = z.object({
|
||||
ddgBangs: z.boolean(),
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
|
||||
</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 { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
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 { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
|
||||
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 getColCount = (width: number, itemCount: number): number => {
|
||||
const getColCount = (width: number, height: number, itemCount: number): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
|
||||
|
||||
let best = 1;
|
||||
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;
|
||||
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
|
||||
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
|
||||
};
|
||||
|
||||
interface MetricRowProps {
|
||||
@@ -133,20 +133,25 @@ interface MetricRowProps {
|
||||
}
|
||||
|
||||
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
|
||||
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
|
||||
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
|
||||
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{value}
|
||||
</Text>
|
||||
{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>
|
||||
);
|
||||
|
||||
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}
|
||||
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
|
||||
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<MetricValue value={value} progress={progress} size={size} />
|
||||
@@ -195,14 +200,13 @@ const metricRenderers = [
|
||||
{
|
||||
key: "showDisk",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="disk"
|
||||
icon={<HardDrive size={sz.iconSize} />}
|
||||
label={t("metric.disk")}
|
||||
value={formatPercent(s.disk)}
|
||||
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
|
||||
size={sz}
|
||||
/>
|
||||
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
|
||||
<HardDrive size={sz.iconSize} />
|
||||
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{t("metric.disk")}
|
||||
</Text>
|
||||
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
|
||||
</Group>
|
||||
),
|
||||
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 rawCellHeight = height / rows;
|
||||
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
|
||||
icon: IconLayoutGrid,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -1,54 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
|
||||
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { containerColors } from "../beszel/_shared/colors";
|
||||
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 { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||
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;
|
||||
}
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const selectedSystem = options.systemId || systems[0]?.value || "";
|
||||
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
|
||||
const systemExists = systems.some((s) => s.value === selectedSystem);
|
||||
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
|
||||
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
|
||||
const systemExists = systems.some((system) => system.value === selectedSystem);
|
||||
|
||||
const handleSelectSystem = useCallback(
|
||||
(value: string) => {
|
||||
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
|
||||
[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 (!isLive && statsError) throw statsError;
|
||||
|
||||
if (systemsPending) {
|
||||
if (systemsPending)
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (systems.length === 0) {
|
||||
return (
|
||||
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemsPending && !systemExists && systems.length > 0) {
|
||||
if (!systemExists) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<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 (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
className={classes.beszelStatsContainer}
|
||||
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap="md" p="sm">
|
||||
{!isEditMode && systems.length > 1 && (
|
||||
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((s) => (
|
||||
{systems.map((system) => (
|
||||
<Menu.Item
|
||||
key={s.value}
|
||||
key={system.value}
|
||||
fz="xs"
|
||||
fw={s.value === selectedSystem ? 600 : 400}
|
||||
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(s.value)}
|
||||
fw={system.value === selectedSystem ? 600 : 400}
|
||||
c={system.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(system.value)}
|
||||
>
|
||||
{s.label}
|
||||
{system.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
<BeszelStatsView
|
||||
integrationIds={integrationIds}
|
||||
systemId={selectedSystem}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDiskIO,
|
||||
network: options.showNetwork,
|
||||
dockerCpu: options.showDockerCpu,
|
||||
dockerMemory: options.showDockerMemory,
|
||||
dockerNetwork: options.showDockerNetwork,
|
||||
}}
|
||||
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
|
||||
/>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
@@ -16,6 +16,7 @@ const timePeriodOptions = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
|
||||
icon: IconChartAreaLine,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
|
||||
showDockerCpu: factory.switch({ defaultValue: true }),
|
||||
showDockerMemory: factory.switch({ defaultValue: true }),
|
||||
showDockerNetwork: factory.switch({ defaultValue: true }),
|
||||
showStorage: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
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 { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||
|
||||
@@ -97,11 +105,16 @@ export default function BeszelSystemTableWidget({
|
||||
}, [filteredSystems, sortStatus]);
|
||||
|
||||
const PercentCell = ({ value }: { value: number }) => (
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{formatPercent(value)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -156,7 +169,15 @@ export default function BeszelSystemTableWidget({
|
||||
</Group>
|
||||
),
|
||||
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 && {
|
||||
accessor: "gpu",
|
||||
|
||||
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
|
||||
icon: IconTable,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
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]);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user