Compare commits

..
Author SHA1 Message Date
Thomas Camlong 9037da96b5 feat: rebuild workshop 2026-07-13 19:24:31 +02:00
Thomas ba7718bde5 feat: rework the ci (#6296) 2026-07-13 18:32:48 +02:00
Thomas 06f1877ae1 Merge pull request #6282 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-07-13 18:11:10 +02:00
homarr-renovate[bot] 1d9e8aabf3 chore(deps): update turbo monorepo to ^2.10.4 (#6303)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 13:36:45 +00:00
homarr-renovate[bot] b111e5285b chore(deps): update dependency @scalar/api-reference-react to ^0.9.54 (#6317)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 10:38:14 +00:00
homarr-renovate[bot] f4c491b570 chore(deps): update dependency @types/node to ^24.13.3 (#6318)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 10:37:23 +00:00
homarr-renovate[bot] 29bd4fd70b chore(deps): update dependency posthog-js to ^1.398.1 (#6311)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 18:09:44 +00:00
homarr-renovate[bot] 269dadee8d fix(deps): update dependency swiper to ^14.0.2 (#6313)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 18:09:11 +00:00
Thomas 03f68a0421 fix(qbittorrent): sort torrents by progress so active items appear before limit cutoff (#6297) 2026-07-12 14:56:24 +02:00
Bergasha 807e5eb2a9 feat(docker-widget): replace footer timestamp text with compact refresh button (#6307) 2026-07-12 14:51:56 +02:00
Thomas 8e8c29979f fix(system-disks): format absolute storage values with shared byte helper (#6299) 2026-07-12 14:50:58 +02:00
Manuel 7d859c1af0 docs: update pr template (#6147) 2026-07-12 14:49:56 +02:00
homarr-renovate[bot] e9543dc30d chore(deps): update tiptap monorepo to v3.27.3 (#6308)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 09:12:55 +00:00
Nerdy Technician 9f313440ac fix(integrations): update PatchMon API key settings link (#6302) 2026-07-12 08:59:52 +02:00
homarr-renovate[bot] c2508dffea chore(deps): update dependency posthog-js to ^1.396.7 (#6285)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 06:18:17 +00:00
Artur d44bc06a93 feat(notebook): support text direction controls (#6293) 2026-07-12 07:58:02 +02:00
homarr-renovate[bot] c56b91570b chore(deps): update tiptap monorepo to v3.27.2 (#6292)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 03:01:23 +00:00
homarr-renovate[bot] d53c4e6513 chore(deps): update dependency hono@<4.6.5 to >=4.12.28 (#6291)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 02:58:51 +00:00
homarr-renovate[bot] 0426d600cc chore(deps): update dependency oxfmt to ^0.58.0 (#6305)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 00:06:59 +00:00
github-actions[bot] 34bd574354 docs(helm): Update Helm chart documentation 2026-07-10 20:32:11 +00:00
140 changed files with 5261 additions and 12888 deletions

No files matched your search

+7
View File
@@ -6,7 +6,14 @@ npm-debug.log
README.md
.next
.turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git
.github
dev
.build
e2e
+3 -1
View File
@@ -66,4 +66,6 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
# Set to false for writable preview deployments so the demo user can modify the dashboard.
# DEMO_READ_ONLY=false
# DEMO_READ_ONLY=false
# Central Homarr Workshop API. Override only when developing the Workshop locally.
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
+2
View File
@@ -0,0 +1,2 @@
.secrets
!.secrets.example
+4
View File
@@ -0,0 +1,4 @@
TURBO_API=
TURBO_TEAM=
TURBO_TOKEN=
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
+36
View File
@@ -0,0 +1,36 @@
# Running CI locally
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
```bash
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
```
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
## PR preview images
Successful non-draft pull requests from this repository publish an amd64 image:
```bash
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
```
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
## Dokploy preview cache
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
+8
View File
@@ -11,3 +11,11 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
+48
View File
@@ -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"
+347
View File
@@ -0,0 +1,347 @@
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: ["*"]
pull_request_target:
types: [closed]
branches: ["*"]
push:
branches: [main, dev, beta, next]
merge_group:
workflow_dispatch:
inputs:
operation:
description: Operation to run
required: true
default: validate
type: choice
options:
- validate
- publish
- cleanup
pr_number:
description: PR number for publish or cleanup
required: false
type: number
ref:
description: Git ref to validate or publish
required: false
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_TELEMETRY_DISABLED: 1
REGISTRY: ghcr.io
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
jobs:
fast-gate:
name: Fast gate
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Validate manual inputs
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
env:
PR_NUMBER: ${{ inputs.pr_number }}
BUILD_REF: ${{ inputs.ref }}
run: |
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
- name: Validate workspace configuration
run: pnpm lint:ws
- name: Run unit tests
run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container:
name: Container, E2E, and memory
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: '!env.ACT'
uses: docker/setup-buildx-action@v4
- name: Build container once
if: '!env.ACT'
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
cache-from: type=gha,scope=homarr-linux-amd64
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
- name: Build container once with Act
if: env.ACT
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
run: |
secret_args=()
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
if [ -n "${!secret:-}" ]; then
secret_args+=(--secret "id=${secret},env=${secret}")
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: '!env.ACT'
id: playwright-cache
uses: actions/cache@v4
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@v4
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag
id: preview
if: |
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
env:
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: steps.preview.outputs.tag != ''
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push preview image
if: steps.preview.outputs.tag != ''
env:
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
run: |
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
- name: Show Act publication result
if: env.ACT
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
cleanup:
name: Clean up preview image
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
packages: write
env:
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Validate cleanup input
if: github.event_name == 'workflow_dispatch'
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: '!env.ACT'
uses: actions/github-script@v8
with:
script: |
const packageName = "homarr-test";
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{ package_type: "container", package_name: packageName, org, per_page: 100 },
);
const target = versions.find((version) =>
version.metadata?.container?.tags?.includes(tag),
);
if (!target) {
core.info(`No package version found with tag "${tag}"`);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
org,
package_version_id: target.id,
});
core.info(`Deleted package version ${target.id} (${tag})`);
- name: Show Act cleanup result
if: env.ACT
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
-165
View File
@@ -1,165 +0,0 @@
name: "[Quality] Code Analysis"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs:
lint-format-autofix:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Lint
run: pnpm turbo lint --affected && pnpm lint:ws
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm turbo format --affected --continue
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Test
run: pnpm test
- name: "Report Coverage"
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-e2e
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
@@ -148,6 +148,11 @@ jobs:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
build-arm64:
@@ -187,6 +192,11 @@ jobs:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
extract-asset-amd64:
+18 -11
View File
@@ -1,24 +1,31 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
RUN apk add --no-cache libc6-compat curl bash && apk update
RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && pnpm fetch
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive --offline --frozen-lockfile
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN corepack enable pnpm && pnpm build
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
FROM base AS runner
WORKDIR /app
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.21.0](https://img.shields.io/badge/Version-8.21.0-informational?style=flat)
![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.69.2](https://img.shields.io/badge/AppVersion-v1.69.2-informational?style=flat)
![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
@@ -1,39 +0,0 @@
---
title: "Open WebUI"
description: "Self-hosted AI chat interface for local and remote LLM providers"
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { openWebUiIntegration } from ".";
import { openWebUiWidget } from "../../widgets/open-webui";
<IntegrationHeader integration={openWebUiIntegration} categories={["AI", "Chat"]} />
[Open WebUI](https://openwebui.com/) is a self-hosted AI chat interface that aggregates multiple LLM providers (Ollama, OpenAI-compatible APIs, and more) behind a single API. Homarr connects to it so you can chat with your models directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: openWebUiWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["apiKey"],
steps: [],
},
]}
/>
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const openWebUiIntegration: IntegrationDefinition = {
name: "Open WebUI",
description: "Self-hosted AI chat interface for local and remote LLM providers",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
path: "../../integrations/open-webui",
};
@@ -82,6 +82,10 @@ Custom widgets can be exported and imported as JSON files. To export, select the
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
## Community Workshop
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance.
## Available Mantine Components for Custom JSX
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
@@ -0,0 +1,40 @@
---
tags:
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Workshop
The [Workshop](pathname:///workshop) is Homarr's community catalog for custom widgets and CSS themes. You can inspect every creation before downloading it or using it in Homarr.
:::warning Community content
Workshop entries are created by community members. Review widget endpoints, templates, and CSS before using them. Keep a current Homarr backup and never add secrets to an exported widget.
:::
## Discover and install
In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions.
- Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally.
- CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**.
- The public website never executes submitted widget templates or CSS.
## Share a creation
Use **Share in Workshop** beside a custom widget or in a board's custom CSS settings. Sign in with GitHub, provide a public title and description, inspect the exported source, and publish it. You can also publish from the Workshop website by pasting an exported widget or CSS source.
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
## Votes and reports
Signed-in members can vote to help useful creations rank higher. Report malicious content, spam, copyright problems, or inappropriate material from a submission's detail page. Reports are visible only to Workshop staff.
## Accounts and moderation
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded.
The Workshop is a central service operated by Homarr at `store.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace.
+40 -20
View File
@@ -1,29 +1,25 @@
---
title: 'Notebook'
description: 'A simple notebook widget that supports markdown.'
title: "Notebook"
description: "A simple notebook widget that supports markdown."
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { notebookWidget } from '.';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from ".";
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
<WidgetHeader
widget={notebookWidget}
categories={['Notes']}
/>
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
It is based on Tiptap.dev and supports most of its features.
### Screenshots
import editOff from './img/normal.png';
import editOn from './img/edit.png';
import editOff from "./img/normal.png";
import editOn from "./img/edit.png";
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={notebookWidget.configuration} />
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
![](./img/notebook-enter-editing.webp)
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions
The notebook offers a variaty of functions to edit the text and more.
The notebook offers a variety of functions to edit the text and more.
![](./img/notebook-toolbar.webp)
#### Common text formatting
| Function | Description |
| ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold |
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Text Alignment
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes
![](./img/notebook-toolbar-lists.webp)
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions
##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -102,16 +117,20 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls
![](./img/notebook-image-modal.webp)
##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
![](./img/notebook-table-modal.webp)
@@ -119,6 +138,7 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it.
##### Table controls
![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table.
@@ -134,4 +154,4 @@ All the following ones are for editing the wize of the table:
- Delete Column
- Add row on top
- Add row below
- Delete Row
- Delete Row
@@ -1,56 +0,0 @@
---
title: "AI Chat"
description: "Chat with AI models through Open WebUI, with model picking and history."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { openWebUiWidget } from ".";
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
<WidgetHeader widget={openWebUiWidget} categories={["AI", "Chat"]} />
The AI Chat widget lets you chat with AI models served by Open WebUI directly from your dashboard. Responses stream in token-by-token, you can pick any model your Open WebUI instance exposes (Ollama, OpenAI, and other configured providers), and you can browse and reopen your Open WebUI chat history. A full attachment menu lets you ground a conversation on images, uploaded or stored files, web pages, knowledge bases, notes, and previous chats.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: openWebUiIntegration,
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={openWebUiWidget.configuration} />
### Features
| Feature | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Model picker** | Lists every model exposed by Open WebUI's `/api/models` endpoint, selectable from the chat input. The first model is selected by default. |
| **Streaming responses** | Assistant replies stream in live as they are generated. |
| **Chat history** | Browse and reopen previous chats. New conversations are saved back to Open WebUI. |
| **Image attachments** | Attach or capture images for vision-capable models (sent inline as multimodal content). A hint warns when the model has no vision support. |
| **File attachments** | Upload new documents or attach files already stored in Open WebUI; their content is retrieved as context. |
| **Web pages** | Attach a URL; Open WebUI ingests the page and the relevant parts are retrieved as context. |
| **Knowledge bases** | Ground replies on any RAG knowledge collection your account owns, with an expandable picker to inspect a base's files. |
| **Notes** | Attach the contents of your Open WebUI notes. |
| **Reference chats** | Bring a previous conversation in as context for the current one. |
| **Voice messages** | Record from your microphone and have Open WebUI transcribe it into the message box (requires STT configured in Open WebUI). |
### Notes
- The widget uses a single Open WebUI integration.
- Conversations are stored in Open WebUI itself, under the account that owns the API key, so they also appear in the Open WebUI app.
- Knowledge bases and attached web pages stay active for the whole conversation until you remove them; images apply only to the message you attach them to.
- Conversation text is saved to Open WebUI history, but attached image data is not persisted.
@@ -1,25 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRobot } from "@tabler/icons-react";
export const openWebUiWidget: WidgetDefinition = {
icon: IconRobot,
name: "AI Chat",
description: "Chat with AI models through Open WebUI, with model picking and history.",
path: "../../widgets/open-webui",
configuration: {
items: [
{
name: "System prompt",
description: "Optional instruction prepended to every conversation.",
values: { type: "string" },
defaultValue: "empty",
},
{
name: "Show chat history",
description: "Show a button to browse and reopen previous Open WebUI chats.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+20
View File
@@ -16,6 +16,9 @@ const config: Config = {
defaultLocale: "en",
locales: ["en"],
},
customFields: {
workshopApiUrl: process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://store.homarr.dev",
},
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
onDuplicateRoutes: "throw",
@@ -124,6 +127,11 @@ const config: Config = {
position: "left",
to: "/blog",
},
{
label: "Workshop",
position: "left",
to: "/workshop",
},
{
label: "About us",
position: "left",
@@ -309,6 +317,18 @@ const config: Config = {
},
};
},
function workshopRoutesPlugin() {
return {
name: "workshop-routes",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
});
},
};
},
"@signalwire/docusaurus-plugin-llms-txt",
async function tailwindCssPlugin() {
return {
+2 -1
View File
@@ -27,6 +27,7 @@
"@docusaurus/plugin-sitemap": "^3.10.1",
"@docusaurus/preset-classic": "^3.10.1",
"@homarr/definitions": "workspace:^",
"@homarr/workshop": "workspace:^0.1.0",
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
@@ -43,7 +44,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.1",
"swiper": "^14.0.2",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
@@ -0,0 +1,300 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopAccountState,
type WorkshopModerationAction,
type WorkshopReport,
type WorkshopRole,
type WorkshopUser,
} from "@homarr/workshop";
import styles from "./workshop.module.css";
type PendingAction =
| { kind: "report"; id: string; status: "resolved" | "dismissed" }
| { kind: "state"; id: string; state: WorkshopAccountState }
| { kind: "role"; id: string; role: WorkshopRole };
export function WorkshopAdmin() {
const { siteConfig } = useDocusaurusContext();
const client = useMemo(
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
[siteConfig],
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [users, setUsers] = useState<WorkshopUser[]>([]);
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
const [pending, setPending] = useState<PendingAction | null>(null);
const [error, setError] = useState("");
const load = useCallback(async () => {
const current = await client.refreshAuth();
setUser(current);
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
try {
const [nextReports, nextUsers, nextActions] = await Promise.all([
client.listReports(),
client.listUsers(),
client.listModerationActions(),
]);
setReports(nextReports);
setUsers(nextUsers);
setActions(nextActions);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
}
}, [client]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void load();
}, [load]);
if (!user)
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<p>Sign in with a promoted GitHub account to continue.</p>
<button
className="button button--primary"
onClick={async () => {
await client.signInWithGitHub();
await load();
}}
>
Sign in with GitHub
</button>
</main>
);
if (user.role === "member")
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<div className={styles.error} role="alert">
Your account does not have moderation access.
</div>
</main>
);
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<h1>Workshop moderation</h1>
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
</div>
</header>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<section className={styles.adminSection}>
<h2>Open reports</h2>
{reports.length === 0 ? (
<p>No open reports.</p>
) : (
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Category</th>
<th>Submission</th>
<th>Details</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{reports.map((report) => (
<tr key={report.id}>
<td>{report.category}</td>
<td>{report.submission}</td>
<td>{report.explanation}</td>
<td>{new Date(report.created).toLocaleString()}</td>
<td aria-label="Report actions">
<div className={styles.rowActions}>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "resolved" })}>
Resolve
</button>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "dismissed" })}>
Dismiss
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className={styles.adminSection}>
<h2>Community accounts</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>State</th>
<th>Moderate</th>
</tr>
</thead>
<tbody>
{users.map((account) => (
<tr key={account.id}>
<td>{account.displayName}</td>
<td>{account.role}</td>
<td>{account.state}</td>
<td>
{account.id === user.id ? (
"Current account"
) : (
<div className={styles.rowActions}>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "posting_banned" ? "active" : "posting_banned",
})
}
>
{account.state === "posting_banned" ? "Lift posting ban" : "Posting ban"}
</button>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "disabled" ? "active" : "disabled",
})
}
>
{account.state === "disabled" ? "Restore" : "Disable"}
</button>
{user.role === "admin" && (
<button
onClick={() =>
setPending({
kind: "role",
id: account.id,
role: account.role === "member" ? "moderator" : "member",
})
}
>
{account.role === "member" ? "Promote" : "Set member"}
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={styles.adminSection}>
<h2>Recent actions</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Action</th>
<th>Target</th>
<th>Reason</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{actions.map((action) => (
<tr key={action.id}>
<td>{action.action}</td>
<td>
{action.targetType}:{action.targetId}
</td>
<td>{action.reason}</td>
<td>{new Date(action.created).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{pending && (
<ReasonDialog
action={pending}
onClose={() => setPending(null)}
onConfirm={async (reason) => {
if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason);
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
else await client.updateRole(pending.id, pending.role, reason);
setPending(null);
await load();
}}
/>
)}
</main>
);
}
function ReasonDialog({
action,
onClose,
onConfirm,
}: {
action: PendingAction;
onClose: () => void;
onConfirm: (reason: string) => Promise<void>;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="moderation-reason-title">
<h2 id="moderation-reason-title">Confirm moderation action</h2>
<p>
This will apply <strong>{action.kind}</strong> to the selected record.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setSaving(true);
try {
await onConfirm(reason);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Action failed");
setSaving(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saving} className="button button--danger">
{saving ? "Applying…" : "Apply action"}
</button>
</div>
</form>
</dialog>
</div>
);
}
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopDetailRoutePage() {
return (
<Layout title="Workshop submission">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopDetailRoute } = require("./WorkshopPage");
return <WorkshopDetailRoute />;
}}
</BrowserOnly>
</Layout>
);
}
@@ -0,0 +1,668 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "@docusaurus/Link";
import { useHistory, useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import {
IconAlertTriangle,
IconBrandGithub,
IconCopy,
IconDownload,
IconFlag,
IconSearch,
IconUpload,
} from "@tabler/icons-react";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopSubmissionDetail,
type WorkshopSubmissionInput,
type WorkshopSubmissionSummary,
type WorkshopSubmissionType,
type WorkshopUser,
workshopExportFilename,
} from "@homarr/workshop";
import styles from "./workshop.module.css";
const useWorkshop = () => {
const { siteConfig } = useDocusaurusContext();
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL;
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
};
const saveContent = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
};
export function WorkshopApp() {
const client = useWorkshop();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [mine, setMine] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
useEffect(() => client.subscribeToAuth(setUser), [client]);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
await client.refreshAuth();
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
setItems(result.items);
setTotalPages(result.totalPages);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
} finally {
setLoading(false);
}
}, [client, mine, page, search, sort, type]);
useEffect(() => {
const timeout = window.setTimeout(() => void load(), 250);
return () => window.clearTimeout(timeout);
}, [load]);
const signIn = async () => {
try {
await client.signInWithGitHub();
await load();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign-in failed");
}
};
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<h1>Workshop</h1>
<p>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
</div>
<div className={styles.accountActions}>
{user ? (
<>
{(user.role === "moderator" || user.role === "admin") && (
<Link className="button button--secondary" to="/workshop/admin">
Moderate
</Link>
)}
<button className="button button--primary" onClick={() => setShowForm(true)}>
<IconUpload size={17} /> Share creation
</button>
<button className="button button--secondary" onClick={() => client.signOut()}>
Sign out
</button>
</>
) : (
<button className="button button--primary" onClick={() => void signIn()}>
<IconBrandGithub size={18} /> Sign in with GitHub
</button>
)}
</div>
</header>
<section className={styles.safety} aria-label="Community content warning">
<IconAlertTriangle aria-hidden size={20} />
<p>
Workshop content is community-provided. Inspect source before installing it and keep a current Homarr backup.
</p>
</section>
<div className={styles.toolbar}>
<label className={styles.search}>
<span className="sr-only">Search Workshop</span>
<IconSearch aria-hidden size={18} />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="Search creations"
/>
</label>
<label>
Type
<select
value={type}
onChange={(event) => {
setType(event.target.value as typeof type);
setPage(1);
}}
>
<option value="all">All</option>
<option value="widget">Widgets</option>
<option value="css">CSS themes</option>
</select>
</label>
<label>
Sort
<select
value={sort}
onChange={(event) => {
setSort(event.target.value as typeof sort);
setPage(1);
}}
>
<option value="top">Top rated</option>
<option value="newest">Newest</option>
</select>
</label>
{user && (
<label className={styles.checkbox}>
<input
type="checkbox"
checked={mine}
onChange={(event) => {
setMine(event.target.checked);
setPage(1);
}}
/>{" "}
My submissions
</label>
)}
</div>
{error && (
<div className={styles.error} role="alert">
{error} <button onClick={() => void load()}>Try again</button>
</div>
)}
{loading ? (
<WorkshopSkeleton />
) : items.length ? (
<div className={styles.grid}>
{items.map((item) => (
<WorkshopCard client={client} item={item} key={item.id} />
))}
</div>
) : (
<section className={styles.empty}>
<h2>No creations found</h2>
<p>Adjust the filters, or share the first creation in this category.</p>
</section>
)}
{totalPages > 1 && (
<nav className={styles.pagination} aria-label="Workshop pages">
<button disabled={page === 1} onClick={() => setPage((value) => value - 1)}>
Previous
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page === totalPages} onClick={() => setPage((value) => value + 1)}>
Next
</button>
</nav>
)}
{showForm && (
<SubmissionForm
client={client}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false);
await load();
}}
/>
)}
</main>
);
}
function WorkshopCard({ client, item }: { client: WorkshopClient; item: WorkshopSubmissionSummary }) {
const image = item.screenshots[0];
return (
<article className={styles.card}>
{image ? (
<img src={client.fileUrl(item.id, image, "480x320")} alt="" loading="lazy" />
) : (
<div className={styles.cardPlaceholder} aria-hidden>
{item.type === "widget" ? "{}" : "CSS"}
</div>
)}
<div className={styles.cardBody}>
<div className={styles.cardMeta}>
<span>{item.type === "widget" ? "Widget" : "CSS theme"}</span>
<span>
{item.score >= 0 ? "+" : ""}
{item.score}
</span>
</div>
<h2>
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
</h2>
<p>{item.description || "No description provided."}</p>
<small>
By {item.authorName} · revision {item.revision}
</small>
</div>
</article>
);
}
export function WorkshopDetailRoute() {
const client = useWorkshop();
const location = useLocation();
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
const history = useHistory();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(null);
const [error, setError] = useState("");
const [reporting, setReporting] = useState(false);
const [editing, setEditing] = useState(false);
const [moderating, setModerating] = useState(false);
const load = useCallback(async () => {
try {
setSubmission(await client.get(id));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Submission not found");
}
}, [client, id]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client.refreshAuth();
void load();
}, [client, load]);
if (error)
return (
<main className={styles.page}>
<div className={styles.error} role="alert">
{error}
</div>
<Link to="/workshop">Back to Workshop</Link>
</main>
);
if (!submission) return <WorkshopSkeleton />;
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
return (
<main className={styles.page}>
<Link to="/workshop">← Back to Workshop</Link>
<article className={styles.detail}>
<header>
<span className={styles.type}>{submission.type === "widget" ? "Widget" : "CSS theme"}</span>
<h1>{submission.title}</h1>
<p>{submission.description}</p>
<small>
By {submission.authorName} · revision {submission.revision}
</small>
</header>
{submission.screenshots.length > 0 && (
<div className={styles.gallery}>
{submission.screenshots.map((image, index) => (
<img
key={image}
src={client.fileUrl(submission.id, image, "960x640")}
alt={`${submission.title} screenshot ${index + 1}`}
/>
))}
</div>
)}
<div className={styles.detailActions}>
<button className="button button--primary" onClick={() => saveContent(submission)}>
<IconDownload size={17} /> Download {submission.type === "widget" ? "JSON" : "CSS"}
</button>
<button
className="button button--secondary"
onClick={() => void navigator.clipboard.writeText(submission.content)}
>
<IconCopy size={17} /> Copy source
</button>
<button
className="button button--secondary"
onClick={async () => {
await requireAuth();
await client.vote(submission.id, 1);
await load();
}}
>
Upvote ({submission.upvotes})
</button>
<button className="button button--secondary" onClick={() => setReporting(true)}>
<IconFlag size={16} /> Report
</button>
{user?.id === submission.author && (
<>
<button className="button button--secondary" onClick={() => setEditing(true)}>
Edit
</button>
<button
className="button button--danger"
onClick={async () => {
if (window.confirm("Delete this submission permanently?")) {
await client.delete(submission.id);
history.push("/workshop");
}
}}
>
Delete
</button>
</>
)}
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
<button className="button button--danger" onClick={() => setModerating(true)}>
Remove submission
</button>
)}
</div>
<section>
<h2>Source</h2>
<p className={styles.sourceHint}>
Review this content before using it. Workshop content is never executed on homarr.dev.
</p>
<pre className={styles.source}>
<code>{submission.content}</code>
</pre>
</section>
{submission.changelog && (
<section>
<h2>Latest changes</h2>
<p>{submission.changelog}</p>
</section>
)}
</article>
{reporting && <ReportForm client={client} submissionId={submission.id} onClose={() => setReporting(false)} />}
{editing && (
<SubmissionForm
client={client}
existing={submission}
onClose={() => setEditing(false)}
onSaved={async () => {
setEditing(false);
await load();
}}
/>
)}
{moderating && (
<ModerationRemovalForm
client={client}
submission={submission}
onClose={() => setModerating(false)}
onRemoved={() => history.push("/workshop/admin")}
/>
)}
</main>
);
}
function ModerationRemovalForm({
client,
submission,
onClose,
onRemoved,
}: {
client: WorkshopClient;
submission: WorkshopSubmissionDetail;
onClose: () => void;
onRemoved: () => void;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="remove-submission-title">
<h2 id="remove-submission-title">Remove {submission.title}</h2>
<p>
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
moderation history.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setPending(true);
try {
await client.removeSubmission(submission.id, reason);
onRemoved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Removal failed");
setPending(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--danger">
{pending ? "Removing…" : "Remove permanently"}
</button>
</div>
</form>
</dialog>
</div>
);
}
function SubmissionForm({
client,
existing,
onClose,
onSaved,
}: {
client: WorkshopClient;
existing?: WorkshopSubmissionDetail;
onClose: () => void;
onSaved: () => void;
}) {
const [type, setType] = useState<WorkshopSubmissionType>(existing?.type ?? "widget");
const [title, setTitle] = useState(existing?.title ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [content, setContent] = useState(existing?.content ?? "");
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
setError("");
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
try {
if (!client.currentUser) await client.signInWithGitHub();
if (existing) {
await client.update(existing.id, input, screenshots, removedScreenshots);
} else {
await client.create(input, screenshots);
}
await onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save submission");
} finally {
setPending(false);
}
};
return (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<dialog className={styles.dialog} open aria-labelledby="submission-form-title">
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
<form onSubmit={(event) => void submit(event)}>
<label>
Type
<select
disabled={Boolean(existing)}
value={type}
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
>
<option value="widget">Custom widget</option>
<option value="css">CSS theme</option>
</select>
</label>
<label>
Title
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{type === "widget" ? "Widget JSON" : "CSS source"}
<textarea
className={styles.editor}
required
value={content}
onChange={(event) => setContent(event.target.value)}
/>
</label>
<label>
Changelog
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, up to 5)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
/>
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
</label>
))}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--primary">
{pending ? "Saving…" : existing ? "Save update" : "Publish creation"}
</button>
</div>
</form>
</dialog>
</div>
);
}
function ReportForm({
client,
submissionId,
onClose,
}: {
client: WorkshopClient;
submissionId: string;
onClose: () => void;
}) {
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
const [explanation, setExplanation] = useState("");
const [error, setError] = useState("");
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="report-title">
<h2 id="report-title">Report submission</h2>
<form
onSubmit={async (event) => {
event.preventDefault();
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.report(submissionId, category, explanation);
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not send report");
}
}}
>
<label>
Reason
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label>
Details
<textarea
required
minLength={3}
maxLength={1000}
value={explanation}
onChange={(event) => setExplanation(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button className="button button--primary">Submit report</button>
</div>
</form>
</dialog>
</div>
);
}
function WorkshopSkeleton() {
return (
<div className={styles.grid} aria-label="Loading Workshop">
{Array.from({ length: 6 }, (_, index) => (
<div className={styles.skeleton} key={index} />
))}
</div>
);
}
@@ -0,0 +1,299 @@
.page {
max-width: 1240px;
min-height: 70vh;
margin: 0 auto;
padding: 2.5rem 1.25rem 5rem;
}
.hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
margin-bottom: 1.5rem;
}
.hero h1,
.detail h1 {
margin: 0;
font-size: 2.25rem;
letter-spacing: -0.025em;
}
.hero p {
max-width: 65ch;
margin: 0.5rem 0 0;
color: var(--ifm-color-emphasis-700);
}
.accountActions,
.detailActions,
.formActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.accountActions :global(.button),
.detailActions :global(.button) {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.safety {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-warning-dark);
border-radius: 10px;
background: color-mix(in srgb, var(--ifm-color-warning) 10%, transparent);
}
.safety p {
margin: 0;
}
.toolbar {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
}
.toolbar label,
.dialog label {
display: grid;
gap: 0.3rem;
font-size: 0.8rem;
font-weight: 600;
}
.toolbar input,
.toolbar select,
.dialog input,
.dialog select,
.dialog textarea {
min-height: 44px;
padding: 0.55rem 0.7rem;
border: 1px solid var(--ifm-color-emphasis-400);
border-radius: 7px;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
font: inherit;
}
.search {
position: relative;
flex: 1 1 280px;
}
.search svg {
position: absolute;
left: 0.75rem;
bottom: 0.8rem;
color: var(--ifm-color-emphasis-600);
}
.search input {
width: 100%;
padding-left: 2.25rem;
}
.checkbox {
display: flex !important;
grid-auto-flow: column;
align-items: center;
min-height: 44px;
}
.checkbox input {
min-height: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 1rem;
}
.card {
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
background: var(--ifm-background-surface-color);
}
.card img,
.cardPlaceholder {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
}
.cardPlaceholder {
display: grid;
place-items: center;
background: var(--ifm-color-emphasis-100);
color: var(--ifm-color-emphasis-600);
font: 700 1.5rem var(--ifm-font-family-monospace);
}
.cardBody {
padding: 1rem;
}
.cardBody h2 {
margin: 0.4rem 0;
font-size: 1.08rem;
}
.cardBody p {
min-height: 2.7em;
margin: 0 0 0.65rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.9rem;
}
.cardMeta {
display: flex;
justify-content: space-between;
color: var(--ifm-color-emphasis-700);
font-size: 0.75rem;
font-weight: 700;
}
.error {
padding: 0.85rem 1rem;
margin: 1rem 0;
border-radius: 8px;
color: var(--ifm-color-danger-contrast-foreground);
background: var(--ifm-color-danger-contrast-background);
}
.empty {
padding: 4rem 1rem;
text-align: center;
border: 1px dashed var(--ifm-color-emphasis-400);
border-radius: 12px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
min-height: 44px;
padding: 0.5rem 0.8rem;
}
.skeleton {
min-height: 310px;
border-radius: 12px;
background: var(--ifm-color-emphasis-200);
animation: pulse 1.5s ease-in-out infinite;
}
.detail {
margin-top: 1.5rem;
}
.detail header {
max-width: 75ch;
}
.type {
display: inline-block;
margin-bottom: 0.5rem;
color: var(--ifm-color-primary);
font-weight: 700;
}
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
margin: 1.5rem 0;
scroll-snap-type: x mandatory;
}
.gallery img {
width: min(720px, 85vw);
max-height: 480px;
object-fit: contain;
border-radius: 10px;
background: var(--ifm-color-emphasis-100);
scroll-snap-align: start;
}
.detailActions {
margin: 1.5rem 0 2rem;
}
.sourceHint {
color: var(--ifm-color-emphasis-700);
}
.source {
max-height: 560px;
overflow: auto;
padding: 1rem;
border-radius: 8px;
font-size: 0.78rem;
}
.overlay {
position: fixed;
z-index: 500;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 0.7);
}
.dialog {
width: min(680px, 100%);
max-height: 90vh;
overflow-y: auto;
padding: 1.25rem;
border-radius: 12px;
background: var(--ifm-background-color);
}
.dialog form {
display: grid;
gap: 1rem;
}
.dialog textarea {
min-height: 88px;
resize: vertical;
}
.dialog .editor {
min-height: 240px;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
}
.formActions {
justify-content: flex-end;
}
@keyframes pulse {
50% {
opacity: 0.55;
}
}
@media (max-width: 700px) {
.hero {
align-items: stretch;
flex-direction: column;
}
.accountActions > * {
flex: 1;
justify-content: center;
}
.toolbar > label {
flex: 1 1 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.adminSection {
margin: 2rem 0;
}
.tableWrap {
overflow-x: auto;
}
.tableWrap table {
width: 100%;
border-collapse: collapse;
}
.tableWrap th,
.tableWrap td {
padding: 0.7rem;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
vertical-align: top;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rowActions button {
min-height: 36px;
}
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopAdminPage() {
return (
<Layout title="Workshop moderation" noFooter>
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopAdmin } = require("@site/src/components/workshop/WorkshopAdmin");
return <WorkshopAdmin />;
}}
</BrowserOnly>
</Layout>
);
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopPage() {
return (
<Layout title="Workshop" description="Community widgets and CSS themes for Homarr">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopPage");
return <WorkshopApp />;
}}
</BrowserOnly>
</Layout>
);
}
+1
View File
@@ -50,6 +50,7 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dropzone": "catalog:",
@@ -1,11 +0,0 @@
"use client";
import dynamic from "next/dynamic";
const OpenWebUiChat = dynamic(() => import("~/components/open-webui-chat").then((mod) => mod.OpenWebUiChat), {
ssr: false,
});
export const OpenWebUiChatProvider = () => {
return <OpenWebUiChat />;
};
@@ -11,6 +11,9 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
@@ -45,7 +48,11 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Group justify="space-between">
<Group>
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<PublishToWorkshopButton type="css" defaultTitle={board.name} getContent={() => form.values.customCss} />
</Group>
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
{formatBytes(file.size)}
</Text>
</Group>
<Button
-2
View File
@@ -32,7 +32,6 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
import { AuthProvider } from "./_client-providers/session";
@@ -148,7 +147,6 @@ export default async function Layout(props: {
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
<OpenWebUiChatProvider />
{props.children}
</StackedProvider>
</body>
@@ -6,6 +6,7 @@ import {
IconCopy,
IconDots,
IconDownload,
IconShare3,
IconToggleLeft,
IconToggleRight,
IconTrash,
@@ -19,6 +20,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
const iconProps = { size: 16, stroke: 1.5 };
@@ -30,6 +32,7 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const workshopT = useScopedI18n("workshop");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -141,6 +144,16 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<PublishToWorkshopButton
type="widget"
defaultTitle={widget.name}
getContent={async () => JSON.stringify(await utils.customWidget.export.fetch({ id: widget.id }), null, 2)}
renderTrigger={(open) => (
<Menu.Item onClick={open} leftSection={<IconShare3 {...iconProps} />}>
{workshopT("action.share")}
</Menu.Item>
)}
/>
<Menu.Divider />
<Menu.Item
color="red"
@@ -9,6 +9,7 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { InstallWidgetFromWorkshopButton } from "~/components/workshop/workshop-actions";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
@@ -28,6 +29,7 @@ export default async function CustomWidgetsPage() {
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<InstallWidgetFromWorkshopButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
@@ -18,6 +18,7 @@ import {
IconHomeFilled,
IconLayoutDashboardFilled,
IconMailForward,
IconBuildingStore,
IconPhotoFilled,
IconPointerFilled,
IconSearch,
@@ -80,6 +81,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.customWidgets"),
hidden: !session,
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
{formatBytes(bytesUsage)}
</Text>
);
},
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopBrowser } from "~/components/workshop/workshop-browser";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
return (
<ManagePageLayout title={t("title")} size="xl">
<WorkshopBrowser />
</ManagePageLayout>
);
}
@@ -1,108 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Drawer } from "@mantine/core";
import { useHotkeys, useMediaQuery } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { openWebUiChatEvent } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Chat } from "@homarr/widgets/_open-webui/chat";
/**
* App-wide launcher for the Open WebUI chat. Renders a right-side panel that can
* be opened from the header user menu, the {@link hotkeys.toggleAiChat} keybind,
* or the spotlight "ask AI" mode (via the {@link openWebUiChatEvent} window
* event). Renders nothing unless the signed-in user owns an Open WebUI
* integration.
*/
export const OpenWebUiChat = () => {
const t = useI18n();
const { data: session } = useSession();
const isMobile = useMediaQuery("(max-width: 767px)");
const [opened, setOpened] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
// Bumped on every open so the chat remounts and a fresh initial query is sent.
const [openNonce, setOpenNonce] = useState(0);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const myOpenWebUiIntegration = useMemo(() => {
if (!session?.user?.id) return null;
return integrations.find(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id,
);
}, [integrations, session?.user?.id]);
const open = useCallback((query?: string) => {
setInitialQuery(query?.trim() ? query.trim() : undefined);
setOpenNonce((nonce) => nonce + 1);
setOpened(true);
}, []);
const toggle = useCallback(() => {
setOpened((previous) => {
if (previous) return false;
setInitialQuery(undefined);
setOpenNonce((nonce) => nonce + 1);
return true;
});
}, []);
// Open (with an optional query) when the spotlight "ask AI" mode dispatches.
useEffect(() => {
const handler = (event: Event) => {
if (!myOpenWebUiIntegration) return;
open((event as CustomEvent<{ query?: string }>).detail?.query);
};
window.addEventListener(openWebUiChatEvent, handler);
return () => window.removeEventListener(openWebUiChatEvent, handler);
}, [open, myOpenWebUiIntegration]);
useHotkeys([[hotkeys.toggleAiChat, () => myOpenWebUiIntegration && toggle()]]);
if (!myOpenWebUiIntegration) return null;
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size={isMobile ? "100%" : 480}
title={t("widget.openWebUi.name")}
keepMounted={false}
styles={{
body: {
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
padding: 0,
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
key={openNonce}
options={{ systemPrompt: "", showHistory: true }}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the launcher chat.
}}
width={480}
height={600}
initialQuery={initialQuery}
/>
</Box>
</Drawer>
);
};
@@ -11,17 +11,14 @@ import {
IconHome,
IconLogin,
IconLogout,
IconRobot,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { openOpenWebUiChat } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -49,15 +46,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session.data?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id,
);
const handleSignout = useCallback(async () => {
await signOut({
redirect: false,
@@ -108,11 +96,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
{t("docker")}
</Menu.Item>
)}
{hasOpenWebUiIntegration && (
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
{t("aiChat")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
@@ -0,0 +1,72 @@
"use client";
import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { validateWorkshopContent } from "@homarr/workshop";
import { WorkshopBrowser } from "./workshop-browser";
export function InstallWidgetFromWorkshopButton() {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("title"), message: t("action.install") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
close();
},
onError: (error) => showErrorNotification({ title: t("title"), message: error.message }),
});
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.install")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%" fullScreen={false}>
<WorkshopBrowser
initialType="widget"
lockedType="widget"
useLabel={t("action.install")}
onUse={async (submission) => {
const parsed = customWidgetImportSchema.parse(JSON.parse(submission.content));
await mutation.mutateAsync(parsed);
}}
/>
</Modal>
</>
);
}
export function UseCssFromWorkshopButton({ onUse }: { onUse: (css: string) => void }) {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.useCss")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%">
<WorkshopBrowser
initialType="css"
lockedType="css"
useLabel={t("action.useCss")}
onUse={(submission) => {
const validation = validateWorkshopContent("css", submission.content);
if (!validation.success) throw new Error(validation.error);
onUse(submission.content);
close();
}}
/>
</Modal>
</>
);
}
@@ -0,0 +1,339 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Modal,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconBrandGithub, IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
export interface WorkshopBrowserProps {
initialType?: WorkshopSubmissionType | "all";
lockedType?: WorkshopSubmissionType;
onUse?: (submission: WorkshopSubmissionDetail) => Promise<void> | void;
useLabel?: string;
}
export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [page, setPage] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">(initialType);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [usePending, setUsePending] = useState(false);
const [useError, setUseError] = useState("");
const query = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
queryFn: () => client.list({ page, type, sort, search: debouncedSearch, perPage: 12 }),
});
const openDetail = async (id: string) => {
setDetailLoading(true);
try {
setSelected(await client.get(id));
} finally {
setDetailLoading(false);
}
};
const download = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = workshopExportFilename(submission.title, submission.type);
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Stack gap="lg">
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
{t("safety.description")}
</Alert>
<Group align="end">
<TextInput
label={t("search.label")}
placeholder={t("search.placeholder")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
style={{ flex: 1 }}
/>
<Select
label={t("filter.type")}
value={type}
disabled={Boolean(lockedType)}
onChange={(value) => {
setType((value ?? "all") as typeof type);
setPage(1);
}}
data={[
{ value: "all", label: t("filter.all") },
{ value: "widget", label: t("filter.widgets") },
{ value: "css", label: t("filter.css") },
]}
/>
<Select
label={t("filter.sort")}
value={sort}
onChange={(value) => {
setSort((value ?? "top") as typeof sort);
setPage(1);
}}
data={[
{ value: "top", label: t("filter.top") },
{ value: "newest", label: t("filter.newest") },
]}
/>
</Group>
{query.error && (
<Alert color="red" title={t("error.loadTitle")}>
{query.error.message}
<Button variant="subtle" onClick={() => void query.refetch()}>
{t("action.retry")}
</Button>
</Alert>
)}
{query.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : query.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{query.data.items.map((item) => (
<Card key={item.id} withBorder padding="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Text ff="monospace" fw={700}>
{item.type === "widget" ? "{}" : "CSS"}
</Text>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Badge variant="light">{item.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{item.score >= 0 ? "+" : ""}
{item.score}
</Text>
</Group>
<Text fw={600} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("empty.description")}
</Text>
<Text size="xs" c="dimmed">
{t("by", { name: item.authorName, revision: item.revision })}
</Text>
<Button variant="light" onClick={() => void openDetail(item.id)}>
{t("action.inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" py="xl">
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed">{t("empty.description")}</Text>
</Stack>
)}
{(query.data?.totalPages ?? 0) > 1 && (
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={Boolean(selected) || detailLoading}
onClose={() => setSelected(null)}
title={selected?.title ?? t("loading")}
size="xl"
>
{detailLoading || !selected ? (
<Skeleton h={400} />
) : (
<Stack>
<Group>
<Badge>{selected.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{t("by", { name: selected.authorName, revision: selected.revision })}
</Text>
</Group>
<Text>{selected.description}</Text>
<Alert icon={<IconAlertTriangle size={18} />} color="yellow">
{t("safety.inspect")}
</Alert>
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{selected.content}
</Code>
<Group justify="space-between">
<Group>
<Button variant="default" leftSection={<IconDownload size={16} />} onClick={() => download(selected)}>
{t("action.download")}
</Button>
<Button
component="a"
href={`${WORKSHOP_WEB_URL}/${selected.id}`}
target="_blank"
variant="subtle"
leftSection={<IconExternalLink size={16} />}
>
{t("action.website")}
</Button>
</Group>
{onUse && (
<Button
loading={usePending}
onClick={async () => {
setUsePending(true);
setUseError("");
try {
if (lockedType && selected.type !== lockedType) throw new Error(t("error.wrongType"));
await onUse(selected);
setSelected(null);
} catch (caught) {
setUseError(caught instanceof Error ? caught.message : t("error.install"));
} finally {
setUsePending(false);
}
}}
>
{useLabel ?? t("action.use")}
</Button>
)}
</Group>
{useError && <Alert color="red">{useError}</Alert>}
</Stack>
)}
</Modal>
</Stack>
);
}
export function PublishToWorkshopButton({
type,
getContent,
defaultTitle,
compact = false,
renderTrigger,
}: {
type: WorkshopSubmissionType;
getContent: () => Promise<string> | string;
defaultTitle: string;
compact?: boolean;
renderTrigger?: (open: () => void) => ReactNode;
}) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [opened, { open, close }] = useDisclosure(false);
const [title, setTitle] = useState(defaultTitle);
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => setTitle(defaultTitle), [defaultTitle]);
const publish = async () => {
setPending(true);
setError("");
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.create({ type, title, description, changelog, content: await getContent() });
close();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("error.publish"));
} finally {
setPending(false);
}
};
return (
<>
{renderTrigger ? (
renderTrigger(open)
) : (
<Button
size={compact ? "xs" : undefined}
variant="default"
leftSection={<IconBrandGithub size={16} />}
onClick={open}
>
{t("action.share")}
</Button>
)}
<Modal opened={opened} onClose={close} title={t("publish.title")}>
<Stack>
<TextInput
required
label={t("publish.name")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
/>
<Textarea
label={t("publish.description")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Textarea
label={t("publish.changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
/>
{error && <Alert color="red">{error}</Alert>}
<Group justify="end">
<Button variant="default" onClick={close}>
{t("action.cancel")}
</Button>
<Button loading={pending} disabled={title.trim().length < 3} onClick={() => void publish()}>
{t("action.publish")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
PB_SUPERUSER_EMAIL=
PB_SUPERUSER_PASSWORD=
PB_EXPOSE_PORT=8090
+4
View File
@@ -0,0 +1,4 @@
.env
.env.*
!.env.example
pb_data/
+31
View File
@@ -0,0 +1,31 @@
FROM alpine:3.22 AS download
ARG TARGETARCH
ARG POCKETBASE_VERSION=0.39.6
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=download /out/pocketbase /usr/local/bin/pocketbase
COPY entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_hooks /pb_migrations && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations
USER root
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-entrypoint"]
+45
View File
@@ -0,0 +1,45 @@
# Homarr Workshop backend
PocketBase provides the central Workshop API at `store.homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
## Local startup
1. Copy `.env.example` to `.env` and create a GitHub OAuth app.
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
4. Run `docker compose up --build` from this directory.
The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
The image downloads the official PocketBase `0.39.6` release and verifies it against that release's `checksums.txt`. Migrations and hooks are mounted read-only.
## Roles and account states
- `member`: publish, update/delete owned submissions, vote, and report.
- `moderator`: report queue, submission removal, posting bans, and account disable/restore.
- `admin`: moderator actions plus role changes.
- `posting_banned`: cannot create or update submissions; can browse, vote, and report.
- `disabled`: cannot authenticate or write.
Bootstrap the first admin through the PocketBase dashboard by changing a GitHub-authenticated user's role. All later staff actions use the restricted Workshop back office and are recorded in `moderation_actions`.
## Backup and restore
PocketBase stores records and uploads in `/pb_data`. Stop writes or stop the container, then archive the named volume:
```sh
docker compose stop workshop
docker run --rm -v store_workshop_data:/data -v "$PWD":/backup alpine tar czf /backup/workshop-backup.tgz -C /data .
docker compose start workshop
```
Restore only into the same PocketBase version after preserving the current volume. Extract the archive into an empty `workshop_data` volume, start the service, and verify `/api/health`, collection migrations, a listing request, and an authenticated write.
## Production checklist
- Terminate TLS at the reverse proxy and forward the real client IP.
- Keep superuser credentials outside Compose and rotate them after bootstrap.
- Restrict the dashboard path to operators; GitHub users never need it.
- Review the versioned default rate limits for OAuth, submissions, votes, reports, moderation, and general API traffic against production load before launch.
- Back up `/pb_data` before every PocketBase or migration upgrade and test restore regularly.
- Monitor health, authentication failures, 429/5xx responses, moderation actions, disk use, and backup age.
+24
View File
@@ -0,0 +1,24 @@
services:
workshop:
build:
context: .
args:
POCKETBASE_VERSION: 0.39.6
image: homarr/workshop-pocketbase:0.39.6
restart: unless-stopped
environment:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
ports:
- "${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- workshop_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
chown -R pocketbase:pocketbase /pb_data
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
su-exec pocketbase pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
fi
exec su-exec pocketbase pocketbase serve \
--http=0.0.0.0:8090 \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations
+102
View File
@@ -0,0 +1,102 @@
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CSS_SCHEMA = "homarr-custom-css-v1";
const CSS_LIMIT = 16384;
const WIDGET_AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
const WIDGET_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const WIDGET_DISPLAY_TYPES = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
];
function fail(ErrorType, _code, message) {
throw new ErrorType(message);
}
function requireWritableAccount(app, auth, posting) {
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
const account = app.findRecordById("users", auth.id);
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
if (posting && account.getString("state") === "posting_banned")
fail(ForbiddenError, "posting_banned", "Account cannot publish submissions");
return account;
}
function validateSubmission(record) {
const type = record.getString("type");
const content = record.getString("content");
if (type === "css") {
if (!content.trim()) fail(BadRequestError, "invalid_submission", "CSS cannot be empty");
if (content.length > CSS_LIMIT) fail(BadRequestError, "invalid_submission", "CSS is too large");
record.set("schemaVersion", CSS_SCHEMA);
return;
}
if (type !== "widget") fail(BadRequestError, "invalid_submission", "Unsupported submission type");
let widget;
try {
widget = JSON.parse(content);
} catch {
fail(BadRequestError, "invalid_submission", "Widget content is not valid JSON");
}
if (
!widget ||
widget.$schema !== WIDGET_SCHEMA ||
typeof widget.name !== "string" ||
widget.name.length < 1 ||
widget.name.length > 128 ||
typeof widget.url !== "string" ||
widget.url.length < 1 ||
!WIDGET_AUTH_TYPES.includes(widget.authType) ||
!WIDGET_METHODS.includes(widget.method) ||
!WIDGET_DISPLAY_TYPES.includes(widget.displayType) ||
typeof widget.displayConfig !== "object" ||
Array.isArray(widget.displayConfig) ||
Object.prototype.hasOwnProperty.call(widget, "secrets")
) {
fail(BadRequestError, "invalid_submission", "Widget does not match homarr-custom-widget-v2");
}
if (!widget.displayConfig || widget.displayConfig.type !== widget.displayType) {
fail(BadRequestError, "invalid_submission", "Widget displayConfig.type must match displayType");
}
record.set("schemaVersion", WIDGET_SCHEMA);
}
function snapshot(record) {
return JSON.stringify(record.publicExport());
}
function requireStaff(app, auth) {
const account = requireWritableAccount(app, auth, false);
const role = account.getString("role");
if (role !== "moderator" && role !== "admin") fail(ForbiddenError, "forbidden", "Staff access is required");
return role;
}
function body(e) {
const data = new DynamicModel({ reason: "", state: "", role: "", status: "" });
e.bindBody(data);
if (!data.reason || data.reason.trim().length < 3)
fail(BadRequestError, "invalid_submission", "A reason is required");
return data;
}
function audit(app, actor, action, targetType, targetId, reason, targetSnapshot) {
const collection = app.findCollectionByNameOrId("moderation_actions");
const record = new Record(collection);
record.set("actor", actor.id);
record.set("action", action);
record.set("targetType", targetType);
record.set("targetId", targetId);
record.set("reason", reason.trim());
record.set("snapshot", targetSnapshot || "");
app.save(record);
}
module.exports = { audit, body, fail, requireStaff, requireWritableAccount, snapshot, validateSubmission };
+168
View File
@@ -0,0 +1,168 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordAuthWithOAuth2Request((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record && e.record.getString("state") === "disabled")
utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
if (e.isNewRecord) {
e.createData.displayName = e.oAuth2User.name || e.oAuth2User.username || "Community member";
e.createData.avatarUrl = e.oAuth2User.avatarUrl || "";
e.createData.role = "member";
e.createData.state = "active";
}
e.next();
}, "users");
onRecordAuthRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record.getString("state") === "disabled") utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
e.next();
}, "users");
onRecordUpdateRequest((e) => {
const original = e.app.findRecordById("users", e.record.id);
e.record.set("role", original.get("role"));
e.record.set("state", original.get("state"));
e.record.set("moderationReason", original.get("moderationReason"));
e.next();
}, "users");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const account = utils.requireWritableAccount(e.app, e.auth, true);
utils.validateSubmission(e.record);
e.record.set("author", e.auth.id);
e.record.set("authorName", account.getString("displayName"));
e.record.set("revision", 1);
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, true);
const original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("authorName", original.get("authorName"));
e.record.set("type", original.get("type"));
e.record.set("schemaVersion", original.get("schemaVersion"));
e.record.set(
"revision",
original.getInt("revision") + (e.record.getString("content") !== original.getString("content") ? 1 : 0),
);
utils.validateSubmission(e.record);
e.next();
}, "submissions");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", e.auth.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const original = e.app.findRecordById("votes", e.record.id);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
e.record.set("reporter", e.auth.id);
e.record.set("status", "open");
e.next();
}, "reports");
routerAdd(
"POST",
"/api/workshop/moderation/submissions/{id}/remove",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("submissions", id);
utils.audit(tx, e.auth, "remove_submission", "submission", id, data.reason, utils.snapshot(target));
tx.delete(target);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/state",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const actorRole = utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["active", "posting_banned", "disabled"].includes(data.state))
throw new BadRequestError("Invalid account state");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
if (target.getString("role") === "admin" && actorRole !== "admin") throw new ForbiddenError("forbidden");
const previous = utils.snapshot(target);
target.set("state", data.state);
target.set("moderationReason", data.reason);
tx.save(target);
utils.audit(tx, e.auth, "set_account_state", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/role",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (utils.requireStaff(e.app, e.auth) !== "admin") throw new ForbiddenError("forbidden");
const data = utils.body(e);
if (!["member", "moderator", "admin"].includes(data.role)) throw new BadRequestError("Invalid role");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot change your own role");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
const previous = utils.snapshot(target);
target.set("role", data.role);
tx.save(target);
utils.audit(tx, e.auth, "set_role", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/reports/{id}/resolve",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["resolved", "dismissed"].includes(data.status)) throw new BadRequestError("Invalid report status");
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("reports", id);
const previous = utils.snapshot(target);
target.set("status", data.status);
tx.save(target);
utils.audit(tx, e.auth, "resolve_report", "report", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
@@ -0,0 +1,248 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
users = new Collection({
type: "auth",
name: "users",
listRule: "@request.auth.role = 'moderator' || @request.auth.role = 'admin'",
viewRule: "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'",
updateRule: "id = @request.auth.id && @request.auth.state != 'disabled'",
deleteRule: null,
passwordAuth: { enabled: false },
});
}
// PocketBase creates a default users auth collection on first boot, so
// Workshop fields must be added to the existing collection explicitly.
users.fields.add(
new TextField({ name: "displayName", required: true, min: 1, max: 100 }),
new URLField({ name: "avatarUrl" }),
new SelectField({ name: "role", required: true, maxSelect: 1, values: ["member", "moderator", "admin"] }),
new SelectField({
name: "state",
required: true,
maxSelect: 1,
values: ["active", "posting_banned", "disabled"],
}),
new TextField({ name: "moderationReason", max: 1000 }),
);
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
users.oauth2.enabled = Boolean(githubClientId && githubClientSecret);
users.oauth2.providers =
githubClientId && githubClientSecret
? [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }]
: [];
app.save(users);
const staffRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
const submissions = new Collection({
type: "base",
name: "submissions",
// Full source is intentionally available only through getOne; discovery
// must use workshop_listings so list payloads never contain content.
listRule: null,
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["widget", "css"] },
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "schemaVersion", required: true, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{
type: "file",
name: "screenshots",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
thumbs: ["480x320", "960x640"],
},
{
type: "relation",
name: "author",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "text", name: "authorName", required: true, max: 100 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
submissions.createRule = "@request.auth.id != ''";
submissions.updateRule = authorRule;
submissions.deleteRule = authorRule;
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_type_created", false, "type, created", "");
app.save(submissions);
const votes = new Collection({
type: "base",
name: "votes",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(votes);
votes.listRule = "user = @request.auth.id";
votes.viewRule = "user = @request.auth.id";
votes.createRule = writableRule;
votes.updateRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.deleteRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
votes.addIndex("idx_votes_submission", false, "submission", "");
app.save(votes);
const reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
type: "relation",
name: "reporter",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "select",
name: "category",
required: true,
maxSelect: 1,
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
},
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "resolved", "dismissed"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(reports);
reports.listRule = staffRule;
reports.viewRule = staffRule;
reports.createRule = writableRule;
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
reports.addIndex("idx_reports_status_created", false, "status, created", "");
app.save(reports);
const moderationActions = new Collection({
type: "base",
name: "moderation_actions",
listRule: staffRule,
viewRule: staffRule,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
{
type: "select",
name: "action",
required: true,
maxSelect: 1,
values: ["remove_submission", "set_account_state", "set_role", "resolve_report"],
},
{ type: "text", name: "targetType", required: true, max: 30 },
{ type: "text", name: "targetId", required: true, max: 30 },
{ type: "text", name: "reason", required: true, min: 3, max: 1000 },
{ type: "text", name: "snapshot", max: 100000 },
{ type: "autodate", name: "created", onCreate: true },
],
});
app.save(moderationActions);
moderationActions.addIndex("idx_moderation_actions_created", false, "created", "");
app.save(moderationActions);
const listings = new Collection({
type: "view",
name: "workshop_listings",
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.schemaVersion, s.screenshots,
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
COALESCE(SUM(v.value), 0) AS score
FROM submissions s
LEFT JOIN votes v ON v.submission = s.id
GROUP BY s.id
`,
});
app.save(listings);
const settings = app.settings();
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
];
settings.logs.maxDays = 30;
settings.logs.logAuthId = true;
settings.logs.logIP = true;
app.save(settings);
},
(app) => {
// PocketBase creates the users auth collection before this migration. Keep
// it intact on rollback so reverting Workshop can never delete accounts.
for (const name of ["workshop_listings", "moderation_actions", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// Collection was not created or was already removed.
}
}
const settings = app.settings();
settings.rateLimits.enabled = false;
settings.rateLimits.rules = [];
app.save(settings);
},
);
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="WorkshopIntegrationPassword123!"
cleanup() {
PB_EXPOSE_PORT="$port" docker compose --file apps/store/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
cleanup
PB_EXPOSE_PORT="$port" \
PB_SUPERUSER_EMAIL="$email" \
PB_SUPERUSER_PASSWORD="$password" \
docker compose --file apps/store/docker-compose.yml --project-name "$project" up --detach --build --wait
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/store/tests/workshop.integration.mjs; then
docker compose --file apps/store/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
+165
View File
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import PocketBase, { ClientResponseError } from "pocketbase";
const baseUrl = process.env.WORKSHOP_TEST_URL;
const superuserEmail = process.env.WORKSHOP_TEST_SUPERUSER_EMAIL;
const superuserPassword = process.env.WORKSHOP_TEST_SUPERUSER_PASSWORD;
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
const root = new PocketBase(baseUrl);
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
const createUser = async (name, role = "member", state = "active") => {
const password = `Workshop${name}Password123!`;
return root.collection("users").create({
email: `${name.toLowerCase()}@homarr.test`,
password,
passwordConfirm: password,
verified: true,
displayName: name,
role,
state,
});
};
const expectStatus = async (operation, status) => {
const statuses = Array.isArray(status) ? status : [status];
await assert.rejects(operation, (error) => error instanceof ClientResponseError && statuses.includes(error.status));
};
const memberRecord = await createUser("Member");
const secondMemberRecord = await createUser("SecondMember");
const bannedRecord = await createUser("Banned", "member", "posting_banned");
const disabledRecord = await createUser("Disabled");
const moderatorRecord = await createUser("Moderator", "moderator");
const adminRecord = await createUser("Admin", "admin");
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
const secondMember = await root.collection("users").impersonate(secondMemberRecord.id, 3600);
const banned = await root.collection("users").impersonate(bannedRecord.id, 3600);
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
const forgedMember = await member.collection("users").update(memberRecord.id, {
role: "admin",
state: "posting_banned",
moderationReason: "self escalation",
});
assert.equal(forgedMember.role, "member", "members must not change their own role");
assert.equal(forgedMember.state, "active", "members must not change their own account state");
assert.equal(forgedMember.moderationReason, "", "members must not change protected moderation fields");
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Integration disable" },
});
const submission = await member.collection("submissions").create({
type: "css",
title: "Integration theme",
description: "A backend integration fixture",
schemaVersion: "forged-version",
content: ":root { --homarr-test: #fff; }",
author: secondMemberRecord.id,
authorName: "Forged",
revision: 99,
});
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
assert.equal(submission.revision, 1, "server must own the initial revision");
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
await expectStatus(
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
403,
);
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
await expectStatus(
() =>
member.collection("submissions").create({
type: "widget",
title: "Invalid widget",
content: JSON.stringify({ $schema: "homarr-custom-widget-v2", name: "Missing contract" }),
}),
400,
);
const vote = await member.collection("votes").create({ submission: submission.id, value: 1 });
await expectStatus(() => member.collection("votes").create({ submission: submission.id, value: -1 }), 400);
assert.equal(
(await member.collection("votes").update(vote.id, { submission: "forged", user: "forged", value: -1 })).submission,
submission.id,
);
const report = await secondMember.collection("reports").create({
submission: submission.id,
reporter: memberRecord.id,
category: "spam",
explanation: "Integration report",
status: "dismissed",
});
assert.equal(report.reporter, secondMemberRecord.id);
assert.equal(report.status, "open");
assert.equal((await member.collection("reports").getList()).totalItems, 0, "reports must remain private from members");
assert.equal((await moderator.collection("reports").getList()).items.length, 1);
await expectStatus(
() =>
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
method: "POST",
body: { state: "posting_banned", reason: "No permission" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${adminRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Cannot alter admin" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Cannot promote" },
}),
403,
);
await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Integration promotion" },
});
assert.equal((await root.collection("users").getOne(secondMemberRecord.id)).role, "moderator");
await moderator.send(`/api/workshop/moderation/reports/${report.id}/resolve`, {
method: "POST",
body: { status: "resolved", reason: "Integration resolution" },
});
assert.equal((await root.collection("reports").getOne(report.id)).status, "resolved");
await moderator.send(`/api/workshop/moderation/submissions/${submission.id}/remove`, {
method: "POST",
body: { reason: "Integration removal" },
});
await expectStatus(() => root.collection("submissions").getOne(submission.id), 404);
await expectStatus(() => root.collection("votes").getOne(vote.id), 404);
await expectStatus(() => root.collection("reports").getOne(report.id), 404);
const actions = await root.collection("moderation_actions").getFullList({ sort: "created" });
assert.deepEqual(
actions.map((action) => action.action),
["set_account_state", "set_role", "resolve_report", "remove_submission"],
);
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
const anonymous = new PocketBase(baseUrl);
const listings = await anonymous.collection("workshop_listings").getList();
assert.equal(listings.totalItems, 0);
await expectStatus(() => anonymous.collection("submissions").getList(), 403);
console.log("Workshop backend integration matrix passed");
+78
View File
@@ -0,0 +1,78 @@
# Workshop V2 audit
## Purpose
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
### Feature North Star
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
### Developer-experience North Star
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
## Baseline audit
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
| Area | Severity | Baseline finding | V2 resolution |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
## Decisions
- Public name: **Workshop**.
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
- Identity: GitHub OAuth only.
- Community features: votes and reports; comments are removed.
- Hosting: one Homarr-operated service at `store.homarr.dev`; overrides are development-only.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
## Launch acceptance
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
- [x] Only admins can change roles; staff actions are append-only and attributable.
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
## Deferred after V2
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
## Exit scorecard
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
| Gate | Result | Evidence |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
| Backend operations | Pass | PocketBase 0.39.6 image builds with checksum verification; clean migration, health, and listing/detail access boundaries pass. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
| Browser smoke | Pass | Desktop and 390px mobile discovery render without console errors or horizontal overflow; semantic labels, skip link, dark mode, and reduced motion are active. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
+10 -4
View File
@@ -6,6 +6,10 @@
"build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
"ci:act:fast": "bash scripts/ci/act.sh fast",
"cli": "pnpm with-env tsx packages/cli/index.ts",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
@@ -17,6 +21,7 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "docker compose -f apps/store/docker-compose.yml up --build",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
@@ -29,10 +34,14 @@
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop-backend": "bash apps/store/tests/run-integration.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
"dependencies": {
"jsonpath-plus": "catalog:"
},
"devDependencies": {
"@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:",
@@ -64,8 +73,5 @@
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.10.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
"packageManager": "pnpm@11.10.0"
}
@@ -99,7 +99,6 @@ export const integrationRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
url: integration.url,
creatorId: integration.creatorId,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
@@ -345,67 +344,24 @@ export const integrationRouter = createTRPCRouter({
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
const userId = ctx.session.user.id;
const secretValues = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
}));
// Create the integration, its creator permission and secrets atomically so
// a failure part-way through can't leave a committed integration the
// creator can neither use nor manage.
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (transaction) => {
await transaction.insert(schema.integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
});
// Auto-grant the creator "full" permission so they can immediately
// use and manage the integration they just created.
await transaction.insert(schema.integrationUserPermissions).values({
integrationId,
userId,
permission: "full",
});
if (secretValues.length >= 1) {
await transaction.insert(schema.integrationSecrets).values(secretValues);
}
});
},
handleSync: (db) => {
db.transaction((transaction) => {
transaction
.insert(integrations)
.values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
})
.run();
transaction
.insert(integrationUserPermissions)
.values({
integrationId,
userId,
permission: "full",
})
.run();
if (secretValues.length >= 1) {
transaction.insert(integrationSecrets).values(secretValues).run();
}
});
},
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
logger.info("Created integration", {
id: integrationId,
name: input.name,
@@ -4,7 +4,6 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -302,10 +301,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -322,23 +317,10 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst({
where: and(
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
eq(integrationUserPermissions.userId, defaultUserId),
),
});
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
// Creator should be auto-granted "full" permission
expect(dbPermission).toBeDefined();
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
expect(dbPermission!.userId).toBe(defaultUserId);
expect(dbPermission!.permission).toBe("full");
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -354,10 +336,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
@@ -379,7 +357,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -397,10 +374,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -432,7 +405,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
@@ -459,10 +431,6 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -487,7 +455,6 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
-1
View File
@@ -43,6 +43,5 @@ export const widgetRouter = createTRPCRouter({
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
@@ -1,379 +0,0 @@
import { observable } from "@trpc/server/observable";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import {
openWebUiChatRequestHandler,
openWebUiChatsRequestHandler,
openWebUiFilesRequestHandler,
openWebUiKnowledgeFilesRequestHandler,
openWebUiKnowledgeRequestHandler,
openWebUiModelsRequestHandler,
openWebUiNoteRequestHandler,
openWebUiNotesRequestHandler,
} from "@homarr/request-handler/open-webui";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "openWebUiRouter" });
/**
* Open WebUI integrations hold a personal API key that grants access to a
* single user's data (chat history, files, notes, knowledge bases). To prevent
* other Homarr users from reading that data, every procedure that touches the
* integration verifies that the caller is the integration's creator.
*
* The check is intentionally placed **after** the integration middleware (which
* already validates the caller's permission level) so the error message does
* not leak whether the integration exists.
*/
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
// Existing integrations that predate the creatorId column have a null
// creatorId; we skip the check for backward compatibility.
if (integration.creatorId && integration.creatorId !== userId) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
}
};
// Hosts that should never be fetched server-side. processWeb is already
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
const isBlockedWebHost = (hostname: string): boolean => {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "0.0.0.0" ||
host === "::" ||
host === "::1" ||
host.startsWith("127.") ||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
host === "metadata.google.internal"
);
};
// A user-provided URL for web-page ingestion: must be http(s) and not target a
// loopback/metadata host.
const webUrlSchema = z
.string()
.url()
.refine((value) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
// Bounds the upload/transcription payloads so a huge request can't pressure
// memory here or upstream before we even start decoding.
const MAX_BASE64_LENGTH = 25_000_000;
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
const uploadFilenameSchema = z.string().min(1).max(255);
const contentTypeSchema = z.string().min(1).max(255);
// Tag persisted conversations with the owning Homarr user so they can be found
// back in Open WebUI even when an integration/account is shared between users.
const homarrUserTag = (userId: string) => `homarr-user:${userId}`;
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
});
// Outbound completion messages may carry multimodal parts (text + images),
// so users can attach images to a message.
const contentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
const completionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(contentPartSchema)]),
});
const chatPayloadSchema = z.object({
title: z.string(),
models: z.array(z.string()),
messages: z.array(messageSchema),
});
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
type CompletionMessage = z.infer<typeof completionMessageSchema>;
// Extract the plain text of a message, flattening multimodal content parts.
const messageText = (message: CompletionMessage): string =>
typeof message.content === "string"
? message.content
: message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
// Number of chunks to retrieve per collection when grounding a reply.
const RETRIEVAL_K = 4;
// Instruction prepended to the retrieved context so the model knows how to use
// it. Model-facing, so intentionally not localized.
const RETRIEVAL_INSTRUCTION =
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
"files, notes and chats) to answer. If the answer is not in the context, say so.";
// Retrieve relevant chunks from the attached collections and prepend them as a
// system context message so the model can ground its reply on them.
const groundMessagesAsync = async (
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
messages: CompletionMessage[],
collections: string[],
contextTexts: string[],
): Promise<CompletionMessage[]> => {
if (collections.length === 0 && contextTexts.length === 0) return messages;
// Use the most recent user message that has text. The latest turn can be
// image-only (no text), in which case we fall back to the prior question so
// attached collections still get a meaningful retrieval query.
const query =
messages
.filter((message) => message.role === "user")
.map(messageText)
.findLast((text) => text.trim() !== "") ?? "";
let documents: string[] = [];
if (collections.length > 0) {
try {
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
} catch (error) {
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
}
}
const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== "");
if (blocks.length === 0) return messages;
const contextMessage: CompletionMessage = {
role: "system",
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
};
return [contextMessage, ...messages];
};
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Knowledge bases, files and notes expose private source content/metadata, so
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getFiles: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNotes: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNote: protectedProcedure
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getDataAsync();
return data;
}),
getKnowledgeFiles: protectedProcedure
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
knowledgeId: input.knowledgeId,
});
const { data } = await handler.getDataAsync();
return data;
}),
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.input(z.object({ url: webUrlSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.processWebAsync(input.url);
}),
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
// the file id; its vector collection is `file-{id}`.
uploadFile: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
}),
// Transcribe recorded audio to text for voice messages.
transcribe: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
return { text };
}),
// Full chat messages contain private user data; restrict to the creator.
getChat: protectedProcedure
.input(z.object({ chatId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
const { data } = await handler.getDataAsync();
return data;
}),
createChat: protectedProcedure
.input(chatPayloadSchema)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.createChatAsync({
title: input.title,
models: input.models,
messages: input.messages,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
return chat;
}),
updateChat: protectedProcedure
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.updateChatAsync(input.chatId, {
...input.chat,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
openWebUiChatRequestHandler.invalidateCache();
return chat;
}),
// Streaming chat completion. Delivered over the WebSocket subscription link so
// tokens can be emitted as they arrive. A completion spends the owner's upstream
// LLM credits, so it must not be reachable by anonymous viewers of a public
// board: protectedProcedure requires a session (the WS context resolves it from
// the session-token cookie) and the "interact" middleware checks integration
// access, matching processWeb/uploadFile/transcribe above.
sendMessage: protectedProcedure
.input(
z.object({
model: z.string(),
messages: z.array(completionMessageSchema),
// Collections to ground the reply on (web pages, knowledge bases, files).
// We retrieve relevant chunks and inject them before streaming.
collections: z.array(z.string()).optional(),
// Verbatim context blocks (notes, referenced chats) injected as-is.
contextTexts: z.array(z.string()).optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.subscription(({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
return observable<StreamEvent>((emit) => {
const controller = new AbortController();
void (async () => {
try {
const client = await createIntegrationAsync(ctx.integration);
const messages = await groundMessagesAsync(
client,
input.messages,
input.collections ?? [],
input.contextTexts ?? [],
);
await client.streamChatCompletionAsync(
{ model: input.model, messages },
(delta) => emit.next({ type: "delta", content: delta }),
controller.signal,
);
emit.next({ type: "done" });
emit.complete();
} catch (error) {
if (!controller.signal.aborted) {
logger.error("Open WebUI chat completion failed", {
integrationId: ctx.integration.id,
error,
});
emit.next({
type: "error",
message: error instanceof Error ? error.message : "Failed to stream chat completion",
});
}
emit.complete();
}
})();
return () => {
controller.abort();
};
});
}),
});
@@ -11,7 +11,6 @@ interface IntegrationContextProps {
name: string;
url: string;
kind: IntegrationKind;
creatorId: string | null;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -15,7 +15,6 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
name: true,
url: true,
kind: true,
creatorId: true,
},
with: {
userPermissions: {
+104 -5
View File
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
};
/**
* Number of bytes to si format. (Division by 1024)
* Does not accept floats, size in bytes should be an integer.
* Will return "NaI" and logs a warning if a float is passed.
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
* Returns "∞" if the size is too large to be represented in the current format.
* Number of bytes to a human-readable string using binary (KiB) suffixes.
* Does not accept floats; size in bytes should be an integer.
* Will return "NaI" and log a warning if a float is passed.
* `concat` is appended after the unit so it is omitted when the returned
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
* represented in the current format.
*
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
* paired "used / total" displays, and `formatByteRate` for byte-per-second
* rates. This function is kept for backwards compatibility but should not
* be used in new code.
*/
export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
return "∞";
};
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export type ByteUnitSystem = "binary" | "decimal";
export interface FormatBytesOptions {
/**
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
*/
unit?: ByteUnitSystem;
}
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
let index = 0;
let value = bytes;
while (value >= base && index < lastIndex) {
value /= base;
index++;
}
return index;
};
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
const resolveUnitConfig = (options: FormatBytesOptions) => {
const { unit = "binary" } = options;
return {
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
base: unit === "binary" ? 1024 : 1000,
};
};
/**
* Format a byte value as a human-readable string with a unit suffix.
*
* Picks the largest unit that keeps the scaled value below the base. Use
* `formatBytesPair` when two related values (e.g. used and available) should
* share a common unit.
*
* @example
* formatBytes(0); // "0.0 B"
* formatBytes(1024); // "1.0 KiB"
* formatBytes(985828802560); // "918.1 GiB"
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
*/
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
const { units, base } = resolveUnitConfig(options);
const safe = sanitizeBytes(bytes);
const index = pickUnitIndex(safe, base, units.length - 1);
const scaled = safe / base ** index;
return `${scaled.toFixed(1)} ${units[index]}`;
};
/**
* Format two related byte values (typically `used` and `available`) with a
* shared unit. The unit is picked from the sum of the two values so the
* combined display stays consistent.
*
* @example
* formatBytesPair(985828802560, 2858736793190);
* // { used: "0.9 TiB", available: "2.6 TiB" }
*/
export const formatBytesPair = (
used: number,
available: number,
options: FormatBytesOptions = {},
): { used: string; available: string } => {
const { units, base } = resolveUnitConfig(options);
const safeUsed = sanitizeBytes(used);
const safeAvailable = sanitizeBytes(available);
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
const suffix = units[index];
return {
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
};
};
/**
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
* string with a `/s` suffix. The value is formatted with the same rules as
* `formatBytes` and a trailing `/s` is appended.
*
* @example
* formatByteRate(0); // "0.0 B/s"
* formatByteRate(1024); // "1.0 KiB/s"
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
*/
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
`${formatBytes(bytes, options)}/s`;
const IMPERIAL_MULTIPLIER = 1.609344;
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
describe("formatBytes", () => {
it("returns bytes for small values", () => {
expect(formatBytes(0)).toBe("0.0 B");
expect(formatBytes(1)).toBe("1.0 B");
expect(formatBytes(1023)).toBe("1023.0 B");
});
it("scales binary units correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
});
it("scales decimal units when requested", () => {
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
});
it("formats a typical storage value with one decimal", () => {
expect(formatBytes(985828802560)).toBe("918.1 GiB");
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
});
it("caps at the largest unit to avoid overflow", () => {
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
});
it("returns the zero value for negative or non-finite input", () => {
expect(formatBytes(-1)).toBe("0.0 B");
expect(formatBytes(Number.NaN)).toBe("0.0 B");
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
});
});
describe("formatBytesPair", () => {
it("picks the unit of the larger total and applies it to both values", () => {
const { used, available } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("0.9 TiB");
expect(available).toBe("2.6 TiB");
});
it("uses a smaller unit when both values fit in it", () => {
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("100.0 MiB");
expect(available).toBe("200.0 MiB");
});
it("falls back to zero when both inputs are invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, -1);
expect(used).toBe("0.0 B");
expect(available).toBe("0.0 B");
});
it("uses the same unit across both values when one is invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TiB");
expect(available).toBe("1.0 TiB");
});
it("honours the decimal unit option", () => {
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
expect(used).toBe("0.5 TB");
expect(available).toBe("1.5 TB");
});
});
describe("formatByteRate", () => {
it("appends a /s suffix to the formatted value", () => {
expect(formatByteRate(0)).toBe("0.0 B/s");
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
});
it("honours the decimal unit option", () => {
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
});
it("returns zero for invalid input", () => {
expect(formatByteRate(-1)).toBe("0.0 B/s");
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
});
});
@@ -1,2 +0,0 @@
ALTER TABLE `integration` ADD `creator_id` varchar(64);--> statement-breakpoint
ALTER TABLE `integration` ADD CONSTRAINT `integration_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -302,13 +302,6 @@
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783786572320,
"tag": "0043_add_integration_creator_id",
"breakpoints": true
}
]
}
@@ -1,2 +0,0 @@
ALTER TABLE "integration" ADD COLUMN "creator_id" varchar(64);--> statement-breakpoint
ALTER TABLE "integration" ADD CONSTRAINT "integration_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -64,13 +64,6 @@
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786378808,
"tag": "0009_add_integration_creator_id",
"breakpoints": true
}
]
}
@@ -1 +0,0 @@
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id) ON DELETE SET NULL;
File diff suppressed because it is too large. Load diff
@@ -288,13 +288,6 @@
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786326756,
"tag": "0041_add_integration_creator_id",
"breakpoints": true
}
]
}
-5
View File
@@ -209,7 +209,6 @@ export const integrations = mysqlTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -689,10 +688,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
-5
View File
@@ -208,7 +208,6 @@ export const integrations = pgTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -688,10 +687,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
-5
View File
@@ -195,7 +195,6 @@ export const integrations = sqliteTable(
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -678,10 +677,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
@@ -329,7 +329,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/integrations/open-webui"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -382,5 +381,4 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api"
| "/docs/widgets/open-webui";
| "/docs/widgets/custom-api";
@@ -54,6 +54,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
openWebUi: "open-webui",
mock: null,
};
@@ -55,5 +55,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
openWebUi: "open-webui",
};
-1
View File
@@ -3,5 +3,4 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
toggleAiChat: "mod+shift+a",
};
+1 -11
View File
@@ -379,7 +379,7 @@ export const integrationDefs = {
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413,
apiKeySettingsPath: "/settings/api-keys",
apiKeySettingsPath: "/settings/integrations",
},
tracearr: {
name: "Tracearr",
@@ -469,15 +469,6 @@ export const integrationDefs = {
documentationUrl: null,
defaultUrl: "http://localhost:8001",
},
openWebUi: {
name: "Open WebUI",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
category: ["ai"],
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
defaultPort: 8080,
apiKeySettingsPath: "/?settings=open#account",
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -592,7 +583,6 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"ai",
"subtitleManager",
"reverseProxy",
] as const;
@@ -15,6 +15,12 @@ describe("getIntegrationApiKeyUrl", () => {
);
});
it("builds URL for patchmon", () => {
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
"http://patchmon.local:3413/settings/integrations",
);
});
it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
});
@@ -43,7 +43,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
openWebUi: ["openWebUi"],
traefik: ["traefik"],
};
-1
View File
@@ -53,7 +53,6 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"openWebUi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -32,7 +32,6 @@ import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
@@ -132,7 +131,6 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
openWebUi: OpenWebUiIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
@@ -1,5 +1,3 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: humanFileSize(storageLoad[index]!),
used: `${storageLoad[index]!}`,
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})),
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit });
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
-1
View File
@@ -42,7 +42,6 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
@@ -1,372 +0,0 @@
import { FormData } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiChatPayload,
OpenWebUiCompletionMessage,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
OpenWebUiWebDocument,
} from "./open-webui-types";
import {
openWebUiChatListItemSchema,
openWebUiChatSchema,
openWebUiCollectionQueryResponseSchema,
openWebUiCompletionChunkSchema,
openWebUiFileListSchema,
openWebUiKnowledgeListSchema,
openWebUiModelsResponseSchema,
openWebUiNoteListSchema,
openWebUiNoteSchema,
openWebUiProcessWebResponseSchema,
openWebUiTranscriptionSchema,
openWebUiUploadedFileSchema,
} from "./open-webui-types";
const logger = createLogger({ module: "open-webui-integration" });
// Default per-request timeout so a stalled upstream can't hang model/file/note/
// chat calls indefinitely. Streaming completions opt out (they are long-lived).
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
// The (undici) request options accepted by the shared fetch helper. Derived so
// the request helpers stay in sync with the underlying fetch types.
type RequestOptions = NonNullable<Parameters<typeof fetchWithTrustedCertificatesAsync>[1]>;
export class OpenWebUiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/models"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return { success: true };
}
/**
* List the models served by Open WebUI (aggregates all configured providers).
*/
public async getModelsAsync(): Promise<OpenWebUiModel[]> {
const parsed = await this.getJsonAsync("/api/models", openWebUiModelsResponseSchema);
return parsed.data;
}
/**
* List the knowledge bases (RAG collections) the user can ground chats on.
*/
public async getKnowledgeAsync(): Promise<OpenWebUiKnowledge[]> {
const parsed = await this.getJsonAsync("/api/v1/knowledge/", openWebUiKnowledgeListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's uploaded files (for the "Attach Files" picker).
*/
public async listFilesAsync(): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/**
* List the files contained in a knowledge base (for the expandable picker).
* The knowledge detail endpoint returns `files: null` on recent versions, so
* we read the files list and filter by the base's collection id instead.
*/
public async getKnowledgeFilesAsync(knowledgeId: string): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files
.filter((file) => file.meta?.collection_name === knowledgeId)
.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/** Fetch the raw file list, normalizing the paginated and bare-array shapes. */
private async fetchFilesAsync() {
const parsed = await this.getJsonAsync("/api/v1/files/", openWebUiFileListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's notes (for the "Attach Notes" picker).
*/
public async listNotesAsync(): Promise<OpenWebUiNote[]> {
const parsed = await this.getJsonAsync("/api/v1/notes/", openWebUiNoteListSchema);
const notes = Array.isArray(parsed) ? parsed : parsed.items;
return notes.map((note) => ({
id: note.id,
title: note.title ?? "Untitled note",
content: note.data?.content?.md ?? "",
}));
}
/**
* Get a single note including its full markdown content. The list endpoint
* truncates the body, so we fetch the note directly when attaching it.
*/
public async getNoteAsync(noteId: string): Promise<OpenWebUiNote> {
const parsed = await this.getJsonAsync(`/api/v1/notes/${encodeURIComponent(noteId)}`, openWebUiNoteSchema);
return { id: parsed.id, title: parsed.title ?? "Untitled note", content: parsed.data?.content?.md ?? "" };
}
/**
* Transcribe recorded audio to text (speech-to-text) for voice messages.
*/
public async transcribeAudioAsync(filename: string, base64Content: string, contentType: string): Promise<string> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/audio/transcriptions", form, openWebUiTranscriptionSchema);
return parsed.text;
}
/**
* Upload a file to Open WebUI so it can be retrieved from later. Returns the
* new file id (its vector collection is `file-{id}`).
*/
public async uploadFileAsync(
filename: string,
base64Content: string,
contentType: string,
): Promise<OpenWebUiFileSummary> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/files/", form, openWebUiUploadedFileSchema);
return { id: parsed.id, name: parsed.meta?.name ?? parsed.filename ?? filename };
}
/**
* Ingest a web page into a temporary vector collection and return a handle to
* retrieve from it. Open WebUI does not crawl URLs at completion time, so we
* embed the page here and inject retrieved chunks ourselves at send time.
*/
public async processWebAsync(url: string): Promise<OpenWebUiWebDocument> {
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/process/web",
{ url, collection_name: "" },
openWebUiProcessWebResponseSchema,
);
return { collectionName: parsed.collection_name, title: parsed.file?.meta?.name ?? url };
}
/**
* Retrieve the most relevant chunks for a query across the given collections
* (web pages and/or knowledge bases). Returns the chunk texts, best-effort.
*/
public async queryCollectionsAsync(collectionNames: string[], query: string, k = 4): Promise<string[]> {
if (collectionNames.length === 0 || query.trim() === "") {
return [];
}
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/query/collection",
{ collection_names: collectionNames, query, k },
openWebUiCollectionQueryResponseSchema,
);
return (parsed.documents ?? []).flat();
}
/**
* List the user's chats (native Open WebUI history).
*/
public async listChatsAsync(): Promise<OpenWebUiChatListItem[]> {
return await this.getJsonAsync("/api/v1/chats/", openWebUiChatListItemSchema.array());
}
/**
* Get a single chat including its messages.
*/
public async getChatAsync(chatId: string): Promise<OpenWebUiChat> {
return await this.getJsonAsync(`/api/v1/chats/${encodeURIComponent(chatId)}`, openWebUiChatSchema);
}
/**
* Create a new chat in the native history.
*/
public async createChatAsync(payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync("/api/v1/chats/new", { chat: payload }, openWebUiChatSchema);
}
/**
* Update an existing chat in the native history.
*/
public async updateChatAsync(chatId: string, payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync(
`/api/v1/chats/${encodeURIComponent(chatId)}`,
{ chat: payload },
openWebUiChatSchema,
);
}
/**
* Stream an assistant completion token-by-token.
* Reads the OpenAI-compatible SSE response and invokes `onDelta` for each
* content delta. Resolves when the stream completes ([DONE]) or is aborted.
*/
public async streamChatCompletionAsync(
params: { model: string; messages: OpenWebUiCompletionMessage[] },
onDelta: (delta: string) => void,
signal: AbortSignal,
): Promise<void> {
const response = await this.requestAsync("/api/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
// Long-lived stream: opt out of the default request timeout and rely on the
// caller's abort signal instead (the timeout path would also replace this
// signal, breaking cancellation).
timeout: undefined,
signal,
});
if (!response.body) {
throw new ResponseError(response);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processLine = (line: string): boolean => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return false;
const payload = trimmed.slice(5).trim();
if (payload === "") return false;
if (payload === "[DONE]") return true;
try {
const parsed = openWebUiCompletionChunkSchema.parse(JSON.parse(payload));
const choice = parsed.choices?.[0];
if (choice?.delta?.content) {
onDelta(choice.delta.content);
}
// Some providers end the stream with a finish_reason chunk and no
// explicit [DONE] sentinel; treat that as a clean completion too.
if (choice?.finish_reason) return true;
} catch (error) {
// The chunk can contain model output or grounded user data, so log only
// safe metadata rather than the raw payload.
logger.debug("Failed to parse Open WebUI completion chunk", {
error: error instanceof Error ? error.message : "unknown",
payloadLength: payload.length,
});
}
return false;
};
let completed = false;
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
// The server may close after the final data: event without a trailing
// newline; parse whatever is left so a valid completion isn't treated
// as a truncated stream.
if (buffer.trim() !== "" && processLine(buffer)) {
completed = true;
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processLine(line)) {
completed = true;
break;
}
}
if (completed) break;
}
} finally {
reader.cancel().catch(() => {
// ignore cancellation errors during teardown
});
}
// The reader ended without a [DONE]/finish_reason marker: the upstream
// connection was cut mid-stream. Surface it instead of presenting the
// partial reply as a complete answer.
if (!signal.aborted && !completed) {
throw new Error("Open WebUI stream ended before completion");
}
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
}
/**
* Issue an authenticated request against the Open WebUI API and throw a
* {@link ResponseError} on a non-2xx status. Centralizes the auth header,
* trusted-certificate fetch and error handling shared by every endpoint.
*/
private async requestAsync(
path: `/${string}`,
init?: Omit<RequestOptions, "headers"> & { headers?: Record<string, string> },
) {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
// Apply a default timeout unless the caller opts out (e.g. streaming
// completions pass `timeout: undefined` to stay open indefinitely). A
// spread after this key lets an explicit caller value win.
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
...init,
headers: { ...this.getAuthHeaders(), ...init?.headers },
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
/** GET an endpoint and validate the JSON body against `schema`. */
private async getJsonAsync<TOutput>(
path: `/${string}`,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path);
return schema.parse(await response.json());
}
/** POST a JSON body and validate the JSON response against `schema`. */
private async postJsonAsync<TOutput>(
path: `/${string}`,
body: unknown,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return schema.parse(await response.json());
}
/** POST a multipart form (file upload) and validate the JSON response. */
private async postFormAsync<TOutput>(
path: `/${string}`,
form: FormData,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, { method: "POST", body: form });
return schema.parse(await response.json());
}
}
@@ -1,238 +0,0 @@
// Open WebUI API types and schemas.
// Open WebUI exposes an OpenAI-compatible API plus its own native chat-history API.
// Schemas are intentionally defensive (`.nullish()`) because the native chat
// endpoints are undocumented and vary between Open WebUI versions.
import { z } from "zod/v4";
// GET /api/models — OpenAI-compatible. Open WebUI returns `{ data: [...] }`.
export const openWebUiModelSchema = z.object({
id: z.string(),
name: z.string().nullish(),
object: z.string().nullish(),
owned_by: z.string().nullish(),
// Open WebUI exposes per-model capabilities here when configured; `vision`
// tells us whether the model can accept image attachments.
info: z
.object({
meta: z
.object({
capabilities: z.object({ vision: z.boolean().nullish() }).nullish(),
})
.nullish(),
})
.nullish(),
});
export const openWebUiModelsResponseSchema = z.object({
data: z.array(openWebUiModelSchema),
});
export type OpenWebUiModel = z.infer<typeof openWebUiModelSchema>;
// POST /api/chat/completions (stream: true) — OpenAI-compatible SSE delta chunk.
export const openWebUiCompletionChunkSchema = z.object({
id: z.string().nullish(),
choices: z
.array(
z.object({
delta: z
.object({
role: z.string().nullish(),
content: z.string().nullish(),
})
.nullish(),
finish_reason: z.string().nullish(),
}),
)
.nullish(),
});
// Multimodal content part (OpenAI vision format). Lets a message carry text
// and/or images.
export const openWebUiContentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
// Message content is either plain text or multimodal parts. Both the completion
// and the native chat/history contracts use this so an attachment-backed message
// round-trips through create/update/get without parse failures or lossy drops.
export const openWebUiMessageContentSchema = z.union([z.string(), z.array(openWebUiContentPartSchema)]);
// A single chat message exchanged with the model / persisted in a chat.
export const openWebUiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
// A completion message can carry plain text or multimodal parts (text + images).
export const openWebUiCompletionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiCompletionMessage = z.infer<typeof openWebUiCompletionMessageSchema>;
// GET /api/v1/knowledge/ — knowledge bases (RAG collections owned by the user).
// Newer Open WebUI versions paginate the list as `{ items, total }`; older ones
// return a bare array. Accept both and normalise in the integration.
export const openWebUiKnowledgeSchema = z.object({
id: z.string(),
name: z.string().nullish(),
description: z.string().nullish(),
});
export type OpenWebUiKnowledge = z.infer<typeof openWebUiKnowledgeSchema>;
export const openWebUiKnowledgeListSchema = z.union([
z.array(openWebUiKnowledgeSchema),
z.object({ items: z.array(openWebUiKnowledgeSchema) }),
]);
// A file stored in Open WebUI (uploaded directly or part of a knowledge base).
// `meta.collection_name` is the knowledge base id when the file belongs to one.
export const openWebUiFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z
.object({
name: z.string().nullish(),
content_type: z.string().nullish(),
collection_name: z.string().nullish(),
})
.nullish(),
});
export type OpenWebUiFile = z.infer<typeof openWebUiFileSchema>;
export const openWebUiFileListSchema = z.union([
z.array(openWebUiFileSchema),
z.object({ items: z.array(openWebUiFileSchema) }),
]);
// POST /api/v1/files/ — upload response (we only need the new file id + name).
export const openWebUiUploadedFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z.object({ name: z.string().nullish() }).nullish(),
});
// GET /api/v1/knowledge/{id} — knowledge base with its files.
export const openWebUiKnowledgeDetailSchema = z.object({
id: z.string(),
name: z.string().nullish(),
files: z.array(openWebUiFileSchema).nullish(),
});
export type OpenWebUiKnowledgeDetail = z.infer<typeof openWebUiKnowledgeDetailSchema>;
// GET /api/v1/notes/ — user notes (markdown content under data.content.md).
export const openWebUiNoteSchema = z.object({
id: z.string(),
title: z.string().nullish(),
data: z.object({ content: z.object({ md: z.string().nullish() }).nullish() }).nullish(),
});
export const openWebUiNoteListSchema = z.union([
z.array(openWebUiNoteSchema),
z.object({ items: z.array(openWebUiNoteSchema) }),
]);
// A note flattened for the widget: id, title and its markdown body.
export interface OpenWebUiNote {
id: string;
title: string;
content: string;
}
// POST /api/v1/audio/transcriptions — speech-to-text result.
export const openWebUiTranscriptionSchema = z.object({ text: z.string() });
// A file flattened for the widget: id and a display name.
export interface OpenWebUiFileSummary {
id: string;
name: string;
}
// POST /api/v1/retrieval/process/web — ingests a URL into a temporary vector
// collection. We retrieve from that collection at send time and inject the
// results as context (Open WebUI does not fetch URLs at completion time).
export const openWebUiProcessWebResponseSchema = z.object({
collection_name: z.string(),
filename: z.string().nullish(),
file: z.object({ meta: z.object({ name: z.string().nullish() }).nullish() }).nullish(),
});
// POST /api/v1/retrieval/query/collection — top-k chunks per collection.
export const openWebUiCollectionQueryResponseSchema = z.object({
documents: z.array(z.array(z.string())).nullish(),
});
// A web page ingested for grounding: the collection to retrieve from + a label.
export interface OpenWebUiWebDocument {
collectionName: string;
title: string;
}
// GET /api/v1/chats/ — list of the user's chats (native Open WebUI shape).
export const openWebUiChatListItemSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
});
export type OpenWebUiChatListItem = z.infer<typeof openWebUiChatListItemSchema>;
// GET /api/v1/chats/{id} — full chat.
// Two message representations exist: a flat `chat.messages` list (used by chats
// we create) and a `chat.history` tree keyed by message id with a `currentId`
// leaf (used by chats created in the Open WebUI UI). The history tree is
// authoritative when present, since `chat.messages` can be stale/partial.
export const openWebUiHistoryMessageSchema = z.object({
id: z.string().nullish(),
parentId: z.string().nullish(),
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
});
export const openWebUiChatSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
chat: z
.object({
title: z.string().nullish(),
models: z.array(z.string()).nullish(),
messages: z
.array(
z.object({
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
}),
)
.nullish(),
history: z
.object({
currentId: z.string().nullish(),
messages: z.record(z.string(), openWebUiHistoryMessageSchema).nullish(),
})
.nullish(),
})
.nullish(),
});
export type OpenWebUiChat = z.infer<typeof openWebUiChatSchema>;
// Body used when creating/updating a chat through the native API. `meta.tags`
// lets Homarr tag a conversation (e.g. with the owning Homarr user id) so it can
// be found back in Open WebUI even when an integration/account is shared.
export interface OpenWebUiChatPayload {
title: string;
models: string[];
messages: OpenWebUiChatMessage[];
meta?: { tags?: string[] };
}
@@ -1,230 +0,0 @@
import { Request, Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OpenWebUiIntegration } from "../open-webui-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_API_KEY = "test-api-key-12345";
const TEST_URL = "https://openwebui.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const asFetchResult = (response: Response) =>
response as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const setupMockFetch = (responses: Record<string, Record<string, unknown> | unknown[]>) => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const path = new URL(urlString).pathname;
if (path in responses) {
return Promise.resolve(
asFetchResult(
new Response(JSON.stringify(responses[path]), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
}
return Promise.resolve(asFetchResult(new Response(JSON.stringify({ error: "Not Found" }), { status: 404 })));
});
};
const streamFrom = (chunks: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const createIntegration = () =>
new OpenWebUiIntegration({
id: "test-open-webui",
name: "Test Open WebUI",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
});
describe("OpenWebUiIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getModelsAsync", () => {
test("parses the OpenAI-compatible model list", async () => {
setupMockFetch({
"/api/models": { data: [{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }] },
});
const result = await createIntegration().getModelsAsync();
expect(result).toEqual([{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }]);
expect(mockFetch).toHaveBeenCalled();
});
test("sends the bearer token", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const headers = mockFetch.mock.calls.at(0)?.[1]?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBe(`Bearer ${TEST_API_KEY}`);
});
test("throws when the API returns an error", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Unauthorized", { status: 401 })));
await expect(createIntegration().getModelsAsync()).rejects.toThrow();
});
});
describe("streamChatCompletionAsync", () => {
test("emits each content delta and resolves on [DONE]", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hel" } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { content: "lo" } }] })}\n`,
"data: [DONE]\n",
]),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas).toEqual(["Hel", "lo"]);
});
test("handles a delta split across stream chunks", async () => {
const payload = JSON.stringify({ choices: [{ delta: { content: "world" } }] });
const half = Math.floor(payload.length / 2);
mockFetch.mockResolvedValue(
asFetchResult(
new Response(streamFrom([`data: ${payload.slice(0, half)}`, `${payload.slice(half)}\n`, "data: [DONE]\n"]), {
status: 200,
}),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas.join("")).toBe("world");
});
test("throws when the completion endpoint errors", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Bad Gateway", { status: 502 })));
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
),
).rejects.toThrow();
});
test("flushes the final SSE frame when it has no trailing newline", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "done" } }] })}\n`,
// Final event: no trailing newline and ends via finish_reason (no [DONE]).
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}`,
]),
{ status: 200 },
),
),
);
const deltas: string[] = [];
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
),
).resolves.toBeUndefined();
expect(deltas.join("")).toBe("done");
});
});
describe("requestAsync timeouts", () => {
test("applies a default timeout to non-streaming requests", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeGreaterThan(0);
});
test("does not time out the long-lived streaming completion", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response(streamFrom(["data: [DONE]\n"]), { status: 200 })));
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
);
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeUndefined();
});
});
describe("getChatAsync", () => {
test("parses a chat whose message content is multimodal (text + image parts)", async () => {
setupMockFetch({
"/api/v1/chats/abc": {
id: "abc",
chat: {
title: "Vision",
models: ["llava"],
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is this?" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
],
},
{ role: "assistant", content: "A cat." },
],
},
},
});
const chat = await createIntegration().getChatAsync("abc");
expect(chat.id).toBe("abc");
expect(chat.chat?.messages?.[0]?.content).toBeDefined();
});
});
});
-1
View File
@@ -26,5 +26,4 @@ export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
export * from "./open-webui/open-webui-types";
export * from "./traefik/traefik-types";
@@ -1,7 +1,6 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -59,7 +58,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
-109
View File
@@ -1,109 +0,0 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
} from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const openWebUiModelsRequestHandler = createIntegrationRequestHandler<
OpenWebUiModel[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getModelsAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeRequestHandler = createIntegrationRequestHandler<
OpenWebUiKnowledge[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
{ knowledgeId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeFilesAsync(input.knowledgeId);
},
cacheTtlMs: dayjs.duration(1, "minute").asMilliseconds(),
});
export const openWebUiFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listFilesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNotesRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listNotesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNoteRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote,
"openWebUi",
{ noteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNoteAsync(input.noteId);
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiChatsRequestHandler = createIntegrationRequestHandler<
OpenWebUiChatListItem[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listChatsAsync();
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
export const openWebUiChatRequestHandler = createIntegrationRequestHandler<
OpenWebUiChat,
"openWebUi",
{ chatId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getChatAsync(input.chatId);
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
+3 -13
View File
@@ -1,9 +1,9 @@
"use client";
import { mediaRequestSearchEvent, openWebUiChatEvent, spotlightActions } from "./spotlight-store";
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight, openMediaRequestSearch, openOpenWebUiChat };
export { openSpotlight, openMediaRequestSearch };
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
@@ -25,14 +25,4 @@ const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) =>
spotlightActions.open();
};
export { mediaRequestSearchEvent, openWebUiChatEvent };
// Opens the Open WebUI chat side panel, optionally seeding it with a query to
// start a conversation from. Closes the spotlight so the panel takes over.
const openOpenWebUiChat = (query?: string) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } }));
}
spotlightActions.close();
};
export { mediaRequestSearchEvent };
@@ -1,61 +0,0 @@
import { Group, Text } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { openWebUiChatEvent } from "../../spotlight-store";
interface AskAiOption {
key: string;
query: string;
}
// Opening the panel is decoupled from the app via a window event so the
// spotlight package doesn't depend on the Next.js app.
const dispatchOpenChat = (query: string) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } }));
}
};
// "Ask AI" — starts an Open WebUI conversation from the spotlight query.
export const askAiGroup = createGroup<AskAiOption>({
keyPath: "key",
title: (t) => t("search.mode.askAi.title"),
Component: (option) => {
const t = useScopedI18n("search.mode.askAi.group.ask");
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<IconRobot stroke={1.5} />
<Text>{option.query ? t("startWithQuery", { query: option.query }) : t("hint")}</Text>
</Group>
);
},
useInteraction: interaction.javaScript((_option, query) => ({
onSelect() {
dispatchOpenChat(query);
},
closeSpotlightOnTrigger: true,
})),
useQueryOptions(query) {
const { data: session } = useSession();
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id,
);
return {
data: hasOpenWebUiIntegration ? [{ key: "ask-ai", query }] : [],
isLoading: false,
isError: false,
};
},
});
@@ -1,8 +0,0 @@
import type { SearchMode } from "../../lib/mode";
import { askAiGroup } from "./ask-ai-group";
export const askAiMode = {
modeKey: "askAi",
character: "?",
groups: [askAiGroup],
} satisfies SearchMode;
@@ -8,7 +8,6 @@ import {
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
@@ -18,7 +17,6 @@ import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board";
import { askAiMode } from "../ask-ai";
import { commandMode } from "../command";
import { externalMode } from "../external";
import { mediaMode } from "../media";
@@ -42,18 +40,6 @@ export const useHomeEmptyGroups = () => {
visibleSearchModes.unshift(userGroupMode);
}
// Only surface the "Ask AI" mode when the user owns an Open WebUI integration.
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
if (
integrations.some((integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id)
) {
visibleSearchModes.push(askAiMode);
}
return [
createGroup({
keyPath: "key",
-2
View File
@@ -1,5 +1,4 @@
import { appIntegrationBoardMode } from "./app-integration-board";
import { askAiMode } from "./ask-ai";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
@@ -14,6 +13,5 @@ export const searchModes = [
commandMode,
pageMode,
mediaMode,
askAiMode,
homeMode,
] as const;
@@ -14,10 +14,6 @@ export interface MediaRequestSearchScope {
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
// Dispatched to open the Open WebUI chat side panel (optionally with a query to
// start a conversation from). The app-level provider listens for this.
export const openWebUiChatEvent = "homarr:spotlight:open-web-ui-chat";
export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index }));
};
+61 -66
View File
@@ -1489,7 +1489,6 @@
"switchToLightMode": "Switch to light mode",
"management": "Management",
"docker": "Docker",
"aiChat": "AI Chat",
"preferences": "Your preferences",
"logout": "Logout",
"login": "Login",
@@ -1957,6 +1956,8 @@
"clear": "Clear formatting",
"heading": "Heading {level}",
"align": "Align text: {position}",
"directionLtr": "Set text direction left to right",
"directionRtl": "Set text direction right to left",
"blockquote": "Blockquote",
"horizontalLine": "Horizontal line",
"bulletList": "Bullet list",
@@ -3849,61 +3850,6 @@
"executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration."
},
"openWebUi": {
"name": "AI Chat",
"description": "Chat with AI models through Open WebUI, with model picking and history.",
"option": {
"systemPrompt": {
"label": "System prompt",
"description": "Optional instruction prepended to every conversation."
},
"showHistory": {
"label": "Show chat history",
"description": "Show a button to browse and reopen previous Open WebUI chats."
}
},
"selectModel": "Select a model",
"searchModel": "Search models…",
"noModels": "No models available",
"emptyState": "Start a conversation",
"messagePlaceholder": "Type a message…",
"newChat": "New chat",
"history": "History",
"back": "Back",
"send": "Send",
"stop": "Stop",
"retry": "Retry",
"visionWarning": "This model may not support images",
"attach": "Attach",
"uploadFiles": "Upload Files",
"capture": "Capture",
"attachImage": "Attach image",
"attachKnowledge": "Knowledge",
"noKnowledge": "No knowledge bases in Open WebUI",
"attachWebpage": "Webpage",
"attachFiles": "Files",
"attachNotes": "Notes",
"referenceChats": "Reference Chats",
"noFiles": "No files in Open WebUI",
"noNotes": "No notes in Open WebUI",
"noChats": "No previous chats",
"addUrl": "Add URL",
"removeAttachment": "Remove attachment",
"webError": "Could not load that web page",
"uploadError": "Could not upload the file",
"captureError": "Could not access the camera",
"chatError": "Could not load that chat",
"noteError": "Could not load that note",
"record": "Record voice message",
"stopRecording": "Stop recording",
"cancelRecording": "Cancel recording",
"micError": "Could not access the microphone",
"micPermissionError": "Microphone access was denied. Allow it in your browser to record voice messages.",
"transcribeError": "Could not transcribe the recording",
"error": {
"internalServerError": "Failed to reach Open WebUI"
}
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
@@ -4508,6 +4454,61 @@
}
}
},
"workshop": {
"title": "Workshop",
"loading": "Loading submission",
"by": "By {name} · revision {revision}",
"type": {
"widget": "Widget",
"css": "CSS theme"
},
"search": {
"label": "Search",
"placeholder": "Search community creations"
},
"filter": {
"type": "Type",
"sort": "Sort",
"all": "All",
"widgets": "Widgets",
"css": "CSS themes",
"top": "Top rated",
"newest": "Newest"
},
"safety": {
"title": "Community content",
"description": "Workshop content is provided by community members. Inspect it before installing and keep a current backup.",
"inspect": "Review this source before using it. Widget endpoints and CSS can change how your instance behaves."
},
"empty": {
"title": "No creations found",
"description": "Adjust the filters or check back after the community has shared more."
},
"error": {
"loadTitle": "Workshop could not load",
"publish": "The creation could not be published",
"install": "The creation could not be used",
"wrongType": "This creation does not match the requested type"
},
"publish": {
"title": "Share in Workshop",
"name": "Title",
"description": "Description",
"changelog": "Changelog"
},
"action": {
"retry": "Try again",
"inspect": "Inspect source",
"download": "Download",
"website": "Open website",
"use": "Use creation",
"share": "Share in Workshop",
"cancel": "Cancel",
"publish": "Publish creation",
"install": "Install widget",
"useCss": "Use CSS"
}
},
"management": {
"metaTitle": "Management",
"title": {
@@ -4531,6 +4532,7 @@
"apps": "Apps",
"integrations": "Integrations",
"customWidgets": "Custom Widgets",
"workshop": "Workshop",
"searchEngies": "Search engines",
"medias": "Medias",
"users": {
@@ -5259,6 +5261,9 @@
"title": "Containers",
"table": {
"updated": "Updated {when}",
"refresh": {
"lastUpdated": "Last refresh {when}"
},
"search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers",
@@ -5789,16 +5794,6 @@
"fetch": "An error occurred while fetching data"
},
"mode": {
"askAi": {
"help": "Start an AI chat",
"title": "AI Chat",
"group": {
"ask": {
"startWithQuery": "Ask AI: {query}",
"hint": "Start an AI chat"
}
}
},
"appIntegrationBoard": {
"help": "Search for apps, integrations or boards",
"group": {
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more