Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b650bffaa8 | ||
|
|
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 | ||
|
|
34bd574354 |
No files matched your search
@@ -6,7 +6,14 @@ npm-debug.log
|
|||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.turbo
|
.turbo
|
||||||
|
.pnpm-store
|
||||||
|
**/.pnpm-store
|
||||||
|
coverage
|
||||||
|
**/coverage
|
||||||
|
**/.docusaurus
|
||||||
|
**/build
|
||||||
.git
|
.git
|
||||||
|
.github
|
||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
e2e
|
e2e
|
||||||
|
|||||||
@@ -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/)
|
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
|
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
|
||||||
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
|
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
|
||||||
|
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
|
||||||
|
|
||||||
|
**When contributing a new integration or widget:**
|
||||||
|
|
||||||
|
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
|
||||||
|
tests (e.g. what happens if it is offline)
|
||||||
|
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
|
||||||
|
provide support and maintain the integration (e.g. in case of breaking or failure)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Auto-fix lint and format
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
branches: ["*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
autofix:
|
||||||
|
name: Auto-fix
|
||||||
|
if: github.event.pull_request.head.repo.id == github.repository_id
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed
|
||||||
|
env:
|
||||||
|
BASE_REF: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
|
||||||
|
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Auto-fix lint + format
|
||||||
|
if: steps.changed.outputs.count != '0'
|
||||||
|
run: |
|
||||||
|
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
|
||||||
|
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
||||||
|
|
||||||
|
- name: Commit fixes
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
|
with:
|
||||||
|
commit_message: "style: auto-fix lint and format"
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
branches: ["*"]
|
||||||
|
pull_request_target:
|
||||||
|
types: [closed]
|
||||||
|
branches: ["*"]
|
||||||
|
push:
|
||||||
|
branches: [main, dev, beta, next]
|
||||||
|
merge_group:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
operation:
|
||||||
|
description: Operation to run
|
||||||
|
required: true
|
||||||
|
default: validate
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- validate
|
||||||
|
- publish
|
||||||
|
- cleanup
|
||||||
|
pr_number:
|
||||||
|
description: PR number for publish or cleanup
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
ref:
|
||||||
|
description: Git ref to validate or publish
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_COLOR: 3
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
TURBO_TELEMETRY_DISABLED: 1
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fast-gate:
|
||||||
|
name: Fast gate
|
||||||
|
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
TURBO_API: ${{ secrets.TURBO_API }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||||
|
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
|
||||||
|
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||||
|
TURBO_PLATFORM: linux/amd64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
if: '!env.ACT'
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||||
|
|
||||||
|
- name: Validate manual inputs
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
|
||||||
|
env:
|
||||||
|
PR_NUMBER: ${{ inputs.pr_number }}
|
||||||
|
BUILD_REF: ${{ inputs.ref }}
|
||||||
|
run: |
|
||||||
|
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
|
||||||
|
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Copy environment defaults
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Check formatting in affected workspaces
|
||||||
|
run: pnpm turbo run format --affected --continue -- --check
|
||||||
|
|
||||||
|
- name: Lint, typecheck, and build affected workspaces
|
||||||
|
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
||||||
|
|
||||||
|
- name: Validate workspace configuration
|
||||||
|
run: pnpm lint:ws
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Report coverage
|
||||||
|
if: always() && !env.ACT
|
||||||
|
uses: davelosert/vitest-coverage-report-action@v2
|
||||||
|
|
||||||
|
container:
|
||||||
|
name: Container, E2E, and memory
|
||||||
|
needs: fast-gate
|
||||||
|
if: |
|
||||||
|
github.event_name == 'merge_group' ||
|
||||||
|
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
if: '!env.ACT'
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: '!env.ACT'
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build container once
|
||||||
|
if: '!env.ACT'
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
network: host
|
||||||
|
platforms: linux/amd64
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: |
|
||||||
|
homarr-ci
|
||||||
|
homarr-e2e
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
||||||
|
cache-from: type=gha,scope=homarr-linux-amd64
|
||||||
|
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||||
|
secrets: |
|
||||||
|
TURBO_API=${{ secrets.TURBO_API }}
|
||||||
|
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||||
|
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||||
|
|
||||||
|
- name: Build container once with Act
|
||||||
|
if: env.ACT
|
||||||
|
env:
|
||||||
|
TURBO_API: ${{ secrets.TURBO_API }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||||
|
run: |
|
||||||
|
secret_args=()
|
||||||
|
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
|
||||||
|
if [ -n "${!secret:-}" ]; then
|
||||||
|
secret_args+=(--secret "id=${secret},env=${secret}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
||||||
|
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
if: '!env.ACT'
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Install Playwright system deps
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
run: pnpm exec playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: pnpm test:e2e
|
||||||
|
|
||||||
|
- name: Upload E2E artifacts
|
||||||
|
if: failure() && !env.ACT
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: e2e/test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Run memory regression test
|
||||||
|
env:
|
||||||
|
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
run: |
|
||||||
|
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
|
||||||
|
-e NO_EXTERNAL_CONNECTION=true \
|
||||||
|
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
|
||||||
|
homarr-ci
|
||||||
|
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
|
||||||
|
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
|
||||||
|
URL="http://127.0.0.1:${PORT}"
|
||||||
|
|
||||||
|
healthy=false
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
|
||||||
|
echo "Container healthy after $((i * 2))s"
|
||||||
|
healthy=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$healthy" != "true" ]; then
|
||||||
|
echo "ERROR: Container never became healthy"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail 100
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
warmup_ok=0
|
||||||
|
for _ in $(seq 1 5); do
|
||||||
|
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "$warmup_ok" -eq 0 ]; then
|
||||||
|
echo "ERROR: All warmup requests failed"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail 50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
|
||||||
|
echo "ERROR: Container died before memory measurement"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail 100
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
|
||||||
|
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
|
||||||
|
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
|
||||||
|
/MiB/ { gsub(/MiB/, ""); print $1; next }
|
||||||
|
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
|
||||||
|
{ print $1 / 1048576 }
|
||||||
|
')
|
||||||
|
|
||||||
|
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
|
||||||
|
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
|
||||||
|
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail 100
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
|
||||||
|
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail 100
|
||||||
|
exit 1
|
||||||
|
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
|
||||||
|
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail 100
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Clean up memory test container
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
- name: Determine preview tag
|
||||||
|
id: preview
|
||||||
|
if: |
|
||||||
|
!env.ACT &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||||
|
)
|
||||||
|
env:
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||||
|
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: steps.preview.outputs.tag != ''
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Push preview image
|
||||||
|
if: steps.preview.outputs.tag != ''
|
||||||
|
env:
|
||||||
|
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||||
|
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||||
|
|
||||||
|
- name: Show Act publication result
|
||||||
|
if: env.ACT
|
||||||
|
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
name: Clean up preview image
|
||||||
|
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||||
|
steps:
|
||||||
|
- name: Validate cleanup input
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
env:
|
||||||
|
PR_NUMBER: ${{ inputs.pr_number }}
|
||||||
|
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
||||||
|
|
||||||
|
- name: Delete GHCR package version
|
||||||
|
if: '!env.ACT'
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const packageName = "homarr-test";
|
||||||
|
const org = context.repo.owner;
|
||||||
|
const tag = process.env.IMAGE_TAG;
|
||||||
|
const versions = await github.paginate(
|
||||||
|
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
|
||||||
|
{ package_type: "container", package_name: packageName, org, per_page: 100 },
|
||||||
|
);
|
||||||
|
const target = versions.find((version) =>
|
||||||
|
version.metadata?.container?.tags?.includes(tag),
|
||||||
|
);
|
||||||
|
if (!target) {
|
||||||
|
core.info(`No package version found with tag "${tag}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await github.rest.packages.deletePackageVersionForOrg({
|
||||||
|
package_type: "container",
|
||||||
|
package_name: packageName,
|
||||||
|
org,
|
||||||
|
package_version_id: target.id,
|
||||||
|
});
|
||||||
|
core.info(`Deleted package version ${target.id} (${tag})`);
|
||||||
|
|
||||||
|
- name: Show Act cleanup result
|
||||||
|
if: env.ACT
|
||||||
|
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
name: "[Quality] Code Analysis"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: ["*"]
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
merge_group:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_COLOR: 3
|
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
TURBO_API: ${{ secrets.TURBO_API }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
||||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
|
|
||||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-format-autofix:
|
|
||||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.head_ref }}
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Get changed files
|
|
||||||
id: changed
|
|
||||||
run: |
|
|
||||||
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
|
|
||||||
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Auto-fix lint + format
|
|
||||||
if: steps.changed.outputs.count != '0'
|
|
||||||
run: |
|
|
||||||
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
|
|
||||||
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
|
||||||
|
|
||||||
- name: Commit fixes
|
|
||||||
uses: stefanzweifel/git-auto-commit-action@v7
|
|
||||||
with:
|
|
||||||
commit_message: "style: auto-fix lint and format"
|
|
||||||
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Copy env
|
|
||||||
shell: bash
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: pnpm turbo lint --affected && pnpm lint:ws
|
|
||||||
|
|
||||||
format:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Format
|
|
||||||
run: pnpm turbo format --affected --continue
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: pnpm turbo typecheck --affected
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pnpm test
|
|
||||||
|
|
||||||
- name: "Report Coverage"
|
|
||||||
# Set if: always() to also generate the report if tests are failing
|
|
||||||
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
|
||||||
if: always()
|
|
||||||
uses: davelosert/vitest-coverage-report-action@v2
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v4
|
|
||||||
- name: Build docker image
|
|
||||||
id: build-docker-image
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
load: true
|
|
||||||
tags: homarr-e2e
|
|
||||||
network: host
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
env:
|
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
- name: Install playwright browsers
|
|
||||||
run: pnpm exec playwright install chromium
|
|
||||||
- name: Run E2E Tests
|
|
||||||
shell: bash
|
|
||||||
run: pnpm test:e2e
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
- name: Copy env
|
|
||||||
shell: bash
|
|
||||||
run: cp .env.example .env
|
|
||||||
- name: Build
|
|
||||||
run: pnpm turbo build --affected
|
|
||||||
@@ -148,6 +148,11 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
secrets: |
|
||||||
|
TURBO_API=${{ secrets.TURBO_API }}
|
||||||
|
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||||
|
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
build-arm64:
|
build-arm64:
|
||||||
@@ -187,6 +192,11 @@ jobs:
|
|||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
secrets: |
|
||||||
|
TURBO_API=${{ secrets.TURBO_API }}
|
||||||
|
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||||
|
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
extract-asset-amd64:
|
extract-asset-amd64:
|
||||||
|
|||||||
+30
-11
@@ -1,24 +1,43 @@
|
|||||||
|
# syntax=docker/dockerfile:1.25
|
||||||
|
|
||||||
FROM node:24.18.0-alpine AS base
|
FROM node:24.18.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
RUN apk update
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache libc6-compat curl bash
|
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||||
RUN apk update
|
|
||||||
|
RUN corepack enable pnpm
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
COPY patches ./patches
|
||||||
|
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||||
|
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||||
|
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||||
|
# content-addressable store is populated). By temporarily switching to the
|
||||||
|
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
|
||||||
|
# which IS created by pnpm fetch. The original hoisted linker is restored
|
||||||
|
# before the install step so the final node_modules layout stays flat.
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm config set store-dir /pnpm/store && \
|
||||||
|
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
||||||
|
pnpm fetch && \
|
||||||
|
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||||
|
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --recursive
|
||||||
|
|
||||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
|
||||||
|
|
||||||
# Copy static data as it is not part of the build
|
|
||||||
COPY static-data ./static-data
|
|
||||||
ARG SKIP_ENV_VALIDATION='true'
|
ARG SKIP_ENV_VALIDATION='true'
|
||||||
ARG CI='true'
|
ARG CI='true'
|
||||||
ARG DISABLE_REDIS_LOGS='true'
|
ARG DISABLE_REDIS_LOGS='true'
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
RUN corepack enable pnpm && pnpm build
|
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
|
||||||
|
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
|
||||||
|
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
||||||
|
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
|
||||||
|
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
A Helm chart to deploy homarr for Kubernetes
|
A Helm chart to deploy homarr for Kubernetes
|
||||||
|
|
||||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
|||||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||||
| image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion |
|
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||||
| ingress.enabled | bool | `false` | Enable ingress |
|
| ingress.enabled | bool | `false` | Enable ingress |
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
---
|
---
|
||||||
title: 'Notebook'
|
title: "Notebook"
|
||||||
description: 'A simple notebook widget that supports markdown.'
|
description: "A simple notebook widget that supports markdown."
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||||
import { notebookWidget } from '.';
|
import { notebookWidget } from ".";
|
||||||
|
|
||||||
|
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
|
||||||
<WidgetHeader
|
|
||||||
widget={notebookWidget}
|
|
||||||
categories={['Notes']}
|
|
||||||
/>
|
|
||||||
|
|
||||||
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
|
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
|
||||||
It is based on Tiptap.dev and supports most of its features.
|
It is based on Tiptap.dev and supports most of its features.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
import editOff from './img/normal.png';
|
import editOff from "./img/normal.png";
|
||||||
import editOn from './img/edit.png';
|
import editOn from "./img/edit.png";
|
||||||
|
|
||||||
<div className='flex gap-1'>
|
<div className="flex gap-1">
|
||||||
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
|
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
|
||||||
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
|
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
|
||||||
</div>
|
</div>
|
||||||
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
|
|||||||
<AddingWidget />
|
<AddingWidget />
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
<WidgetConfig configuration={notebookWidget.configuration} />
|
<WidgetConfig configuration={notebookWidget.configuration} />
|
||||||
|
|
||||||
### Editing
|
### Editing
|
||||||
|
|
||||||
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
|
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
### Notebook toolbar functions
|
### Notebook toolbar functions
|
||||||
The notebook offers a variaty of functions to edit the text and more.
|
|
||||||
|
The notebook offers a variety of functions to edit the text and more.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Common text formatting
|
#### Common text formatting
|
||||||
|
|
||||||
| Function | Description |
|
| Function | Description |
|
||||||
| ---------------------- | ------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------- |
|
||||||
| Bold | Makes the selected text bold |
|
| Bold | Makes the selected text bold |
|
||||||
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
|
|||||||
| Clear formatting | Erases any of the preceding function's effects on the selected text |
|
| Clear formatting | Erases any of the preceding function's effects on the selected text |
|
||||||
|
|
||||||
#### Heading
|
#### Heading
|
||||||
|
|
||||||
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
|
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
|
||||||
|
|
||||||
#### Text Alignement
|
#### Text Alignment
|
||||||
Select the alignement on the line. Left, center or right.
|
|
||||||
|
Select the alignment on the line. Left, center or right.
|
||||||
|
|
||||||
|
#### Text direction
|
||||||
|
|
||||||
|
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
|
||||||
|
|
||||||
#### Misc functions
|
#### Misc functions
|
||||||
|
|
||||||
##### Blockquote
|
##### Blockquote
|
||||||
|
|
||||||
Puts the current line in a quoting block.
|
Puts the current line in a quoting block.
|
||||||
|
|
||||||
##### Horizontal line
|
##### Horizontal line
|
||||||
|
|
||||||
Adds a line that goes from the far right to the far left as a separator.
|
Adds a line that goes from the far right to the far left as a separator.
|
||||||
|
|
||||||
#### Lists
|
#### Lists
|
||||||
|
|
||||||
##### Bullet lists
|
##### Bullet lists
|
||||||
|
|
||||||
Simple list with a dot preceding each element.
|
Simple list with a dot preceding each element.
|
||||||
|
|
||||||
##### Ordered lists
|
##### Ordered lists
|
||||||
|
|
||||||
List with numbers preceding each element.
|
List with numbers preceding each element.
|
||||||
|
|
||||||
##### Check lists
|
##### Check lists
|
||||||
|
|
||||||
List with a checkbox on each element.
|
List with a checkbox on each element.
|
||||||
|
|
||||||
##### Indentation changes
|
##### Indentation changes
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
|
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
|
||||||
|
|
||||||
#### Insert functions
|
#### Insert functions
|
||||||
|
|
||||||
##### Link controls
|
##### Link controls
|
||||||
|
|
||||||
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
|
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
|
||||||
|
|
||||||
Pasting a link directly into the text will automatically put the link anchor on it too.
|
Pasting a link directly into the text will automatically put the link anchor on it too.
|
||||||
@@ -102,16 +117,20 @@ The second button will remove the link while leaving the text intact.
|
|||||||
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
|
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
|
||||||
|
|
||||||
##### Image controls
|
##### Image controls
|
||||||
|
|
||||||

|

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

|

|
||||||
@@ -119,6 +138,7 @@ This last control allows you to add a table, for which you can determine the num
|
|||||||
Clicking that button while in a table will delete it.
|
Clicking that button while in a table will delete it.
|
||||||
|
|
||||||
##### Table controls
|
##### Table controls
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
While in a table, further controls will appear to help edit the table.
|
While in a table, further controls will appear to help edit the table.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"random-word-slugs": "^0.1.7",
|
"random-word-slugs": "^0.1.7",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"swiper": "^14.0.1",
|
"swiper": "^14.0.2",
|
||||||
"tailwindcss": "^4.3.2"
|
"tailwindcss": "^4.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
|||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
import { IconPencil } from "@tabler/icons-react";
|
import { IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytes } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
|||||||
{file.name}
|
{file.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||||
{humanFileSize(file.size)}
|
{formatBytes(file.size)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytes } from "@homarr/common";
|
||||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { createLocalImageUrl } from "@homarr/icons/local";
|
import { createLocalImageUrl } from "@homarr/icons/local";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
|
|||||||
/>
|
/>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>{media.name}</TableTd>
|
<TableTd>{media.name}</TableTd>
|
||||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
<TableTd>{formatBytes(media.size)}</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
{media.creator ? (
|
{media.creator ? (
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||||
import type { ContainerState } from "@homarr/docker";
|
import type { ContainerState } from "@homarr/docker";
|
||||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||||
{humanFileSize(bytesUsage)}
|
{formatBytes(bytesUsage)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-4
@@ -6,6 +6,10 @@
|
|||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
"clean": "git clean -xdf node_modules",
|
"clean": "git clean -xdf node_modules",
|
||||||
"clean:workspaces": "turbo clean",
|
"clean:workspaces": "turbo clean",
|
||||||
|
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||||
|
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
|
||||||
|
"ci:act:docker": "bash scripts/ci/act.sh docker",
|
||||||
|
"ci:act:fast": "bash scripts/ci/act.sh fast",
|
||||||
"cli": "pnpm with-env tsx packages/cli/index.ts",
|
"cli": "pnpm with-env tsx packages/cli/index.ts",
|
||||||
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
||||||
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
|
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
|
||||||
@@ -33,6 +37,9 @@
|
|||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"with-env": "dotenv -e .env --"
|
"with-env": "dotenv -e .env --"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jsonpath-plus": "catalog:"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "catalog:",
|
"@playwright/test": "catalog:",
|
||||||
"@semantic-release/changelog": "catalog:",
|
"@semantic-release/changelog": "catalog:",
|
||||||
@@ -64,8 +71,5 @@
|
|||||||
"node": ">=24.18.0",
|
"node": ">=24.18.0",
|
||||||
"pnpm": ">=11.10.0"
|
"pnpm": ">=11.10.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.10.0",
|
"packageManager": "pnpm@11.10.0"
|
||||||
"dependencies": {
|
|
||||||
"jsonpath-plus": "catalog:"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of bytes to si format. (Division by 1024)
|
* Number of bytes to a human-readable string using binary (KiB) suffixes.
|
||||||
* Does not accept floats, size in bytes should be an integer.
|
* Does not accept floats; size in bytes should be an integer.
|
||||||
* Will return "NaI" and logs a warning if a float is passed.
|
* Will return "NaI" and log a warning if a float is passed.
|
||||||
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
|
* `concat` is appended after the unit so it is omitted when the returned
|
||||||
* Returns "∞" if the size is too large to be represented in the current format.
|
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
|
||||||
|
* represented in the current format.
|
||||||
|
*
|
||||||
|
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
|
||||||
|
* paired "used / total" displays, and `formatByteRate` for byte-per-second
|
||||||
|
* rates. This function is kept for backwards compatibility but should not
|
||||||
|
* be used in new code.
|
||||||
*/
|
*/
|
||||||
export const humanFileSize = (size: number, concat = ""): string => {
|
export const humanFileSize = (size: number, concat = ""): string => {
|
||||||
//64bit limit for Number stops at EiB
|
//64bit limit for Number stops at EiB
|
||||||
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
|
|||||||
return "∞";
|
return "∞";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
|
||||||
|
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
|
||||||
|
|
||||||
|
export type ByteUnitSystem = "binary" | "decimal";
|
||||||
|
|
||||||
|
export interface FormatBytesOptions {
|
||||||
|
/**
|
||||||
|
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
|
||||||
|
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
|
||||||
|
*/
|
||||||
|
unit?: ByteUnitSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
||||||
|
let index = 0;
|
||||||
|
let value = bytes;
|
||||||
|
while (value >= base && index < lastIndex) {
|
||||||
|
value /= base;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
|
||||||
|
|
||||||
|
const resolveUnitConfig = (options: FormatBytesOptions) => {
|
||||||
|
const { unit = "binary" } = options;
|
||||||
|
return {
|
||||||
|
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
|
||||||
|
base: unit === "binary" ? 1024 : 1000,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a byte value as a human-readable string with a unit suffix.
|
||||||
|
*
|
||||||
|
* Picks the largest unit that keeps the scaled value below the base. Use
|
||||||
|
* `formatBytesPair` when two related values (e.g. used and available) should
|
||||||
|
* share a common unit.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatBytes(0); // "0.0 B"
|
||||||
|
* formatBytes(1024); // "1.0 KiB"
|
||||||
|
* formatBytes(985828802560); // "918.1 GiB"
|
||||||
|
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
|
||||||
|
*/
|
||||||
|
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
|
||||||
|
const { units, base } = resolveUnitConfig(options);
|
||||||
|
const safe = sanitizeBytes(bytes);
|
||||||
|
const index = pickUnitIndex(safe, base, units.length - 1);
|
||||||
|
const scaled = safe / base ** index;
|
||||||
|
return `${scaled.toFixed(1)} ${units[index]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format two related byte values (typically `used` and `available`) with a
|
||||||
|
* shared unit. The unit is picked from the sum of the two values so the
|
||||||
|
* combined display stays consistent.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatBytesPair(985828802560, 2858736793190);
|
||||||
|
* // { used: "0.9 TiB", available: "2.6 TiB" }
|
||||||
|
*/
|
||||||
|
export const formatBytesPair = (
|
||||||
|
used: number,
|
||||||
|
available: number,
|
||||||
|
options: FormatBytesOptions = {},
|
||||||
|
): { used: string; available: string } => {
|
||||||
|
const { units, base } = resolveUnitConfig(options);
|
||||||
|
const safeUsed = sanitizeBytes(used);
|
||||||
|
const safeAvailable = sanitizeBytes(available);
|
||||||
|
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
|
||||||
|
const suffix = units[index];
|
||||||
|
return {
|
||||||
|
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
|
||||||
|
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
|
||||||
|
* string with a `/s` suffix. The value is formatted with the same rules as
|
||||||
|
* `formatBytes` and a trailing `/s` is appended.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatByteRate(0); // "0.0 B/s"
|
||||||
|
* formatByteRate(1024); // "1.0 KiB/s"
|
||||||
|
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
|
||||||
|
*/
|
||||||
|
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
|
||||||
|
`${formatBytes(bytes, options)}/s`;
|
||||||
|
|
||||||
const IMPERIAL_MULTIPLIER = 1.609344;
|
const IMPERIAL_MULTIPLIER = 1.609344;
|
||||||
|
|
||||||
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
|
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -379,7 +379,7 @@ export const integrationDefs = {
|
|||||||
category: ["healthMonitoring"],
|
category: ["healthMonitoring"],
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
|
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
|
||||||
defaultPort: 3413,
|
defaultPort: 3413,
|
||||||
apiKeySettingsPath: "/settings/api-keys",
|
apiKeySettingsPath: "/settings/integrations",
|
||||||
},
|
},
|
||||||
tracearr: {
|
tracearr: {
|
||||||
name: "Tracearr",
|
name: "Tracearr",
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ describe("getIntegrationApiKeyUrl", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds URL for patchmon", () => {
|
||||||
|
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
|
||||||
|
"http://patchmon.local:3413/settings/integrations",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null for integrations without apiKeySettingsPath", () => {
|
it("returns null for integrations without apiKeySettingsPath", () => {
|
||||||
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
|
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { humanFileSize } from "@homarr/common";
|
|
||||||
|
|
||||||
import "@homarr/redis";
|
import "@homarr/redis";
|
||||||
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
|||||||
.map((storage, index) => ({
|
.map((storage, index) => ({
|
||||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
used: humanFileSize(storageLoad[index]!),
|
used: `${storageLoad[index]!}`,
|
||||||
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
|
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
|
||||||
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
|
|||||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||||
const type = "torrent";
|
const type = "torrent";
|
||||||
const client = await this.getClientAsync();
|
const client = await this.getClientAsync();
|
||||||
const torrents = await client.listTorrents({ limit: input.limit });
|
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
|
||||||
const rates = torrents.reduce(
|
const rates = torrents.reduce(
|
||||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||||
{ down: 0, up: 0 },
|
{ down: 0, up: 0 },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import type { fetch as undiciFetch } from "undici/types/fetch";
|
import type { fetch as undiciFetch } from "undici/types/fetch";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
|
||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
@@ -59,7 +58,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
|||||||
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
||||||
fileSystem: systemInfo.array.disks.map((disk) => ({
|
fileSystem: systemInfo.array.disks.map((disk) => ({
|
||||||
deviceName: disk.name,
|
deviceName: disk.name,
|
||||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
|
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
|
||||||
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
|
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
|
||||||
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
|
|||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
return instance.listNotesAsync(input);
|
return instance.listNotesAsync(input);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const anchorNoteRequestHandler = createIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>({
|
export const anchorNoteRequestHandler = createIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>({
|
||||||
@@ -19,4 +20,5 @@ export const anchorNoteRequestHandler = createIntegrationRequestHandler<AnchorNo
|
|||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
return instance.getNoteAsync(input.noteId);
|
return instance.getNoteAsync(input.noteId);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 15_000,
|
||||||
});
|
});
|
||||||
@@ -13,4 +13,5 @@ export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getStatusAsync();
|
return await integrationInstance.getStatusAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
@@ -12,4 +12,5 @@ export const audiobookshelfRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getDashboardDataAsync();
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 600_000,
|
||||||
});
|
});
|
||||||
@@ -23,4 +23,5 @@ export const calendarMonthRequestHandler = createIntegrationRequestHandler<
|
|||||||
input.showUnmonitored,
|
input.showUnmonitored,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
});
|
});
|
||||||
@@ -12,4 +12,5 @@ export const coolifyRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getInstanceInfoAsync();
|
return await integrationInstance.getInstanceInfoAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 30_000,
|
||||||
});
|
});
|
||||||
@@ -13,4 +13,5 @@ export const dnsHoleRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getSummaryAsync();
|
return await integrationInstance.getSummaryAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 5_000,
|
||||||
});
|
});
|
||||||
@@ -177,6 +177,7 @@ export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
|||||||
}
|
}
|
||||||
return await getContainersWithStatsAsync();
|
return await getContainersWithStatsAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 20_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
|
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export const downloadClientRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getClientJobsAndStatusAsync(input);
|
return await integrationInstance.getClientJobsAndStatusAsync(input);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 5_000,
|
||||||
});
|
});
|
||||||
@@ -69,4 +69,5 @@ export const duckDuckGoBangsRequestHandler = createRequestHandler({
|
|||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 86_400_000,
|
||||||
});
|
});
|
||||||
@@ -18,6 +18,7 @@ export const firewallCpuRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return integrationInstance.getFirewallCpuAsync();
|
return integrationInstance.getFirewallCpuAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
|
export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -29,6 +30,7 @@ export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getFirewallMemoryAsync();
|
return await integrationInstance.getFirewallMemoryAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
|
export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -40,6 +42,7 @@ export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getFirewallInterfacesAsync();
|
return await integrationInstance.getFirewallInterfacesAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const firewallVersionRequestHandler = createIntegrationRequestHandler<
|
export const firewallVersionRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -51,4 +54,5 @@ export const firewallVersionRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getFirewallVersionAsync();
|
return await integrationInstance.getFirewallVersionAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 3_600_000,
|
||||||
});
|
});
|
||||||
@@ -13,6 +13,7 @@ export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getSystemInfoAsync();
|
return await integrationInstance.getSystemInfoAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const clusterInfoRequestHandler = createIntegrationRequestHandler<
|
export const clusterInfoRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -24,4 +25,5 @@ export const clusterInfoRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getClusterInfoAsync();
|
return await integrationInstance.getClusterInfoAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 5_000,
|
||||||
});
|
});
|
||||||
@@ -13,6 +13,7 @@ export const immichStatsRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getServerStatsAsync();
|
return await integrationInstance.getServerStatsAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 900_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -28,6 +29,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getAlbumsAsync();
|
return await integrationInstance.getAlbumsAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 900_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -39,4 +41,5 @@ export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getAlbumAsync(input.albumId);
|
return await integrationInstance.getAlbumAsync(input.albumId);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 900_000,
|
||||||
});
|
});
|
||||||
@@ -13,4 +13,5 @@ export const indexerManagerRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getIndexersAsync();
|
return await integrationInstance.getIndexersAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import { createRequestHandler } from "./request-handler";
|
|||||||
|
|
||||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||||
requestAsync: (input: TInput) => Promise<TData>;
|
requestAsync: (input: TInput) => Promise<TData>;
|
||||||
|
cacheTtlMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||||
@@ -10,5 +11,6 @@ export const createWidgetRequestHandler = <TData, TInput extends Record<string,
|
|||||||
handler: (widgetOptions: TInput) =>
|
handler: (widgetOptions: TInput) =>
|
||||||
createRequestHandler<TData, TInput>({
|
createRequestHandler<TData, TInput>({
|
||||||
requestAsync: options.requestAsync,
|
requestAsync: options.requestAsync,
|
||||||
|
cacheTtlMs: options.cacheTtlMs,
|
||||||
}).handler(widgetOptions),
|
}).handler(widgetOptions),
|
||||||
});
|
});
|
||||||
@@ -13,4 +13,5 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getMediaReleasesAsync();
|
return await integrationInstance.getMediaReleasesAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
@@ -16,4 +16,5 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
|||||||
users: await integrationInstance.getUsersAsync(),
|
users: await integrationInstance.getUsersAsync(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
});
|
});
|
||||||
@@ -15,4 +15,5 @@ export const mediaServerRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying });
|
return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying });
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 5_000,
|
||||||
});
|
});
|
||||||
@@ -17,6 +17,7 @@ export const mediaTranscodingRequestHandler = createIntegrationRequestHandler<
|
|||||||
statistics: await integrationInstance.getStatisticsAsync(),
|
statistics: await integrationInstance.getStatisticsAsync(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface MediaTranscoding {
|
export interface MediaTranscoding {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({
|
|||||||
);
|
);
|
||||||
return responseSchema.parse(await response.json());
|
return responseSchema.parse(await response.json());
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseSchema = z
|
const responseSchema = z
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export const navidromeRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getDashboardDataAsync();
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 600_000,
|
||||||
});
|
});
|
||||||
@@ -13,4 +13,5 @@ export const networkControllerRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getNetworkSummaryAsync();
|
return await integrationInstance.getNetworkSummaryAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
@@ -13,4 +13,5 @@ export const notificationsRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getNotificationsAsync();
|
return await integrationInstance.getNotificationsAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
@@ -12,4 +12,5 @@ export const paperlessNgxStatsRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getStatsAsync();
|
return await integrationInstance.getStatsAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 900_000,
|
||||||
});
|
});
|
||||||
@@ -28,6 +28,7 @@ export const rssFeedsRequestHandler = createWidgetRequestHandler({
|
|||||||
entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [],
|
entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
|
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
|
|||||||
|
|
||||||
return result.data.state;
|
return result.data.state;
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
});
|
});
|
||||||
@@ -12,4 +12,5 @@ export const speedtestTrackerRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getDashboardDataAsync();
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 600_000,
|
||||||
});
|
});
|
||||||
@@ -39,6 +39,7 @@ export const fetchStockPriceHandler = createWidgetRequestHandler({
|
|||||||
shortName: firstResult.meta.shortName,
|
shortName: firstResult.meta.shortName,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataSchema = z
|
const dataSchema = z
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export const tracearrRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getDashboardDataAsync();
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 5_000,
|
||||||
});
|
});
|
||||||
@@ -12,6 +12,7 @@ export const umamiRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
|
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const umamiEventNamesRequestHandler = createIntegrationRequestHandler<string[], "umami", { websiteId: string }>({
|
export const umamiEventNamesRequestHandler = createIntegrationRequestHandler<string[], "umami", { websiteId: string }>({
|
||||||
@@ -19,6 +20,7 @@ export const umamiEventNamesRequestHandler = createIntegrationRequestHandler<str
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getEventNamesAsync(input.websiteId);
|
return await integrationInstance.getEventNamesAsync(input.websiteId);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const umamiTopPagesRequestHandler = createIntegrationRequestHandler<
|
export const umamiTopPagesRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -30,6 +32,7 @@ export const umamiTopPagesRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
|
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler<
|
export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -41,6 +44,7 @@ export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
|
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const umamiMultiEventRequestHandler = createIntegrationRequestHandler<
|
export const umamiMultiEventRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -52,6 +56,7 @@ export const umamiMultiEventRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
|
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler<
|
export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler<
|
||||||
@@ -63,4 +68,5 @@ export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
|
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 30_000,
|
||||||
});
|
});
|
||||||
@@ -13,4 +13,5 @@ export const upsSummariesRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getUpsSummariesAsync();
|
return await integrationInstance.getUpsSummariesAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
});
|
});
|
||||||
@@ -12,4 +12,5 @@ export const uptimeKumaRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getDashboardDataAsync();
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
});
|
});
|
||||||
@@ -25,4 +25,5 @@ export const vpnSummaryHandler = createIntegrationRequestHandler<
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 300_000,
|
||||||
});
|
});
|
||||||
@@ -31,6 +31,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
|
|||||||
}),
|
}),
|
||||||
} satisfies Weather;
|
} satisfies Weather;
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const atLocationOutput = z.object({
|
const atLocationOutput = z.object({
|
||||||
|
|||||||
@@ -1956,6 +1956,8 @@
|
|||||||
"clear": "Clear formatting",
|
"clear": "Clear formatting",
|
||||||
"heading": "Heading {level}",
|
"heading": "Heading {level}",
|
||||||
"align": "Align text: {position}",
|
"align": "Align text: {position}",
|
||||||
|
"directionLtr": "Set text direction left to right",
|
||||||
|
"directionRtl": "Set text direction right to left",
|
||||||
"blockquote": "Blockquote",
|
"blockquote": "Blockquote",
|
||||||
"horizontalLine": "Horizontal line",
|
"horizontalLine": "Horizontal line",
|
||||||
"bulletList": "Bullet list",
|
"bulletList": "Bullet list",
|
||||||
@@ -5203,6 +5205,9 @@
|
|||||||
"title": "Containers",
|
"title": "Containers",
|
||||||
"table": {
|
"table": {
|
||||||
"updated": "Updated {when}",
|
"updated": "Updated {when}",
|
||||||
|
"refresh": {
|
||||||
|
"lastUpdated": "Last refresh {when}"
|
||||||
|
},
|
||||||
"search": "Seach {count} containers",
|
"search": "Seach {count} containers",
|
||||||
"selected": "{selectCount} of {totalCount} containers selected",
|
"selected": "{selectCount} of {totalCount} containers selected",
|
||||||
"footer": "Total {count} containers",
|
"footer": "Total {count} containers",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatByteRate } from "@homarr/common";
|
||||||
import { getIconUrl } from "@homarr/definitions";
|
import { getIconUrl } from "@homarr/definitions";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
|
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import type { IconProps } from "@tabler/icons-react";
|
import type { IconProps } from "@tabler/icons-react";
|
||||||
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
|
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
|
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||||
import type { ContainerState } from "@homarr/docker";
|
import type { ContainerState } from "@homarr/docker";
|
||||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
@@ -110,7 +110,7 @@ const createColumns = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||||
{humanFileSize(bytesUsage)}
|
{formatBytes(bytesUsage)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -184,7 +184,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
|||||||
const t = useScopedI18n("docker");
|
const t = useScopedI18n("docker");
|
||||||
const isTiny = width <= 256;
|
const isTiny = width <= 256;
|
||||||
|
|
||||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
const {
|
||||||
|
data,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
} = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const containers = data?.containers ?? [];
|
const containers = data?.containers ?? [];
|
||||||
@@ -297,12 +301,23 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
|
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Tooltip
|
||||||
{t("table.updated", { when: relativeTime })}
|
label={t("table.refresh.lastUpdated", { when: relativeTime })}
|
||||||
</Text>
|
>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="transparent"
|
||||||
|
c="var(--mantine-color-text)"
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
|
||||||
|
>
|
||||||
|
<IconRefresh style={actionIconIconStyle} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const columnTranslationKeyMap = {
|
|||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
|
queryKey: [["docker", "getContainers"]],
|
||||||
createOptions() {
|
createOptions() {
|
||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from((factory) => ({
|
||||||
columns: factory.multiSelect({
|
columns: factory.multiSelect({
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||||
import { humanFileSize, useIntegrationConnected } from "@homarr/common";
|
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
|
||||||
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
|
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -386,7 +386,7 @@ export default function DownloadClientsWidget({
|
|||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
Cell: ({ cell }) => {
|
Cell: ({ cell }) => {
|
||||||
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
|
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
|
||||||
return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null;
|
return downSpeed ? <Text size="xs">{formatByteRate(downSpeed)}</Text> : null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -466,7 +466,7 @@ export default function DownloadClientsWidget({
|
|||||||
...columnsDefBase({ key: "received", showHeader: true }),
|
...columnsDefBase({ key: "received", showHeader: true }),
|
||||||
Cell: ({ cell }) => {
|
Cell: ({ cell }) => {
|
||||||
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
|
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
|
||||||
return <Text size="xs">{humanFileSize(received)}</Text>;
|
return <Text size="xs">{formatBytes(received)}</Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -474,14 +474,14 @@ export default function DownloadClientsWidget({
|
|||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
Cell: ({ cell }) => {
|
Cell: ({ cell }) => {
|
||||||
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
|
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
|
||||||
return sent && <Text size="xs">{humanFileSize(sent)}</Text>;
|
return sent && <Text size="xs">{formatBytes(sent)}</Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...columnsDefBase({ key: "size", showHeader: true }),
|
...columnsDefBase({ key: "size", showHeader: true }),
|
||||||
Cell: ({ cell }) => {
|
Cell: ({ cell }) => {
|
||||||
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
|
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
|
||||||
return <Text size="xs">{humanFileSize(size)}</Text>;
|
return <Text size="xs">{formatBytes(size)}</Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -521,7 +521,7 @@ export default function DownloadClientsWidget({
|
|||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
Cell: ({ cell }) => {
|
Cell: ({ cell }) => {
|
||||||
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
|
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
|
||||||
return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>;
|
return upSpeed && <Text size="xs">{formatByteRate(upSpeed)}</Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -669,21 +669,21 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
|
|||||||
{item.type !== "miscellaneous" && (
|
{item.type !== "miscellaneous" && (
|
||||||
<NormalizedLine
|
<NormalizedLine
|
||||||
itemKey="upSpeed"
|
itemKey="upSpeed"
|
||||||
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
|
values={item.upSpeed === undefined ? undefined : formatByteRate(item.upSpeed)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<NormalizedLine
|
<NormalizedLine
|
||||||
itemKey="downSpeed"
|
itemKey="downSpeed"
|
||||||
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
|
values={item.downSpeed === undefined ? undefined : formatByteRate(item.downSpeed)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{item.type !== "miscellaneous" && (
|
{item.type !== "miscellaneous" && (
|
||||||
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
|
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : formatBytes(item.sent)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
|
<NormalizedLine itemKey="received" values={formatBytes(item.received)} />
|
||||||
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
|
<NormalizedLine itemKey="size" values={formatBytes(item.size)} />
|
||||||
<NormalizedLine
|
<NormalizedLine
|
||||||
itemKey="progress"
|
itemKey="progress"
|
||||||
values={new Intl.NumberFormat("en", {
|
values={new Intl.NumberFormat("en", {
|
||||||
@@ -744,10 +744,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
|
|||||||
{ paused: [] as string[], active: [] as string[] },
|
{ paused: [] as string[], active: [] as string[] },
|
||||||
);
|
);
|
||||||
const someInteract = clients.some(({ interact }) => interact);
|
const someInteract = clients.some(({ interact }) => interact);
|
||||||
const totalSpeed = humanFileSize(
|
const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0));
|
||||||
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
|
|
||||||
"/s",
|
|
||||||
);
|
|
||||||
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
|
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
|
||||||
@@ -849,21 +846,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
|
|||||||
{client.status.rates.up !== undefined ? (
|
{client.status.rates.up !== undefined ? (
|
||||||
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
|
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
|
||||||
<Text flex={1} ta="right">
|
<Text flex={1} ta="right">
|
||||||
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
|
{`↑ ${formatByteRate(client.status.rates.up)}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Text>{"-"}</Text>
|
<Text>{"-"}</Text>
|
||||||
<Text flex={1} ta="left">
|
<Text flex={1} ta="left">
|
||||||
{humanFileSize(client.status.totalUp ?? 0)}
|
{formatBytes(client.status.totalUp ?? 0)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
|
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
|
||||||
<Text flex={1} ta="right">
|
<Text flex={1} ta="right">
|
||||||
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
|
{`↓ ${formatByteRate(client.status.rates.down)}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Text>{"-"}</Text>
|
<Text>{"-"}</Text>
|
||||||
<Text flex={1} ta="left">
|
<Text flex={1} ta="left">
|
||||||
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
|
{formatBytes(Math.floor(client.status.totalDown ?? 0))}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import duration from "dayjs/plugin/duration";
|
import duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
import { capitalize, humanFileSize } from "@homarr/common";
|
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
|
||||||
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
|
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -93,16 +93,18 @@ const RightSection = ({ label, value }: RightSectionProps) => {
|
|||||||
|
|
||||||
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
||||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||||
|
const memory = formatBytesPair(item.memory.used, item.memory.total);
|
||||||
|
const storage = formatBytesPair(item.storage.used, item.storage.total);
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<List.Item icon={<IconCpu size={16} />}>
|
<List.Item icon={<IconCpu size={16} />}>
|
||||||
{t("cpu")} - {item.cpu.cores}
|
{t("cpu")} - {item.cpu.cores}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item icon={<IconBrain size={16} />}>
|
<List.Item icon={<IconBrain size={16} />}>
|
||||||
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
|
{t("memory")} - {memory.used} / {memory.available}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item icon={<IconDatabase size={16} />}>
|
<List.Item icon={<IconDatabase size={16} />}>
|
||||||
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
|
{t("storage")} - {storage.used} / {storage.available}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item icon={<IconClockHour3 size={16} />}>
|
<List.Item icon={<IconClockHour3 size={16} />}>
|
||||||
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
|
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
|
||||||
@@ -121,6 +123,7 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
|||||||
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
|
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
|
||||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||||
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
|
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
|
||||||
|
const storage = formatBytesPair(item.used, item.total);
|
||||||
return (
|
return (
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Center>
|
<Center>
|
||||||
@@ -133,7 +136,7 @@ const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
|
|||||||
/>
|
/>
|
||||||
<Group align="center" gap={0}>
|
<Group align="center" gap={0}>
|
||||||
<Text>
|
<Text>
|
||||||
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
|
{t("storage")} - {storage.used} / {storage.available}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -152,11 +155,11 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
|
|||||||
<List.Item icon={<IconDatabase size={16} />}>
|
<List.Item icon={<IconDatabase size={16} />}>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<Text>{humanFileSize(item.storage.write)}</Text>
|
<Text>{formatBytes(item.storage.write)}</Text>
|
||||||
<IconArrowNarrowDown size={14} />
|
<IconArrowNarrowDown size={14} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<Text>{humanFileSize(item.storage.read)}</Text>
|
<Text>{formatBytes(item.storage.read)}</Text>
|
||||||
<IconArrowNarrowUp size={14} />
|
<IconArrowNarrowUp size={14} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -172,11 +175,11 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
|
|||||||
<List.Item icon={<IconNetwork size={16} />}>
|
<List.Item icon={<IconNetwork size={16} />}>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<Text>{humanFileSize(item.network.in)}</Text>
|
<Text>{formatBytes(item.network.in)}</Text>
|
||||||
<IconArrowNarrowDown size={14} />
|
<IconArrowNarrowDown size={14} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<Text>{humanFileSize(item.network.out)}</Text>
|
<Text>{formatBytes(item.network.out)}</Text>
|
||||||
<IconArrowNarrowUp size={14} />
|
<IconArrowNarrowUp size={14} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import duration from "dayjs/plugin/duration";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytes } from "@homarr/common";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ export const progressColor = (percentage: number) => {
|
|||||||
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
|
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
|
||||||
const formatFileSize = (value: string) => {
|
const formatFileSize = (value: string) => {
|
||||||
const bytes = Number(value);
|
const bytes = Number(value);
|
||||||
return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value;
|
return Number.isFinite(bytes) ? formatBytes(Math.round(bytes)) : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FileSystem {
|
interface FileSystem {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Group, Stack, Text } from "@mantine/core";
|
|||||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytes } from "@homarr/common";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
@@ -46,7 +46,7 @@ export default function ImmichServerStatsWidget({
|
|||||||
<StatItem
|
<StatItem
|
||||||
icon={<IconDatabase size={20} />}
|
icon={<IconDatabase size={20} />}
|
||||||
label={t("widget.immich-serverStats.storage")}
|
label={t("widget.immich-serverStats.storage")}
|
||||||
value={humanFileSize(stats.totalLibraryUsageInBytes)}
|
value={formatBytes(stats.totalLibraryUsageInBytes)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytes } from "@homarr/common";
|
||||||
import type { TdarrQueue } from "@homarr/integrations";
|
import type { TdarrQueue } from "@homarr/integrations";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ export function QueuePanel(props: QueuePanelProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd py={2}>
|
<TableTd py={2}>
|
||||||
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
|
<Text size="xs">{formatBytes(item.fileSize)}</Text>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
|
|||||||
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytes } from "@homarr/common";
|
||||||
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
|
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
@@ -41,7 +41,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
|
|||||||
<StatisticItem
|
<StatisticItem
|
||||||
icon={IconDatabaseHeart}
|
icon={IconDatabaseHeart}
|
||||||
label={t("savedSpace")}
|
label={t("savedSpace")}
|
||||||
value={humanFileSize(Math.floor(allLibs.totalSavedSpace))}
|
value={formatBytes(Math.floor(allLibs.totalSavedSpace))}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="center" wrap="wrap" grow>
|
<Group justify="center" wrap="wrap" grow>
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
IconRowRemove,
|
IconRowRemove,
|
||||||
IconTableOff,
|
IconTableOff,
|
||||||
IconTablePlus,
|
IconTablePlus,
|
||||||
|
IconTextDirectionLtr,
|
||||||
|
IconTextDirectionRtl,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
@@ -66,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { NotebookTextDirection, setTextDirection } from "./text-direction";
|
||||||
|
|
||||||
import "@mantine/tiptap/styles.css";
|
import "@mantine/tiptap/styles.css";
|
||||||
import "./notebook.css";
|
import "./notebook.css";
|
||||||
@@ -171,6 +174,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
|||||||
}),
|
}),
|
||||||
TaskList.configure({ itemTypeName: "taskItem" }),
|
TaskList.configure({ itemTypeName: "taskItem" }),
|
||||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||||
|
NotebookTextDirection,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
],
|
],
|
||||||
shouldRerenderOnTransaction: true,
|
shouldRerenderOnTransaction: true,
|
||||||
@@ -347,6 +351,22 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
|||||||
position: t("widget.notebook.align.right"),
|
position: t("widget.notebook.align.right"),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
<RichTextEditor.Control
|
||||||
|
title={tControls("directionLtr")}
|
||||||
|
aria-label={tControls("directionLtr")}
|
||||||
|
active={editor?.isActive({ dir: "ltr" })}
|
||||||
|
onClick={() => editor && setTextDirection(editor, "ltr").run()}
|
||||||
|
>
|
||||||
|
<IconTextDirectionLtr {...controlIconProps} />
|
||||||
|
</RichTextEditor.Control>
|
||||||
|
<RichTextEditor.Control
|
||||||
|
title={tControls("directionRtl")}
|
||||||
|
aria-label={tControls("directionRtl")}
|
||||||
|
active={editor?.isActive({ dir: "rtl" })}
|
||||||
|
onClick={() => editor && setTextDirection(editor, "rtl").run()}
|
||||||
|
>
|
||||||
|
<IconTextDirectionRtl {...controlIconProps} />
|
||||||
|
</RichTextEditor.Control>
|
||||||
</RichTextEditor.ControlsGroup>
|
</RichTextEditor.ControlsGroup>
|
||||||
|
|
||||||
<RichTextEditor.ControlsGroup>
|
<RichTextEditor.ControlsGroup>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { NotebookTextDirection, setTextDirection } from "./text-direction";
|
||||||
|
|
||||||
|
describe("notebook text direction", () => {
|
||||||
|
it("persists right-to-left direction in the notebook HTML", () => {
|
||||||
|
const editor = new Editor({
|
||||||
|
content: "<p>יש לי 16GB</p>",
|
||||||
|
extensions: [StarterKit, NotebookTextDirection],
|
||||||
|
});
|
||||||
|
|
||||||
|
setTextDirection(editor, "rtl").run();
|
||||||
|
|
||||||
|
expect(editor.getHTML()).toBe('<p dir="rtl">יש לי 16GB</p>');
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads and changes an existing direction attribute", () => {
|
||||||
|
const editor = new Editor({
|
||||||
|
content: '<h2 dir="rtl">Heading</h2>',
|
||||||
|
extensions: [StarterKit, NotebookTextDirection],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editor.getHTML()).toBe('<h2 dir="rtl">Heading</h2>');
|
||||||
|
|
||||||
|
editor.commands.setTextSelection(1);
|
||||||
|
setTextDirection(editor, "ltr").run();
|
||||||
|
|
||||||
|
expect(editor.getHTML()).toContain('<h2 dir="ltr">Heading</h2>');
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Extension } from "@tiptap/react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
|
export type TextDirection = "ltr" | "rtl";
|
||||||
|
|
||||||
|
export const NotebookTextDirection = Extension.create({
|
||||||
|
name: "notebookTextDirection",
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: ["heading", "paragraph"],
|
||||||
|
attributes: {
|
||||||
|
dir: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("dir"),
|
||||||
|
renderHTML: ({ dir }: { dir?: TextDirection }) => (dir ? { dir } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setTextDirection(editor: Editor, direction: TextDirection) {
|
||||||
|
const nodeType = editor.isActive("heading") ? "heading" : "paragraph";
|
||||||
|
return editor.chain().focus().updateAttributes(nodeType, { dir: direction });
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantin
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytesPair } from "@homarr/common";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
@@ -20,11 +20,13 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
|
|||||||
case "percentage":
|
case "percentage":
|
||||||
return `${Math.round(item.percentage)}%`;
|
return `${Math.round(item.percentage)}%`;
|
||||||
case "absolute": {
|
case "absolute": {
|
||||||
|
const usedInBytes = Number(item.used);
|
||||||
const availableInBytes = Number(item.available);
|
const availableInBytes = Number(item.available);
|
||||||
const availableText = Number.isFinite(availableInBytes)
|
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
|
||||||
? humanFileSize(Math.round(availableInBytes))
|
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
|
||||||
: item.available;
|
return `${used} / ${available}`;
|
||||||
return `${item.used} / ${availableText}`;
|
}
|
||||||
|
return `${item.used} / ${item.available}`;
|
||||||
}
|
}
|
||||||
case "free":
|
case "free":
|
||||||
return `${Math.round(100 - item.percentage)}% free`;
|
return `${Math.round(100 - item.percentage)}% free`;
|
||||||
@@ -130,16 +132,8 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
|||||||
throw new NoIntegrationDataError();
|
throw new NoIntegrationDataError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileSystem = filterStorageVolumes(
|
const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
|
||||||
rawFileSystem,
|
const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
|
||||||
options.visibleStorageVolumes,
|
|
||||||
lastItem.integrationId,
|
|
||||||
);
|
|
||||||
const smart = filterStorageVolumes(
|
|
||||||
lastItem.healthInfo.smart,
|
|
||||||
options.visibleStorageVolumes,
|
|
||||||
lastItem.integrationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fileSystem.length === 0) return <WidgetEmptyState />;
|
if (fileSystem.length === 0) return <WidgetEmptyState />;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { IconNetwork } from "@tabler/icons-react";
|
import { IconNetwork } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatByteRate } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { LabelDisplayModeOption } from "..";
|
import type { LabelDisplayModeOption } from "..";
|
||||||
@@ -47,7 +47,7 @@ export const CombinedNetworkTrafficChart = ({
|
|||||||
{payloadData.value === undefined ? (
|
{payloadData.value === undefined ? (
|
||||||
<>N/A</>
|
<>N/A</>
|
||||||
) : (
|
) : (
|
||||||
<>{humanFileSize(Math.round(Number(payloadData.value)))}/s</>
|
formatByteRate(Math.round(Number(payloadData.value)))
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Paper, Text } from "@mantine/core";
|
import { Paper, Text } from "@mantine/core";
|
||||||
import { IconBrain } from "@tabler/icons-react";
|
import { IconBrain } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatBytesPair } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { LabelDisplayModeOption } from "..";
|
import type { LabelDisplayModeOption } from "..";
|
||||||
@@ -41,11 +41,11 @@ export const SystemResourceMemoryChart = ({
|
|||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
content: ({ payload }) => {
|
content: ({ payload }) => {
|
||||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||||
|
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
|
||||||
return (
|
return (
|
||||||
<Paper px={3} py={2} shadow="md">
|
<Paper px={3} py={2} shadow="md">
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} (
|
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
|
||||||
{Math.round((value / totalCapacityInBytes) * 100)}%)
|
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Paper, Text } from "@mantine/core";
|
import { Paper, Text } from "@mantine/core";
|
||||||
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
|
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { formatByteRate } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { LabelDisplayModeOption } from "..";
|
import type { LabelDisplayModeOption } from "..";
|
||||||
@@ -32,7 +32,7 @@ export const NetworkTrafficChart = ({
|
|||||||
title={isUp ? t("up") : t("down")}
|
title={isUp ? t("up") : t("down")}
|
||||||
icon={isUp ? IconArrowUp : IconArrowDown}
|
icon={isUp ? IconArrowUp : IconArrowDown}
|
||||||
yAxisProps={{ domain: [0, upperBound] }}
|
yAxisProps={{ domain: [0, upperBound] }}
|
||||||
lastValue={`${humanFileSize(Math.round(max))}/s`}
|
lastValue={formatByteRate(Math.round(max))}
|
||||||
chartType={hasShadow ? "area" : "line"}
|
chartType={hasShadow ? "area" : "line"}
|
||||||
labelDisplayMode={labelDisplayMode}
|
labelDisplayMode={labelDisplayMode}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
@@ -41,7 +41,7 @@ export const NetworkTrafficChart = ({
|
|||||||
return (
|
return (
|
||||||
<Paper px={3} py={2} shadow="md">
|
<Paper px={3} py={2} shadow="md">
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{humanFileSize(Math.round(value))}/s
|
{formatByteRate(Math.round(value))}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+1197
-1162
File diff suppressed because it is too large.
Load diff
+34
-34
@@ -18,7 +18,7 @@ catalog:
|
|||||||
"@gitbeaker/rest": ^43.8.0
|
"@gitbeaker/rest": ^43.8.0
|
||||||
"@homarr/gridstack": ^1.12.0
|
"@homarr/gridstack": ^1.12.0
|
||||||
"@homarr/node-unifi": ^2.6.0
|
"@homarr/node-unifi": ^2.6.0
|
||||||
"@immich/sdk": ^3.0.1
|
"@immich/sdk": ^3.0.2
|
||||||
"@jellyfin/sdk": ^0.13.0
|
"@jellyfin/sdk": ^0.13.0
|
||||||
"@kubernetes/client-node": ^1.4.0
|
"@kubernetes/client-node": ^1.4.0
|
||||||
"@modelcontextprotocol/sdk": 1.29.0
|
"@modelcontextprotocol/sdk": 1.29.0
|
||||||
@@ -53,28 +53,28 @@ catalog:
|
|||||||
"@testcontainers/mysql": ^12.0.4
|
"@testcontainers/mysql": ^12.0.4
|
||||||
"@testcontainers/postgresql": ^12.0.4
|
"@testcontainers/postgresql": ^12.0.4
|
||||||
"@testcontainers/redis": ^12.0.4
|
"@testcontainers/redis": ^12.0.4
|
||||||
"@tiptap/extension-color": 3.27.1
|
"@tiptap/extension-color": 3.27.3
|
||||||
"@tiptap/extension-highlight": 3.27.1
|
"@tiptap/extension-highlight": 3.27.3
|
||||||
"@tiptap/extension-image": 3.27.1
|
"@tiptap/extension-image": 3.27.3
|
||||||
"@tiptap/extension-link": ^3.27.1
|
"@tiptap/extension-link": ^3.27.3
|
||||||
"@tiptap/extension-placeholder": ^3.27.1
|
"@tiptap/extension-placeholder": ^3.27.3
|
||||||
"@tiptap/extension-table": 3.27.1
|
"@tiptap/extension-table": 3.27.3
|
||||||
"@tiptap/extension-table-cell": 3.27.1
|
"@tiptap/extension-table-cell": 3.27.3
|
||||||
"@tiptap/extension-table-header": 3.27.1
|
"@tiptap/extension-table-header": 3.27.3
|
||||||
"@tiptap/extension-table-row": 3.27.1
|
"@tiptap/extension-table-row": 3.27.3
|
||||||
"@tiptap/extension-task-item": 3.27.1
|
"@tiptap/extension-task-item": 3.27.3
|
||||||
"@tiptap/extension-task-list": 3.27.1
|
"@tiptap/extension-task-list": 3.27.3
|
||||||
"@tiptap/extension-text-align": 3.27.1
|
"@tiptap/extension-text-align": 3.27.3
|
||||||
"@tiptap/extension-text-style": 3.27.1
|
"@tiptap/extension-text-style": 3.27.3
|
||||||
"@tiptap/extension-underline": 3.27.1
|
"@tiptap/extension-underline": 3.27.3
|
||||||
"@tiptap/react": ^3.27.1
|
"@tiptap/react": ^3.27.3
|
||||||
"@tiptap/starter-kit": ^3.27.1
|
"@tiptap/starter-kit": ^3.27.3
|
||||||
"@trpc/client": ^11.18.0
|
"@trpc/client": ^11.18.0
|
||||||
"@trpc/next": ^11.18.0
|
"@trpc/next": ^11.18.0
|
||||||
"@trpc/react-query": ^11.18.0
|
"@trpc/react-query": ^11.18.0
|
||||||
"@trpc/server": ^11.18.0
|
"@trpc/server": ^11.18.0
|
||||||
"@trpc/tanstack-react-query": ^11.18.0
|
"@trpc/tanstack-react-query": ^11.18.0
|
||||||
"@turbo/gen": ^2.10.2
|
"@turbo/gen": ^2.10.4
|
||||||
"@types/adm-zip": 0.5.8
|
"@types/adm-zip": 0.5.8
|
||||||
"@types/bcrypt": 6.0.0
|
"@types/bcrypt": 6.0.0
|
||||||
"@types/better-sqlite3": 7.6.13
|
"@types/better-sqlite3": 7.6.13
|
||||||
@@ -83,21 +83,21 @@ catalog:
|
|||||||
"@types/crypto-js": ^4.2.2
|
"@types/crypto-js": ^4.2.2
|
||||||
"@types/css-modules": ^1.0.5
|
"@types/css-modules": ^1.0.5
|
||||||
"@types/dockerode": ^4.0.1
|
"@types/dockerode": ^4.0.1
|
||||||
"@types/node": ^24.13.2
|
"@types/node": ^24.13.3
|
||||||
"@types/node-unifi": ^2.5.1
|
"@types/node-unifi": ^2.5.1
|
||||||
"@types/pg": ^8.20.0
|
"@types/pg": ^8.20.0
|
||||||
"@types/prismjs": ^1.26.6
|
"@types/prismjs": ^1.26.6
|
||||||
"@types/react": 19.2.17
|
"@types/react": 19.2.17
|
||||||
"@types/react-dom": 19.2.3
|
"@types/react-dom": 19.2.3
|
||||||
"@scalar/api-reference-react": ^0.9.51
|
"@scalar/api-reference-react": ^0.9.54
|
||||||
"@types/video.js": ^7.3.58
|
"@types/video.js": ^7.3.58
|
||||||
"@types/ws": ^8.18.1
|
"@types/ws": ^8.18.1
|
||||||
"@types/xml2js": ^0.4.14
|
"@types/xml2js": ^0.4.14
|
||||||
posthog-js: ^1.396.4
|
posthog-js: ^1.399.0
|
||||||
posthog-node: ^5.39.2
|
posthog-node: ^5.40.0
|
||||||
"@vitejs/plugin-react": ^6.0.3
|
"@vitejs/plugin-react": ^6.0.3
|
||||||
"@vitest/coverage-v8": ^4.1.9
|
"@vitest/coverage-v8": ^4.1.10
|
||||||
"@vitest/ui": ^4.1.9
|
"@vitest/ui": ^4.1.10
|
||||||
"@xterm/addon-canvas": ^0.7.0
|
"@xterm/addon-canvas": ^0.7.0
|
||||||
"@xterm/addon-fit": 0.11.0
|
"@xterm/addon-fit": 0.11.0
|
||||||
"@xterm/xterm": ^6.0.0
|
"@xterm/xterm": ^6.0.0
|
||||||
@@ -139,20 +139,20 @@ catalog:
|
|||||||
jsonpath-plus: ^10.4.0
|
jsonpath-plus: ^10.4.0
|
||||||
ldapts: 8.1.8
|
ldapts: 8.1.8
|
||||||
lodash.clonedeep: ^4.5.0
|
lodash.clonedeep: ^4.5.0
|
||||||
lucide-react: ^1.23.0
|
lucide-react: ^1.24.0
|
||||||
"mantine-contextmenu": ^9.3.1
|
"mantine-contextmenu": ^9.3.1
|
||||||
mantine-datatable: ^9.3.1
|
mantine-datatable: ^9.3.1
|
||||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||||
mcp-handler: ^1.1.0
|
mcp-handler: ^1.1.0
|
||||||
maria2: ^0.4.1
|
maria2: ^0.4.1
|
||||||
mysql2: 3.22.5
|
mysql2: 3.22.6
|
||||||
next: 16.2.10
|
next: 16.2.10
|
||||||
next-auth: 5.0.0-beta.31
|
next-auth: 5.0.0-beta.31
|
||||||
next-intl: 4.13.1
|
next-intl: 4.13.1
|
||||||
node-loader: ^2.1.0
|
node-loader: ^2.1.0
|
||||||
octokit: ^5.0.5
|
octokit: ^5.0.5
|
||||||
oxfmt: ^0.57.0
|
oxfmt: ^0.58.0
|
||||||
oxlint: ^1.72.0
|
oxlint: ^1.73.0
|
||||||
pg: ^8.22.0
|
pg: ^8.22.0
|
||||||
postcss-preset-mantine: ^1.18.0
|
postcss-preset-mantine: ^1.18.0
|
||||||
prismjs: ^1.30.0
|
prismjs: ^1.30.0
|
||||||
@@ -176,14 +176,14 @@ catalog:
|
|||||||
trpc-to-openapi: ^3.3.0
|
trpc-to-openapi: ^3.3.0
|
||||||
tsdav: ^2.3.0
|
tsdav: ^2.3.0
|
||||||
tsx: 4.20.4
|
tsx: 4.20.4
|
||||||
turbo: ^2.10.2
|
turbo: ^2.10.4
|
||||||
typescript: ^6.0.3
|
typescript: "npm:@typescript/typescript6@^6.0.2"
|
||||||
vite: ^8.1.2
|
vite: ^8.1.4
|
||||||
undici: 7.28.0
|
undici: 7.28.0
|
||||||
use-deep-compare-effect: ^1.8.1
|
use-deep-compare-effect: ^1.8.1
|
||||||
video.js: ^8.23.9
|
video.js: ^8.23.9
|
||||||
vite-tsconfig-paths: ^6.1.1
|
vite-tsconfig-paths: ^6.1.1
|
||||||
vitest: ^4.1.9
|
vitest: ^4.1.10
|
||||||
winston: 3.19.0
|
winston: 3.19.0
|
||||||
ws: ^8.21.0
|
ws: ^8.21.0
|
||||||
xml2js: ^0.6.2
|
xml2js: ^0.6.2
|
||||||
@@ -218,7 +218,7 @@ overrides:
|
|||||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
|
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
|
||||||
"esbuild@<=0.24.2": ">=0.28.1"
|
"esbuild@<=0.24.2": ">=0.28.1"
|
||||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
|
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
|
||||||
"hono@<4.6.5": ">=4.12.27"
|
"hono@<4.6.5": ">=4.12.28"
|
||||||
"linkifyjs@<4.3.2": ">=4.3.3"
|
"linkifyjs@<4.3.2": ">=4.3.3"
|
||||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
|
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
|
||||||
"prismjs@<1.30.0": ">=1.30.0"
|
"prismjs@<1.30.0": ">=1.30.0"
|
||||||
@@ -229,7 +229,7 @@ overrides:
|
|||||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.3"
|
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.3"
|
||||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.3"
|
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.3"
|
||||||
"tmp@<=0.2.3": ">=0.2.7"
|
"tmp@<=0.2.3": ">=0.2.7"
|
||||||
"vite@>=5.0.0 <=5.4.18": ">=8.1.3"
|
"vite@>=5.0.0 <=5.4.18": ">=8.1.4"
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
"@types/node-unifi": "patches/@types__node-unifi.patch"
|
"@types/node-unifi": "patches/@types__node-unifi.patch"
|
||||||
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
|
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mode="${1:-all}"
|
||||||
|
case "$mode" in
|
||||||
|
fast) job="fast-gate" ;;
|
||||||
|
docker) job="container" ;;
|
||||||
|
all) job="" ;;
|
||||||
|
cleanup) job="cleanup" ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {fast|docker|all|cleanup}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
command -v act >/dev/null 2>&1 || {
|
||||||
|
echo "act is required: https://nektosact.com/installation/index.html" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
docker info >/dev/null 2>&1 || {
|
||||||
|
echo "Docker is not running" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${DOCKER_HOST:-}" ]]; then
|
||||||
|
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)
|
||||||
|
export DOCKER_HOST
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_sha=$(git rev-parse HEAD)
|
||||||
|
base_sha=$(git rev-parse HEAD~1 2>/dev/null || echo "$head_sha")
|
||||||
|
head_ref=$(git branch --show-current)
|
||||||
|
head_ref=${head_ref:-act-local}
|
||||||
|
event_file=$(mktemp "${TMPDIR:-/tmp}/homarr-act-event.XXXXXX")
|
||||||
|
trap 'rm -f "$event_file"' EXIT
|
||||||
|
|
||||||
|
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g'; }
|
||||||
|
|
||||||
|
if [[ "$mode" == "cleanup" ]]; then
|
||||||
|
event_name="workflow_dispatch"
|
||||||
|
cat >"$event_file" <<EOF
|
||||||
|
{
|
||||||
|
"inputs": { "operation": "cleanup", "pr_number": "1", "ref": "" },
|
||||||
|
"repository": { "full_name": "homarr-labs/homarr" }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
event_name="pull_request"
|
||||||
|
safe_ref=$(json_escape "$head_ref")
|
||||||
|
cat >"$event_file" <<EOF
|
||||||
|
{
|
||||||
|
"action": "synchronize",
|
||||||
|
"number": 1,
|
||||||
|
"repository": { "full_name": "homarr-labs/homarr" },
|
||||||
|
"pull_request": {
|
||||||
|
"number": 1,
|
||||||
|
"draft": false,
|
||||||
|
"base": { "sha": "$base_sha", "ref": "dev" },
|
||||||
|
"head": {
|
||||||
|
"sha": "$head_sha",
|
||||||
|
"ref": "$safe_ref",
|
||||||
|
"repo": { "full_name": "homarr-labs/homarr" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
runner_image="${ACT_RUNNER_IMAGE:-catthehacker/ubuntu:act-latest}"
|
||||||
|
args=(
|
||||||
|
"$event_name"
|
||||||
|
--workflows .github/workflows/ci.yml
|
||||||
|
--eventpath "$event_file"
|
||||||
|
--platform "ubuntu-latest=$runner_image"
|
||||||
|
--container-architecture linux/amd64
|
||||||
|
--bind
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "$job" ]]; then
|
||||||
|
args+=(--job "$job")
|
||||||
|
fi
|
||||||
|
|
||||||
|
git_common_dir=$(cd "$(git rev-parse --git-common-dir)" && pwd)
|
||||||
|
container_options="--volume homarr-act-node-modules:$PWD/node_modules --volume homarr-act-pnpm-store:$PWD/.pnpm-store"
|
||||||
|
case "$git_common_dir" in
|
||||||
|
"$PWD" | "$PWD"/*) ;;
|
||||||
|
*) container_options="$container_options --volume $git_common_dir:$git_common_dir:ro" ;;
|
||||||
|
esac
|
||||||
|
args+=(--container-options "$container_options")
|
||||||
|
|
||||||
|
if [[ -n "${ACT_SECRET_FILE:-}" ]]; then
|
||||||
|
args+=(--secret-file "$ACT_SECRET_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec act "${args[@]}"
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turborepo.org/schema.json",
|
"$schema": "https://turborepo.org/schema.json",
|
||||||
"remoteCache": { "signature": false },
|
"remoteCache": { "signature": true },
|
||||||
"globalEnv": ["CI", "NODE_ENV", "SKIP_ENV_VALIDATION"],
|
"globalEnv": ["CI", "NODE_ENV", "SKIP_ENV_VALIDATION"],
|
||||||
"ui": "stream",
|
"ui": "stream",
|
||||||
"futureFlags": {
|
"futureFlags": {
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
|
"env": ["TURBO_PLATFORM"],
|
||||||
"inputs": [
|
"inputs": [
|
||||||
"$TURBO_DEFAULT$",
|
"$TURBO_DEFAULT$",
|
||||||
"!**/*.test.ts",
|
"!**/*.test.ts",
|
||||||
|
|||||||
Reference in new issue
Block a user