Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c950a345 | ||
|
|
06590714c3 | ||
|
|
4cee00c850 | ||
|
|
e4b8693f64 | ||
|
|
6b2007c647 |
No files matched your search
@@ -6,14 +6,7 @@ npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.turbo
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
coverage
|
||||
**/coverage
|
||||
**/.docusaurus
|
||||
**/build
|
||||
.git
|
||||
.github
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
|
||||
+1
-20
@@ -59,23 +59,4 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
# ENABLE_KUBERNETES=true
|
||||
|
||||
# Enable mock integration
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
||||
# DEMO_MODE=true
|
||||
|
||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
||||
# DEMO_READ_ONLY=false
|
||||
# Central Workshop API is https://homarr.dev. Override only when developing
|
||||
# the standalone Docusaurus or Homarr clients against a local backend.
|
||||
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
|
||||
# WORKSHOP_API_URL=http://localhost:8090
|
||||
|
||||
# Workshop development ports. PocketBase superusers and GitHub OAuth are
|
||||
# configured once through the persistent PocketBase dashboard, not environment variables.
|
||||
PB_EXPOSE_PORT=8090
|
||||
WORKSHOP_WEB_PORT=3003
|
||||
# Workshop is intentionally consumable by arbitrary self-hosted Homarr origins.
|
||||
# PocketBase is stateless and authorizes writes with bearer tokens, not cookies.
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
@@ -33,8 +33,6 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.70.0
|
||||
- 1.69.2
|
||||
- 1.69.0
|
||||
- 1.68.0
|
||||
- 1.67.0
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
.secrets
|
||||
!.secrets.example
|
||||
@@ -1,4 +0,0 @@
|
||||
TURBO_API=
|
||||
TURBO_TEAM=
|
||||
TURBO_TOKEN=
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
|
||||
@@ -1,36 +0,0 @@
|
||||
# Running CI locally
|
||||
|
||||
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
|
||||
|
||||
```bash
|
||||
pnpm ci:act:fast
|
||||
pnpm ci:act:docker
|
||||
pnpm ci:act:all
|
||||
pnpm ci:act:cleanup
|
||||
```
|
||||
|
||||
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
|
||||
|
||||
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
|
||||
|
||||
```bash
|
||||
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
|
||||
```
|
||||
|
||||
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
|
||||
|
||||
## PR preview images
|
||||
|
||||
Successful non-draft pull requests from this repository publish an amd64 image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
|
||||
```
|
||||
|
||||
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
|
||||
|
||||
## Dokploy preview cache
|
||||
|
||||
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
|
||||
|
||||
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
|
||||
@@ -11,7 +11,7 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -11,11 +11,3 @@
|
||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
|
||||
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
|
||||
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
|
||||
|
||||
**When contributing a new integration or widget:**
|
||||
|
||||
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
|
||||
tests (e.g. what happens if it is offline)
|
||||
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
|
||||
provide support and maintain the integration (e.g. in case of breaking or failure)
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Auto-fix lint and format
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: ["*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
name: Auto-fix
|
||||
if: github.event.pull_request.head.repo.id == github.repository_id
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Get changed files
|
||||
id: changed
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
|
||||
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Auto-fix lint + format
|
||||
if: steps.changed.outputs.count != '0'
|
||||
run: |
|
||||
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
|
||||
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
||||
|
||||
- name: Commit fixes
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "style: auto-fix lint and format"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches: ["*"]
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: [main, dev, beta, next]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
operation:
|
||||
description: Operation to run
|
||||
required: true
|
||||
default: validate
|
||||
type: choice
|
||||
options:
|
||||
- validate
|
||||
- publish
|
||||
- cleanup
|
||||
pr_number:
|
||||
description: PR number for publish or cleanup
|
||||
required: false
|
||||
type: number
|
||||
ref:
|
||||
description: Git ref to validate or publish
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
SKIP_ENV_VALIDATION: true
|
||||
TURBO_TELEMETRY_DISABLED: 1
|
||||
REGISTRY: ghcr.io
|
||||
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
|
||||
|
||||
jobs:
|
||||
fast-gate:
|
||||
name: Fast gate
|
||||
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
TURBO_PLATFORM: linux/amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: '!env.ACT'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Validate manual inputs
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
BUILD_REF: ${{ inputs.ref }}
|
||||
run: |
|
||||
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
|
||||
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Copy environment defaults
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Check formatting in affected workspaces
|
||||
run: pnpm turbo run format --affected --continue -- --check
|
||||
|
||||
- name: Lint, typecheck, and build affected workspaces
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
||||
|
||||
- name: Validate workspace configuration
|
||||
run: pnpm lint:ws
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Report coverage
|
||||
if: always() && !env.ACT
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
|
||||
container:
|
||||
name: Container, E2E, and memory
|
||||
needs: fast-gate
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: '!env.ACT'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: '!env.ACT'
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build container once
|
||||
if: '!env.ACT'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
network: host
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: |
|
||||
homarr-ci
|
||||
homarr-e2e
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Build container once with Act
|
||||
if: env.ACT
|
||||
env:
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
run: |
|
||||
secret_args=()
|
||||
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
|
||||
if [ -n "${!secret:-}" ]; then
|
||||
secret_args+=(--secret "id=${secret},env=${secret}")
|
||||
fi
|
||||
done
|
||||
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
||||
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: '!env.ACT'
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Run memory regression test
|
||||
env:
|
||||
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
run: |
|
||||
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
|
||||
-e NO_EXTERNAL_CONNECTION=true \
|
||||
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
|
||||
homarr-ci
|
||||
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
|
||||
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
|
||||
URL="http://127.0.0.1:${PORT}"
|
||||
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
warmup_ok=0
|
||||
for _ in $(seq 1 5); do
|
||||
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$warmup_ok" -eq 0 ]; then
|
||||
echo "ERROR: All warmup requests failed"
|
||||
docker logs "$CONTAINER_NAME" --tail 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
|
||||
echo "ERROR: Container died before memory measurement"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
}
|
||||
|
||||
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
|
||||
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
|
||||
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
|
||||
/MiB/ { gsub(/MiB/, ""); print $1; next }
|
||||
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
|
||||
{ print $1 / 1048576 }
|
||||
')
|
||||
|
||||
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
|
||||
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
|
||||
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
}
|
||||
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
|
||||
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
|
||||
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Clean up memory test container
|
||||
if: always()
|
||||
env:
|
||||
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
- name: Determine preview tag
|
||||
id: preview
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.preview.outputs.tag != ''
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push preview image
|
||||
if: steps.preview.outputs.tag != ''
|
||||
env:
|
||||
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
|
||||
run: |
|
||||
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
|
||||
- name: Show Act publication result
|
||||
if: env.ACT
|
||||
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
packages: write
|
||||
env:
|
||||
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
steps:
|
||||
- name: Validate cleanup input
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
||||
|
||||
- name: Delete GHCR package version
|
||||
if: '!env.ACT'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const packageName = "homarr-test";
|
||||
const org = context.repo.owner;
|
||||
const tag = process.env.IMAGE_TAG;
|
||||
const versions = await github.paginate(
|
||||
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
|
||||
{ package_type: "container", package_name: packageName, org, per_page: 100 },
|
||||
);
|
||||
const target = versions.find((version) =>
|
||||
version.metadata?.container?.tags?.includes(tag),
|
||||
);
|
||||
if (!target) {
|
||||
core.info(`No package version found with tag "${tag}"`);
|
||||
return;
|
||||
}
|
||||
await github.rest.packages.deletePackageVersionForOrg({
|
||||
package_type: "container",
|
||||
package_name: packageName,
|
||||
org,
|
||||
package_version_id: target.id,
|
||||
});
|
||||
core.info(`Deleted package version ${target.id} (${tag})`);
|
||||
|
||||
- name: Show Act cleanup result
|
||||
if: env.ACT
|
||||
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
|
||||
@@ -0,0 +1,227 @@
|
||||
name: "[Quality] Code Analysis"
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
SKIP_ENV_VALIDATION: true
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
lint-format-autofix:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Get changed files
|
||||
id: changed
|
||||
run: |
|
||||
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
|
||||
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Auto-fix lint + format
|
||||
if: steps.changed.outputs.count != '0'
|
||||
run: |
|
||||
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
|
||||
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
||||
|
||||
- name: Commit fixes
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "style: auto-fix lint and format"
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Lint
|
||||
run: pnpm turbo lint --affected && pnpm lint:ws
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Format
|
||||
run: pnpm turbo format --affected --continue
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm turbo typecheck --affected
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: "Report Coverage"
|
||||
# Set if: always() to also generate the report if tests are failing
|
||||
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
||||
if: always()
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Build docker image
|
||||
id: build-docker-image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: homarr-e2e
|
||||
network: host
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Install playwright browsers
|
||||
run: pnpm exec playwright install chromium
|
||||
- name: Run E2E Tests
|
||||
shell: bash
|
||||
run: pnpm test:e2e
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: cp .env.example .env
|
||||
- name: Build
|
||||
run: pnpm turbo build --affected
|
||||
|
||||
memory-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: homarr-memory-test
|
||||
network: host
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Start container and measure memory
|
||||
run: |
|
||||
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
|
||||
# Wait for container to be healthy (up to 60s)
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
# Warm up: hit the dashboard page a few times
|
||||
for i in $(seq 1 5); do
|
||||
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
|
||||
sleep 2
|
||||
done
|
||||
# Measure RSS after warmup
|
||||
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
|
||||
echo "Raw memory: $MEM_KB"
|
||||
# Convert to numeric MB (handles GiB and MiB suffixes)
|
||||
MEM_NUM=$(echo "$MEM_KB" | awk '{
|
||||
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
|
||||
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
|
||||
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
|
||||
else { print $1 / 1048576 }
|
||||
}')
|
||||
echo "Memory usage: ${MEM_NUM} MB"
|
||||
MAX_MB=700
|
||||
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
|
||||
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Upload Crowdin translations
|
||||
uses: crowdin/github-action@v2
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get Next Version
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
|
||||
@@ -148,11 +148,6 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
build-arm64:
|
||||
@@ -163,7 +158,7 @@ jobs:
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
|
||||
@@ -192,11 +187,6 @@ jobs:
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
extract-asset-amd64:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: "[Deployment] Documentation"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev"]
|
||||
paths:
|
||||
- "apps/docs/**"
|
||||
- "packages/definitions/src/docs/**"
|
||||
- ".github/workflows/deployment-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Build docs
|
||||
run: pnpm turbo build --filter=@homarr/docs
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: "apps/docs/build"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get Next Version
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Validate prebuilt dependencies for amd64
|
||||
id: validate-amd64
|
||||
uses: ./.github/actions/prebuilt-debian
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Validate prebuilt dependencies for arm64
|
||||
id: validate-arm64
|
||||
uses: ./.github/actions/prebuilt-debian
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- run: |
|
||||
npx --yes --package renovate -- \
|
||||
renovate-config-validator --strict .github/renovate.json5
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ steps.obtainToken.outputs.token }}
|
||||
ref: dev
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
- name: Setup
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths: &workshop-paths
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/docusaurus.config.ts"
|
||||
- "apps/docs/package.json"
|
||||
- "apps/docs/src/components/workshop/**"
|
||||
- "apps/docs/src/pages/workshop/**"
|
||||
- "apps/docs/src/theme/Root.tsx"
|
||||
- "apps/docs/static/workshop-*"
|
||||
- "apps/docs/docs/management/workshop/**"
|
||||
- "packages/workshop/**"
|
||||
- "packages/validation/**"
|
||||
- "packages/definitions/**"
|
||||
- "patches/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".github/workflows/workshop.yml"
|
||||
push:
|
||||
branches: [dev]
|
||||
paths: *workshop-paths
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: workshop-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
production-preview:
|
||||
name: Production preview
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Copy environment defaults
|
||||
run: cp .env.example .env
|
||||
- name: Test Workshop backend
|
||||
run: pnpm test:workshop-backend
|
||||
- name: Build and smoke-test Workshop
|
||||
run: pnpm test:workshop-preview
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
score: 9
|
||||
p0: 0
|
||||
p1: 1
|
||||
timestamp: 2026-07-13T21-26-11Z
|
||||
slug: apps-docs-src-components-workshop
|
||||
---
|
||||
# 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 image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
|
||||
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
|
||||
- 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.
|
||||
- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests.
|
||||
- [ ] 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 runs unprivileged and builds with checksum verification; clean boot and a cloned legacy SQLite volume both migrate and become healthy. |
|
||||
| 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/mobile discovery and the disconnected API state render without uncaught errors; cached data remains readable and retry is explicit. |
|
||||
| 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.
|
||||
@@ -1,17 +1,3 @@
|
||||
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
|
||||
|
||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **layout:** raise AppShellHeader z-index above Indicator dots ([687edb9](https://github.com/homarr-labs/homarr/commit/687edb9226307cb83986097abb24fe7f672e1778)), closes [#6194](https://github.com/homarr-labs/homarr/issues/6194)
|
||||
|
||||
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* unblock query cache mutations in demo mode and add cache buster ([#6176](https://github.com/homarr-labs/homarr/issues/6176)) ([a382c75](https://github.com/homarr-labs/homarr/commit/a382c75968e73bafff55b5890b1e95f0779b50d3))
|
||||
|
||||
## [1.69.0](https://github.com/homarr-labs/homarr/compare/v1.68.1...v1.69.0) (2026-07-03)
|
||||
|
||||
### Features
|
||||
|
||||
-27
@@ -19,14 +19,6 @@ packages/integrations/src/anchor @ohmzi
|
||||
apps/docs/docs/integrations/archive-team-warrior @darbyjack
|
||||
packages/integrations/src/archive-team-warrior @darbyjack
|
||||
|
||||
# Bazarr - introduced in https://github.com/homarr-labs/homarr/pull/6156 by @Ayush7614
|
||||
apps/docs/docs/integrations/bazarr @Ayush7614
|
||||
apps/docs/docs/widgets/bazarr @Ayush7614
|
||||
packages/api/src/router/widgets/bazarr.ts @Ayush7614
|
||||
packages/integrations/src/bazarr @Ayush7614
|
||||
packages/request-handler/src/bazarr.ts @Ayush7614
|
||||
packages/widgets/src/bazarr @Ayush7614
|
||||
|
||||
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
|
||||
apps/docs/docs/integrations/audiobookshelf @ajnart
|
||||
packages/integrations/src/audiobookshelf @ajnart
|
||||
@@ -135,13 +127,6 @@ packages/integrations/src/overseerr @manuel-rw
|
||||
apps/docs/docs/integrations/paperless-ngx @ajnart
|
||||
packages/integrations/src/paperless-ngx @ajnart
|
||||
|
||||
# PatchMon - introduced in https://github.com/homarr-labs/homarr/pull/6119 by @mrjoshuap
|
||||
apps/docs/docs/integrations/patchmon @mrjoshuap
|
||||
packages/integrations/src/patchmon @mrjoshuap
|
||||
packages/widgets/src/patchmon @mrjoshuap
|
||||
packages/api/src/router/widgets/patchmon.ts @mrjoshuap
|
||||
packages/request-handler/src/patchmon.ts @mrjoshuap
|
||||
|
||||
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
|
||||
apps/docs/docs/integrations/peanut @afonsojramos
|
||||
packages/integrations/src/peanut @afonsojramos
|
||||
@@ -174,10 +159,6 @@ packages/integrations/src/search-ch @Meierschlumpf
|
||||
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
|
||||
packages/integrations/src/speedtest-tracker @kylegrieder
|
||||
|
||||
# Synology - introduced in https://github.com/homarr-labs/homarr/pull/6121 by @mrjoshuap
|
||||
apps/docs/docs/integrations/synology @mrjoshuap
|
||||
packages/integrations/src/synology @mrjoshuap
|
||||
|
||||
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
|
||||
apps/docs/docs/integrations/technitium-dns @lopsch
|
||||
packages/integrations/src/technitium @lopsch
|
||||
@@ -206,14 +187,6 @@ packages/integrations/src/unraid @manuel-rw
|
||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||
packages/integrations/src/uptime-kuma @ajnart
|
||||
|
||||
# Traefik - by @Nerdy-Technician
|
||||
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
||||
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
||||
packages/api/src/router/widgets/traefik.ts @Nerdy-Technician
|
||||
packages/integrations/src/traefik @Nerdy-Technician
|
||||
packages/request-handler/src/traefik.ts @Nerdy-Technician
|
||||
packages/widgets/src/traefik @Nerdy-Technician
|
||||
|
||||
# Exempt Renovate-managed files (no owners)
|
||||
package.json
|
||||
**/package.json
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Workshop design system
|
||||
|
||||
Workshop is a trustworthy community workbench inside Homarr, not a separate storefront brand.
|
||||
|
||||
## Visual direction
|
||||
|
||||
- Reuse the host surface: Docusaurus tokens on `homarr.dev`, Mantine components inside Homarr.
|
||||
- Use Homarr red only for primary action and focus; warnings use restrained amber and errors use red with explanatory text.
|
||||
- Keep surfaces flat, bordered, and readable. Cards may lift by 3px on pointer hover; no gradients, glass panels, or decorative dashboards.
|
||||
- Typography follows each host. Headings are compact and confident; body copy stays warm, concrete, and short.
|
||||
- Spacing follows an 8px rhythm, 12-16px corners, and 44px minimum controls.
|
||||
|
||||
## Interaction direction
|
||||
|
||||
- The catalog progression is discover → inspect → confirm → use.
|
||||
- Every remote state has a named loading, empty, cached, unavailable, validation, permission, and success outcome.
|
||||
- Community source is never executed on the website. Installation always follows source inspection and local validation.
|
||||
- Motion is limited to 150-200ms hover/state feedback and skeleton loading, with reduced-motion fallbacks.
|
||||
- Native dialogs or Mantine modals own focus, Escape, backdrop dismissal, and keyboard order.
|
||||
|
||||
## Voice
|
||||
|
||||
Clear, dependable, and community-minded. Explain what remains usable during failures and provide one direct recovery action. Avoid playful language during moderation, destructive actions, or service failures.
|
||||
+13
-32
@@ -1,44 +1,24 @@
|
||||
# syntax=docker/dockerfile:1.25
|
||||
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ARG CI='true'
|
||||
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||
|
||||
RUN corepack enable pnpm
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY patches ./patches
|
||||
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||
# content-addressable store is populated). By temporarily switching to the
|
||||
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
|
||||
# which IS created by pnpm fetch. The original hoisted linker is restored
|
||||
# before the install step so the final node_modules layout stays flat.
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
||||
pnpm fetch && \
|
||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||
|
||||
RUN apk add --no-cache libc6-compat curl bash
|
||||
RUN apk update
|
||||
COPY . .
|
||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||
# --offline enforces no registry access (all packages are in the store from fetch)
|
||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive --offline
|
||||
|
||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||
|
||||
# Copy static data as it is not part of the build
|
||||
COPY static-data ./static-data
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
ARG DISABLE_REDIS_LOGS='true'
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
|
||||
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
|
||||
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
||||
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
|
||||
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -82,6 +62,7 @@ COPY nginx.conf /etc/nginx/templates/nginx.conf
|
||||
ENV DB_URL='/appdata/db/db.sqlite'
|
||||
ENV DB_DIALECT='sqlite'
|
||||
ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr serves people who organize and operate self-hosted services, from individual home-lab users to administrators managing shared instances. Workshop also serves community creators who publish and install custom widgets and CSS themes. Users need the core dashboard to remain useful even when optional community services are offline.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Homarr provides a dependable dashboard for discovering, organizing, and operating applications and integrations. Workshop adds a central community catalog where users can inspect, export, publish, report, and install creations with clear source visibility and explicit confirmation.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Clear, dependable, and community-minded. Interface copy should be direct and practical, with enough context for users to recover from failures without exposing internal implementation details.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Avoid fragmented feature surfaces, generic failure messages, decorative dashboard patterns, unexpected network dependencies, and optional services that can crash or block core Homarr workflows.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Keep core Homarr workflows available when optional services fail.
|
||||
- Explain failures in user terms and always offer a useful recovery action.
|
||||
- Use native Homarr patterns and shared contracts instead of feature-specific interaction systems.
|
||||
- Make community content inspectable and require explicit confirmation before installation.
|
||||
- Keep operational behavior deterministic for contributors and instance administrators.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Meet WCAG 2.1 AA for contrast, focus, semantics, and keyboard operation. Respect reduced-motion preferences, preserve useful layouts at 200% zoom and on mobile, and ensure status changes and recovery actions are understandable without relying on color alone.
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.60.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: 'Bazarr'
|
||||
description: "Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from '@site/src/components/integrations/header';
|
||||
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
|
||||
import { AddingIntegration } from '@site/src/components/integrations/adding';
|
||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||
import { bazarrIntegration } from '.';
|
||||
import { bazarrWidget } from '@site/docs/widgets/bazarr';
|
||||
|
||||
<IntegrationHeader
|
||||
integration={bazarrIntegration}
|
||||
categories={['Subtitles', 'Media']}
|
||||
/>
|
||||
|
||||
Bazarr complements Sonarr and Radarr by managing subtitle downloads. The Homarr integration uses your Bazarr API key to fetch missing subtitle counts and health badges.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import apiKeySettings from './img/api-key-settings.png';
|
||||
import widgetOverview from '@site/docs/widgets/bazarr/img/widget-overview.png';
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p>API key location in Bazarr (<strong>Settings → General → Security</strong>):</p>
|
||||
<img src={apiKeySettings} alt="bazarr-api-key-settings" width={128 * 4} />
|
||||
</div>
|
||||
<div>
|
||||
<p>Bazarr widget on a Homarr board:</p>
|
||||
<img src={widgetOverview} alt="bazarr-widget-on-board" width={128 * 4} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[{ widget: bazarrWidget }]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
You can find your Bazarr API key under **Settings → General → Security**.
|
||||
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['apiKey'],
|
||||
steps: [
|
||||
"Open Bazarr and go to Settings → General → Security.",
|
||||
"Copy the API key and paste it into Homarr when creating the integration.",
|
||||
],
|
||||
}]} />
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const bazarrIntegration: IntegrationDefinition = {
|
||||
name: "Bazarr",
|
||||
description:
|
||||
"Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles for your media library.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
|
||||
path: "../../integrations/bazarr",
|
||||
};
|
||||
@@ -39,26 +39,13 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
||||
'Scroll down to "API Keys"',
|
||||
'Click "New API Key"',
|
||||
'Enter a name, e.g. "Homarr"',
|
||||
(
|
||||
|
||||
<span>
|
||||
|
||||
Select the following permissions:{" "}
|
||||
<code>album.statistics</code>,{" "}
|
||||
<code>album.read</code>,{" "}
|
||||
<code>album.create</code>,{" "}
|
||||
<code>user.read</code>,{" "}
|
||||
<code>user.update</code>,{" "}
|
||||
<code>asset.read</code>,{" "}
|
||||
<code>asset.download</code>,{" "}
|
||||
<code>asset.statistics</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>server.about</code>.
|
||||
<br />
|
||||
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
|
||||
</span>
|
||||
|
||||
),
|
||||
(<span>
|
||||
Select the following permissions:{" "}
|
||||
<code>album.read</code>,{" "}
|
||||
<code>user.read</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>asset.download</code>
|
||||
</span>),
|
||||
'Create the integration in Homarr and copy the API key to there'
|
||||
]
|
||||
}]} />
|
||||
}]} />
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
title: 'PatchMon'
|
||||
description: "PatchMon is a patch management and monitoring solution for Linux hosts."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from '@site/src/components/integrations/header';
|
||||
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
|
||||
import { AddingIntegration } from '@site/src/components/integrations/adding';
|
||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||
import { patchmonIntegration } from '.';
|
||||
import { patchmonWidget } from '@site/docs/widgets/patchmon';
|
||||
|
||||
|
||||
<IntegrationHeader
|
||||
integration={patchmonIntegration}
|
||||
categories={['Health Monitoring']}
|
||||
/>
|
||||
|
||||
PatchMon provides host and package update statistics including total hosts, hosts needing updates, and security update counts.
|
||||
|
||||
The PatchMon widget requires users to be **signed in to Homarr**; see the [PatchMon widget documentation](/docs/widgets/patchmon#authentication) for details on authentication and public boards.
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[
|
||||
{ widget: patchmonWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['patchmonApiKey', 'patchmonApiSecret'],
|
||||
steps: [
|
||||
'Open your PatchMon instance',
|
||||
'Navigate to Settings and create a new GetHomepage API key',
|
||||
'Copy the API key and secret from the success dialog',
|
||||
'Paste them into Homarr — Basic authentication is handled automatically',
|
||||
]
|
||||
}]} />
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const patchmonIntegration: IntegrationDefinition = {
|
||||
name: "PatchMon",
|
||||
description:
|
||||
"PatchMon is a patch management and monitoring solution for Linux hosts, tracking package updates and security patches across your infrastructure.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/patchmon.svg",
|
||||
path: "../../integrations/patchmon",
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
title: "Synology DiskStation"
|
||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { synologyIntegration } from ".";
|
||||
import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
|
||||
import { systemDisksWidget } from "@site/docs/widgets/system-disks";
|
||||
import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring";
|
||||
import Alert from "@theme/Admonition";
|
||||
|
||||
<IntegrationHeader integration={synologyIntegration} categories={["NAS", "Hardware"]} />
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites
|
||||
items={[
|
||||
{ widget: healthMonitoringWidget },
|
||||
{ widget: systemResourcesWidget },
|
||||
{ widget: systemDisksWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Supported metrics
|
||||
|
||||
| Widget section | DSM API source | May be unavailable |
|
||||
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
|
||||
| CPU ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||
| Memory ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||
| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor |
|
||||
| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations |
|
||||
| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — |
|
||||
| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — |
|
||||
| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups |
|
||||
| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent |
|
||||
| GPU | Not available | Typical DiskStation models |
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
<Alert type="info" title="Use the correct DSM URL">
|
||||
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
|
||||
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
|
||||
<a href="/docs/management/certificates">Management > Tools > Certificates</a> so Homarr can connect securely.
|
||||
</Alert>
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Create a dedicated DSM user with a strong password for Homarr.",
|
||||
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
|
||||
"Under User Groups and Permissions, deny access to shared folders and most applications.",
|
||||
"Allow login to DSM only, optionally restricted by source IP address.",
|
||||
"Log in once with the service account in DSM to accept terms and conditions.",
|
||||
],
|
||||
body: (
|
||||
<>
|
||||
<Alert type="caution" title="Administrators group required">
|
||||
Homarr reads system, storage, and utilization data from APIs that require membership in the default
|
||||
Administrators group.
|
||||
</Alert>
|
||||
<Alert type="warning" title="Two-factor authentication is not supported">
|
||||
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
|
||||
account without 2FA.
|
||||
</Alert>
|
||||
<Alert type="tip" title="First-time setup">
|
||||
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
|
||||
> Security > Protection, then re-enable it after Homarr connects successfully.
|
||||
</Alert>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Storage volume selection
|
||||
|
||||
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
|
||||
|
||||
<Alert type="note" title="Synology-only option">
|
||||
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
|
||||
</Alert>
|
||||
|
||||
<Alert type="note" title="Volume ordering">
|
||||
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
|
||||
</Alert>
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const synologyIntegration: IntegrationDefinition = {
|
||||
name: "Synology DiskStation",
|
||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg",
|
||||
path: "../../integrations/synology",
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
title: "Traefik"
|
||||
description: "Cloud-native reverse proxy and load balancer"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { traefikIntegration } from ".";
|
||||
import { traefikWidget } from "../../widgets/traefik";
|
||||
|
||||
<IntegrationHeader integration={traefikIntegration} categories={["Reverse proxy", "Monitoring"]} />
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: traefikWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the URL where Traefik exposes its dashboard/API, for example `http://traefik:8080` or `https://traefik.example.com`. Homarr calls Traefik endpoints under `/api` automatically.
|
||||
:::
|
||||
|
||||
:::warning API requirement
|
||||
Traefik must have the API enabled. For local or internal setups this is commonly exposed through `api.insecure=true`; for production setups, expose the `api@internal` service through an authenticated router.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [
|
||||
"Choose this option when the Traefik API is only reachable from a trusted internal network and does not require authentication.",
|
||||
],
|
||||
},
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Use this option when your Traefik dashboard route is protected by Basic Auth.",
|
||||
"Enter the same username and password required to open the Traefik dashboard.",
|
||||
],
|
||||
},
|
||||
{
|
||||
credentials: ["apiKey"],
|
||||
steps: [
|
||||
"Use this option when your Traefik dashboard route accepts a bearer token.",
|
||||
"Paste the token into the API Key field in Homarr.",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const traefikIntegration: IntegrationDefinition = {
|
||||
name: "Traefik",
|
||||
description: "Cloud-native reverse proxy and load balancer",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
|
||||
path: "../../integrations/traefik",
|
||||
};
|
||||
@@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
|
||||
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
|
||||
|
||||
@@ -54,7 +54,6 @@ Type `I understand` to acknowledge that restoring will replace your current data
|
||||
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
|
||||
|
||||
- **The Next.js server restarts automatically** to reload the new database state
|
||||
- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created
|
||||
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
|
||||
- The page reloads once the server is ready
|
||||
|
||||
|
||||
@@ -82,10 +82,6 @@ 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. To publish your own widget, export it from Homarr and upload the JSON file in the Workshop submission form. 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.
|
||||
|
||||
@@ -141,7 +141,6 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
|
||||
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
|
||||
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
|
||||
| `healthMonitoring` | Resource usage stats | GET |
|
||||
| `patchmon` | Host patch and security update statistics | GET |
|
||||
| `mediaRequests` | Approve/decline media requests | GET + POST |
|
||||
| `mediaServer` | Media server sessions and stats | GET |
|
||||
| `smartHome` | Execute automations, device states | GET + POST |
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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.
|
||||
|
||||
Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns.
|
||||
|
||||
The public site also caches pages you have visited. If your network drops, a previously opened Workshop or documentation page can still load while the saved catalog remains on the device. Use **Check again** after connectivity returns.
|
||||
|
||||
- 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
|
||||
|
||||
Export a custom widget from Homarr, then choose or drop the JSON file in the Workshop submission form. CSS themes can be pasted into the same form. Sign in with GitHub, provide a public title and description, inspect the source, and publish it.
|
||||
|
||||
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. PocketBase creates OAuth accounts directly; staff privileges come only from the server-managed `workshop_staff` collection. Workshop moderators can remove submissions, disable an account, and resolve reports. These actions require a reason and are recorded.
|
||||
|
||||
The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace.
|
||||
|
||||
The service image is built directly from the Homarr repository rather than published separately. Contributors can run `pnpm dev:workshop` to start PocketBase in Docker while Docusaurus and the Workshop contracts watch on the host. Use `pnpm preview:workshop` to build the combined production image, or `pnpm reset:workshop` to explicitly clear the gitignored local PocketBase database. Superusers and GitHub OAuth are configured once in the persistent dashboard instead of being passed as environment variables.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -1,41 +0,0 @@
|
||||
---
|
||||
title: 'Bazarr'
|
||||
description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { bazarrWidget } from '.';
|
||||
import { bazarrIntegration } from '@site/docs/integrations/bazarr';
|
||||
|
||||
<WidgetHeader
|
||||
widget={bazarrWidget}
|
||||
categories={['Subtitles', 'Media']}
|
||||
/>
|
||||
|
||||
This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import widgetOverview from './img/widget-overview.png';
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: bazarrIntegration,
|
||||
note: "Displays missing subtitle counts and health indicators"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={bazarrWidget.configuration} />
|
||||
@@ -1,37 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBadgeCc } from "@tabler/icons-react";
|
||||
|
||||
export const bazarrWidget: WidgetDefinition = {
|
||||
icon: IconBadgeCc,
|
||||
name: "Bazarr",
|
||||
description: "Displays missing subtitle counts and health indicators from your Bazarr instance.",
|
||||
path: "../../widgets/bazarr",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show missing episodes",
|
||||
description: "Displays the number of episodes with missing subtitles",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show missing movies",
|
||||
description: "Displays the number of movies with missing subtitles",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show provider issues",
|
||||
description: "Displays the number of subtitle providers with issues",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show health issues",
|
||||
description: "Displays the number of Bazarr health issues",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -18,9 +18,7 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
|
||||
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
---
|
||||
title: "Beszel System Stats"
|
||||
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
|
||||
title: 'Beszel System Stats'
|
||||
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { beszelSystemStatsWidget } from ".";
|
||||
import { beszelIntegration } from "@site/docs/integrations/beszel";
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { beszelSystemStatsWidget } from '.';
|
||||
import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
|
||||
import beszelSystemStats from "./img/beszel-system-stats.png";
|
||||
import beszelSystemStats from './img/beszel-system-stats.png';
|
||||
|
||||
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
||||
<WidgetHeader
|
||||
widget={beszelSystemStatsWidget}
|
||||
categories={['System Monitoring', 'Charts']}
|
||||
/>
|
||||
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -23,19 +26,14 @@ This widget displays time-series charts for a selected Beszel system, including
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays time-series charts for a selected system",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<WidgetIntegrations items={[{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays time-series charts for a selected system"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
|
||||
@@ -17,9 +17,7 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
|
||||
|
||||
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -1,71 +1,61 @@
|
||||
---
|
||||
title: "System Health Monitoring"
|
||||
description: "Displays information showing the health and status of your system(s)."
|
||||
title: 'System Health Monitoring'
|
||||
description: 'Displays information showing the health and status of your system(s).'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { healthMonitoringWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { healthMonitoringWidget } from '.';
|
||||
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||
|
||||
<WidgetHeader widget={healthMonitoringWidget} categories={["Health monitoring"]} />
|
||||
|
||||
<WidgetHeader
|
||||
widget={healthMonitoringWidget}
|
||||
categories={['Health monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import systemView from "./img/system-view.png";
|
||||
import clusterView from "./img/cluster-view.png";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
import systemView from './img/system-view.png';
|
||||
import clusterView from './img/cluster-view.png';
|
||||
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
|
||||
<img src={systemView} alt="health-monitoring system-view" width={256} height={256} />
|
||||
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: glancesIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: proxmoxIntegration,
|
||||
note: "Will display information on the cluster tab, only supports one integration at a time",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<WidgetIntegrations items={[{
|
||||
integration: dashDotIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: openMediaVaultIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: truenasIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: glancesIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: proxmoxIntegration,
|
||||
note: "Will display information on the cluster tab, only supports one integration at a time"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
|
||||
@@ -38,13 +38,6 @@ export const healthMonitoringWidget: WidgetDefinition = {
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Visible storage volumes",
|
||||
description:
|
||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
||||
values: "List of storage volumes",
|
||||
defaultValue: "Empty list",
|
||||
},
|
||||
{
|
||||
name: "Default tab",
|
||||
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: 'Media Missing & Queue'
|
||||
description: 'Track missing media and active downloads from Radarr and Sonarr'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { mediaMissingWidget } from '.';
|
||||
import { radarrIntegration } from '@site/docs/integrations/radarr';
|
||||
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
|
||||
|
||||
<WidgetHeader
|
||||
widget={mediaMissingWidget}
|
||||
categories={['Media', 'Downloads']}
|
||||
/>
|
||||
|
||||
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import widgetMissing from './img/missing.png';
|
||||
import widgetQueued from './img/queued.png';
|
||||
import widgetCompact from './img/compact.png';
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
|
||||
<img src={widgetQueued} alt="media-missing tab showing download queue with progress bars" width={256} />
|
||||
<img src={widgetCompact} alt="media-missing widget in a compact wide-and-short size" width={256} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: radarrIntegration,
|
||||
}, {
|
||||
integration: sonarrIntegration,
|
||||
}]} />
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tab | Description |
|
||||
|---|---|
|
||||
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
|
||||
| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
|
||||
|
||||
### Sizing
|
||||
|
||||
The widget adapts to its container size, so it looks good at any grid size:
|
||||
|
||||
- **Wide** — fits as many columns of cards as the width allows, filling larger widgets instead of stretching a single column.
|
||||
- **Short** — packs items into more, tighter columns so they stay visible in the limited height.
|
||||
- **Narrow (1×N)** — tab labels collapse to counts only and posters scale down.
|
||||
|
||||
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={mediaMissingWidget.configuration} />
|
||||
@@ -1,31 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
|
||||
export const mediaMissingWidget: WidgetDefinition = {
|
||||
icon: IconMovie,
|
||||
name: "Media Missing & Queue",
|
||||
description: "Track missing media and active downloads from Radarr and Sonarr",
|
||||
path: "../../widgets/media-missing",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show Missing",
|
||||
description: "Display the missing media tab, listing movies or episodes not yet downloaded",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
name: "Show Queued",
|
||||
description: "Display the download queue tab with progress for each item",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
name: "Page Size",
|
||||
description: "Maximum number of items to fetch per integration",
|
||||
values: { type: "string" },
|
||||
defaultValue: "10",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,25 +1,29 @@
|
||||
---
|
||||
title: "Notebook"
|
||||
description: "A simple notebook widget that supports markdown."
|
||||
title: 'Notebook'
|
||||
description: 'A simple notebook widget that supports markdown.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { notebookWidget } from ".";
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { notebookWidget } from '.';
|
||||
|
||||
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
|
||||
|
||||
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
|
||||
<WidgetHeader
|
||||
widget={notebookWidget}
|
||||
categories={['Notes']}
|
||||
/>
|
||||
|
||||
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
|
||||
It is based on Tiptap.dev and supports most of its features.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import editOff from "./img/normal.png";
|
||||
import editOn from "./img/edit.png";
|
||||
import editOff from './img/normal.png';
|
||||
import editOn from './img/edit.png';
|
||||
|
||||
<div className="flex gap-1">
|
||||
<div className='flex gap-1'>
|
||||
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
|
||||
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
|
||||
</div>
|
||||
@@ -29,27 +33,24 @@ import editOn from "./img/edit.png";
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={notebookWidget.configuration} />
|
||||
|
||||
### Editing
|
||||
|
||||
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
|
||||
|
||||

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

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

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

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

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

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

|
||||
|
||||
While in a table, further controls will appear to help edit the table.
|
||||
@@ -154,4 +134,4 @@ All the following ones are for editing the wize of the table:
|
||||
- Delete Column
|
||||
- Add row on top
|
||||
- Add row below
|
||||
- Delete Row
|
||||
- Delete Row
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
title: 'PatchMon'
|
||||
description: 'Displays host patch statistics from PatchMon including update and security counts.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { patchmonWidget } from '.';
|
||||
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={patchmonWidget}
|
||||
categories={['Health Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
|
||||
|
||||
PatchMon does not render a built-in widget title. To add a label on your board, open **Edit item → Advanced options → Title** — the same optional overlay badge used by other Homarr widgets.
|
||||
|
||||
### Authentication
|
||||
|
||||
:::note
|
||||
This widget requires a **signed-in Homarr session**. Anonymous visitors cannot load PatchMon data, even when the board is [public](/docs/management/boards#access-control). Placing this widget on a public board does not expose patch or security statistics to guests.
|
||||
|
||||
Stat visibility toggles in the widget settings control **display only**. Authenticated users with integration query access still receive the full API payload regardless of which stats are hidden in the UI.
|
||||
:::
|
||||
|
||||
### Compliance hero
|
||||
|
||||
When **Show compliance hero** is enabled (default), the widget displays a ring showing the percentage of hosts that are fully up to date. The ring color follows the same threshold rules as the "Up-to-Date Hosts" stat. When the widget is **2 or more columns wide**, the hero also shows the "Patch Compliance" label and exact host count (e.g. "27 / 42") beside the ring.
|
||||
|
||||
In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a ring-only view so useful compliance data remains visible in tight spaces. If the compliance hero is disabled, compact widgets fall back to the next selected content: OS distribution when enabled, then the first enabled stat.
|
||||
|
||||
### Threshold colors
|
||||
|
||||
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
|
||||
|
||||
| Stat | Green | Yellow | Red |
|
||||
|------|-------|--------|-----|
|
||||
| Needs Updates | 0 | 1–4 | 5+ |
|
||||
| Security Updates | 0 | 1–9 | 10+ |
|
||||
| Hosts with Security Updates | 0 | — | 1+ |
|
||||
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
|
||||
| Outdated Packages | 0 | 1–49 | 50+ |
|
||||
|
||||
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
|
||||
|
||||
### OS distribution
|
||||
|
||||
When **Show OS distribution** is enabled, the widget shows a breakdown of monitored hosts by operating system. Two display modes are available:
|
||||
|
||||
- **Horizontal bars** (default) — a scrollable list of colored progress bars showing each OS's share
|
||||
- **Donut chart** — a compact donut chart visualization that scales down in narrow layouts
|
||||
|
||||
The OS distribution limit defaults to **All**. A widget size of **2×3** or larger is recommended when using this section alongside the default stats, but compact 1×1 and 2×1 layouts can still show multiple OS rows when OS distribution is the primary selected content. Wider donut layouts can also show a compact legend beside the chart.
|
||||
|
||||
### Responsive sizing
|
||||
|
||||
The widget adapts its layout based on the available space:
|
||||
|
||||
| Widget size | Layout |
|
||||
|-------------|--------|
|
||||
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
|
||||
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
|
||||
| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
|
||||
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
|
||||
| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
|
||||
|
||||
### Last updated
|
||||
|
||||
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: patchmonIntegration,
|
||||
note: "Displays host patch and security update statistics"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={patchmonWidget.configuration} />
|
||||
@@ -1,200 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconShieldCheck } from "@tabler/icons-react";
|
||||
|
||||
export const patchmonWidget: WidgetDefinition = {
|
||||
icon: IconShieldCheck,
|
||||
name: "PatchMon",
|
||||
description:
|
||||
"Displays host patch statistics including total hosts, hosts needing updates, and security update counts.",
|
||||
path: "../../widgets/patchmon",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show compliance hero",
|
||||
description: "Display a compliance ring showing the percentage of up-to-date hosts",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show total hosts",
|
||||
description: "Displays the total number of monitored hosts",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show hosts needing updates",
|
||||
description: "Displays hosts with at least one outdated package",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show security updates",
|
||||
description: "Displays the total number of security updates available",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show up-to-date hosts",
|
||||
description: "Displays hosts with zero outdated packages",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show hosts with security updates",
|
||||
description: "Displays hosts requiring security patches",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show recent updates (24h)",
|
||||
description: "Displays the number of updates applied in the last 24 hours",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show outdated packages",
|
||||
description: "Displays the total number of outdated packages across all hosts",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show repositories",
|
||||
description: "Displays the number of active repositories being monitored",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Enable threshold colors",
|
||||
description: "Colors stat values green, yellow, or red based on built-in severity presets",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Advanced thresholds",
|
||||
description: "Customize warning and critical thresholds per stat (absolute count or % of total hosts)",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Needs updates threshold mode",
|
||||
description:
|
||||
"Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Needs updates warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Needs updates critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "5",
|
||||
},
|
||||
{
|
||||
name: "Security updates threshold mode",
|
||||
description:
|
||||
"Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Security updates warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Security updates critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "10",
|
||||
},
|
||||
{
|
||||
name: "Hosts with security updates threshold mode",
|
||||
description:
|
||||
"Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Hosts with security updates warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Hosts with security updates critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Up-to-date hosts threshold mode",
|
||||
description:
|
||||
"Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Percent of total hosts",
|
||||
},
|
||||
{
|
||||
name: "Up-to-date hosts warning threshold",
|
||||
description: "Enter yellow when the value is at or above this threshold but below full coverage",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "90",
|
||||
},
|
||||
{
|
||||
name: "Up-to-date hosts critical threshold",
|
||||
description: "Enter red when the value falls below this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "90",
|
||||
},
|
||||
{
|
||||
name: "Outdated packages threshold mode",
|
||||
description:
|
||||
"Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Outdated packages warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Outdated packages critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "50",
|
||||
},
|
||||
{
|
||||
name: "Show OS distribution",
|
||||
description: "Displays a horizontal bar breakdown of monitored hosts by operating system",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "OS display mode",
|
||||
description:
|
||||
"Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.",
|
||||
values: { type: "select", options: ["Horizontal bars", "Donut chart"] },
|
||||
defaultValue: "Horizontal bars",
|
||||
},
|
||||
{
|
||||
name: "OS distribution limit",
|
||||
description: "Maximum number of operating systems to display in the distribution section",
|
||||
values: { type: "select", options: ["Top 3", "Top 5", "Top 10", "All"] },
|
||||
defaultValue: "All",
|
||||
},
|
||||
{
|
||||
name: "Show OS version",
|
||||
description: "Append the OS version to each distribution label when available",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
title: "System disks"
|
||||
description: "Disk usage of your system"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { systemDisksWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { unraidIntegration } from "@site/docs/integrations/unraid";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
},
|
||||
{
|
||||
integration: unraidIntegration,
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
note: "Supports storage volume filtering when only Synology integrations are selected",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={systemDisksWidget.configuration} />
|
||||
@@ -1,41 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconServer2 } from "@tabler/icons-react";
|
||||
|
||||
export const systemDisksWidget: WidgetDefinition = {
|
||||
icon: IconServer2,
|
||||
name: "System disks",
|
||||
description: "Disk usage of your system",
|
||||
path: "../../widgets/system-disks",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show temperature if available",
|
||||
description: "Display disk temperature when the integration provides it",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "Yes",
|
||||
},
|
||||
{
|
||||
name: "Display mode",
|
||||
description: "How disk usage values are shown",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
|
||||
},
|
||||
defaultValue: "Percentage (e.g., 76%)",
|
||||
},
|
||||
{
|
||||
name: "Show background usage bar",
|
||||
description: "Fill the area below the usage value with a background bar",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "Yes",
|
||||
},
|
||||
{
|
||||
name: "Visible storage volumes",
|
||||
description:
|
||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
||||
values: "List of storage volumes",
|
||||
defaultValue: "All volumes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,69 +1,57 @@
|
||||
---
|
||||
title: "System Resources"
|
||||
description: "Displays CPU, RAM and network of your host in real time"
|
||||
title: 'System Resources'
|
||||
description: 'Displays CPU, RAM and network of your host in real time'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { systemResourcesWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { systemResourcesWidget } from '.';
|
||||
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||
|
||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
||||
|
||||
<WidgetHeader
|
||||
widget={systemResourcesWidget}
|
||||
categories={['CPU', 'RAM', 'Memory', 'Network', 'Interface']}
|
||||
/>
|
||||
|
||||
Displays CPU, RAM and network of your host in real time
|
||||
|
||||
### Screenshots
|
||||
|
||||
import network from "./img/network.png";
|
||||
import cpu from "./img/cpu.png";
|
||||
import memory from "./img/memory.png";
|
||||
import cpuAndMemory from "./img/cpu-and-memory.png";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
import network from './img/network.png';
|
||||
import cpu from './img/cpu.png';
|
||||
import memory from './img/memory.png';
|
||||
import cpuAndMemory from './img/cpu-and-memory.png';
|
||||
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className='flex gap-1'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<img src={network} alt="system-resources-widget network-only icon-label" width={256 + 4} height={128} />
|
||||
<div className="flex gap-1">
|
||||
<div className='flex gap-1'>
|
||||
<img src={cpu} alt="system-resources-widget cpu-only no-label item-title" width={128} height={128} />
|
||||
<img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src={cpuAndMemory}
|
||||
alt="system-resources-widget cpu-and-memory icon-text-label"
|
||||
width={256 + 4}
|
||||
height={256 + 4}
|
||||
/>
|
||||
<img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
},
|
||||
{
|
||||
integration: glancesIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<WidgetIntegrations items={[{
|
||||
integration: dashDotIntegration
|
||||
}, {
|
||||
integration: openMediaVaultIntegration
|
||||
}, {
|
||||
integration: truenasIntegration
|
||||
}, {
|
||||
integration: glancesIntegration
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -71,4 +59,4 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
title: "Traefik"
|
||||
description: "Displays Traefik router, service, middleware, and entry point counts."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { traefikWidget } from ".";
|
||||
import { traefikIntegration } from "@site/docs/integrations/traefik";
|
||||
|
||||
<WidgetHeader widget={traefikWidget} categories={["Monitoring", "Reverse proxy"]} />
|
||||
|
||||
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: traefikIntegration,
|
||||
note: "Reads data from Traefik's dashboard/API endpoints",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={traefikWidget.configuration} />
|
||||
@@ -1,31 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconRoute } from "@tabler/icons-react";
|
||||
|
||||
export const traefikWidget: WidgetDefinition = {
|
||||
icon: IconRoute,
|
||||
name: "Traefik",
|
||||
description: "Overview of Traefik routers, services, middlewares, and entry points.",
|
||||
path: "../../widgets/traefik",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show TCP",
|
||||
description: "Display TCP routers, services, and middlewares.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show UDP",
|
||||
description: "Display UDP routers and services.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show entry points",
|
||||
description: "Display the configured Traefik entry point names.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,25 +1,18 @@
|
||||
// Algolia DocSearch crawler for homarr.dev
|
||||
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
|
||||
// Paste this in the Algolia crawler editor.
|
||||
// DocSearch-managed crawlers: paste this in the Algolia crawler editor or send to DocSearch support.
|
||||
// Template: https://docsearch.algolia.com/docs/templates/#docusaurus-v3-template
|
||||
// Markdown indexing: https://www.algolia.com/doc/guides/algolia-ai/askai/guides/markdown-indexing
|
||||
|
||||
new Crawler({
|
||||
appId: "N69WSPZTID",
|
||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
||||
indexPrefix: "",
|
||||
rateLimit: 32,
|
||||
maxDepth: 4,
|
||||
rateLimit: 8,
|
||||
maxDepth: 10,
|
||||
startUrls: ["https://homarr.dev/"],
|
||||
sitemaps: ["https://homarr.dev/sitemap.xml"],
|
||||
ignoreCanonicalTo: true,
|
||||
discoveryPatterns: ["https://homarr.dev/**"],
|
||||
exclusionPatterns: [
|
||||
"https://homarr.dev/docs/tags/**",
|
||||
"https://homarr.dev/blog/tags/**",
|
||||
"https://homarr.dev/blog/authors/**",
|
||||
"https://homarr.dev/blog/archive/**",
|
||||
"https://homarr.dev/search/**",
|
||||
"https://homarr.dev/docs/category/**",
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
indexName: "Docusaurus",
|
||||
@@ -112,6 +105,7 @@ new Crawler({
|
||||
advancedSyntax: true,
|
||||
attributeCriteriaComputedByMinProximity: true,
|
||||
removeWordsIfNoResults: "allOptional",
|
||||
separatorsToIndex: "_",
|
||||
},
|
||||
markdown: {
|
||||
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
|
||||
@@ -125,10 +119,4 @@ new Crawler({
|
||||
advancedSyntax: false,
|
||||
},
|
||||
},
|
||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
||||
schedule: "on saturday",
|
||||
renderJavaScript: false,
|
||||
maxUrls: null,
|
||||
cache: { enabled: false },
|
||||
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
|
||||
});
|
||||
@@ -3,16 +3,12 @@ import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
// Production uses PocketBase's own origin. The explicit override exists only
|
||||
// for the standalone Docusaurus development server.
|
||||
const workshopApiUrl = process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "";
|
||||
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
url: "https://homarr.dev",
|
||||
baseUrl: "/",
|
||||
trailingSlash: undefined,
|
||||
trailingSlash: true,
|
||||
favicon: "img/logo.png",
|
||||
organizationName: "homarr-labs",
|
||||
projectName: "homarr",
|
||||
@@ -20,9 +16,6 @@ const config: Config = {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
customFields: {
|
||||
workshopApiUrl,
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
onDuplicateRoutes: "throw",
|
||||
@@ -86,27 +79,8 @@ const config: Config = {
|
||||
sitemap: {
|
||||
changefreq: "weekly",
|
||||
priority: 0.5,
|
||||
ignorePatterns: ["/tags/**", "/docs/category/**"],
|
||||
ignorePatterns: ["/tags/**"],
|
||||
filename: "sitemap.xml",
|
||||
createSitemapItems: async ({ routes, siteConfig, defaultCreateSitemapItems }) => {
|
||||
const items = await defaultCreateSitemapItems({ routes, siteConfig });
|
||||
return items.map((item) => {
|
||||
const path = new URL(item.url).pathname;
|
||||
if (path === "/" || path === "") {
|
||||
return { ...item, priority: 1.0, changefreq: "weekly" };
|
||||
}
|
||||
if (path.startsWith("/docs/")) {
|
||||
return { ...item, priority: 0.7, changefreq: "monthly" };
|
||||
}
|
||||
if (path.startsWith("/blog/")) {
|
||||
return { ...item, priority: 0.3, changefreq: "never" };
|
||||
}
|
||||
if (path.startsWith("/about-us")) {
|
||||
return { ...item, priority: 0.4, changefreq: "yearly" };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
},
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
@@ -131,11 +105,6 @@ const config: Config = {
|
||||
position: "left",
|
||||
to: "/blog",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
position: "left",
|
||||
to: "/workshop",
|
||||
},
|
||||
{
|
||||
label: "About us",
|
||||
position: "left",
|
||||
@@ -309,7 +278,6 @@ const config: Config = {
|
||||
],
|
||||
"docusaurus-plugin-image-zoom",
|
||||
require.resolve("./plugins/validate-docs-coverage"),
|
||||
require.resolve("./plugins/offline-manifest.mjs"),
|
||||
function disableExpensiveBundlerOptimizationPlugin() {
|
||||
return {
|
||||
name: "disable-expensive-bundler-optimizations",
|
||||
@@ -322,18 +290,6 @@ 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 {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"dev": "docusaurus start --no-open --host 0.0.0.0 --port 3003",
|
||||
"dev": "docusaurus start --no-open --port 3003",
|
||||
"start": "docusaurus start --port 3003",
|
||||
"build": "docusaurus build",
|
||||
"serve": "docusaurus serve",
|
||||
@@ -27,16 +27,12 @@
|
||||
"@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:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
@@ -47,8 +43,8 @@
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^14.0.2",
|
||||
"tailwindcss": "^4.3.2"
|
||||
"swiper": "^12.2.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const collectFiles = async (directory, root = directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath);
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
};
|
||||
|
||||
export default function offlineManifestPlugin() {
|
||||
return {
|
||||
name: "workshop-offline-manifest",
|
||||
async postBuild({ outDir }) {
|
||||
const assetsDirectory = path.join(outDir, "assets");
|
||||
const assets = (await collectFiles(assetsDirectory))
|
||||
.filter((file) => /\.(?:css|js|woff2?)$/.test(file))
|
||||
.map((file) => `/assets/${file.split(path.sep).join("/")}`);
|
||||
await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"]));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -47,16 +47,6 @@ const secretKinds = {
|
||||
description: "The Secret part of the API Key for authentication.",
|
||||
icon: IconPassword,
|
||||
},
|
||||
patchmonApiKey: {
|
||||
name: "API Key",
|
||||
description: "The API key from PatchMon's GetHomepage integration.",
|
||||
icon: IconKey,
|
||||
},
|
||||
patchmonApiSecret: {
|
||||
name: "API Secret",
|
||||
description: "The API secret from PatchMon's GetHomepage integration.",
|
||||
icon: IconPassword,
|
||||
},
|
||||
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
|
||||
githubInstallationId: {
|
||||
name: "Installation ID",
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
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() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAdminContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminContent() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin),
|
||||
[siteConfig],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
const canModerate = user?.role === "moderator" || user?.role === "admin";
|
||||
const moderationQuery = useQuery({
|
||||
queryKey: ["workshop", "moderation"],
|
||||
queryFn: async () => {
|
||||
const [reports, users, actions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listUsers(),
|
||||
client.listModerationActions(),
|
||||
]);
|
||||
return { reports, users, actions };
|
||||
},
|
||||
enabled: canModerate,
|
||||
});
|
||||
const reports = moderationQuery.data?.reports ?? [];
|
||||
const users = moderationQuery.data?.users ?? [];
|
||||
const actions = moderationQuery.data?.actions ?? [];
|
||||
const actionMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "action"],
|
||||
mutationFn: async ({ action, reason }: { action: PendingAction; reason: string }) => {
|
||||
if (action.kind === "report") await client.resolveReport(action.id, action.status, reason);
|
||||
else if (action.kind === "state") await client.updateAccountState(action.id, action.state, reason);
|
||||
else await client.updateRole(action.id, action.role, reason);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setPending(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "moderation"] });
|
||||
},
|
||||
});
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "moderator"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: setUser,
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client
|
||||
.refreshAuth()
|
||||
.then(setUser)
|
||||
.catch((caught: unknown) =>
|
||||
setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"),
|
||||
)
|
||||
.finally(() => setAuthLoading(false));
|
||||
}, [client]);
|
||||
|
||||
if (authLoading)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<WorkshopAdminSkeleton />
|
||||
</main>
|
||||
);
|
||||
if (!user)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Sign in with a promoted GitHub account to continue.</p>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button button--primary"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
signInMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{signInMutation.isPending ? "Connecting…" : "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 || moderationQuery.isError) && (
|
||||
<WorkshopServiceState
|
||||
cached={Boolean(moderationQuery.data)}
|
||||
message={error || "Moderation data is unavailable. No action can be applied until Workshop reconnects."}
|
||||
onRetry={() => void moderationQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
<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 === "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={(reason) => actionMutation.mutateAsync({ action: pending, reason })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminSkeleton() {
|
||||
return (
|
||||
<div aria-label="Loading Workshop moderation">
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Loading account and moderation data…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<WorkshopDialog titleId="moderation-reason-title" onClose={onClose}>
|
||||
<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>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,740 +0,0 @@
|
||||
import React, { 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,
|
||||
IconHeart,
|
||||
IconPackage,
|
||||
IconSearch,
|
||||
IconSparkles,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_SCREENSHOTS,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
type WorkshopSubmissionInput,
|
||||
type WorkshopSubmissionSummary,
|
||||
type WorkshopSubmissionType,
|
||||
type WorkshopUser,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const useWorkshop = () => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin;
|
||||
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() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAppContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAppContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [mine, setMine] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "sign-in"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop"] }),
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "Sign-in failed"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth().catch(() => undefined);
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["workshop", "list", page, type, sort, debouncedSearch, mine ? user?.id : null],
|
||||
queryFn: ({ signal }) =>
|
||||
client.list({
|
||||
page,
|
||||
type,
|
||||
sort,
|
||||
search: debouncedSearch,
|
||||
author: mine ? user?.id : undefined,
|
||||
signal,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const totalPages = listQuery.data?.totalPages ?? 1;
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Made by the Homarr community</span>
|
||||
<h1>Workshop</h1>
|
||||
<p>Discover useful creations, inspect every line, then bring the ones you trust into Homarr.</p>
|
||||
<ul className={styles.heroPromises} aria-label="Workshop principles">
|
||||
<li>
|
||||
<IconSparkles size={16} /> Community-made
|
||||
</li>
|
||||
<li>
|
||||
<IconPackage size={16} /> Ready to export
|
||||
</li>
|
||||
<li>
|
||||
<IconHeart size={16} /> Ranked by members
|
||||
</li>
|
||||
</ul>
|
||||
</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"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => signInMutation.mutate()}
|
||||
>
|
||||
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "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 || listQuery.isError || listQuery.fetchStatus === "paused") && (
|
||||
<WorkshopServiceState
|
||||
cached={items.length > 0}
|
||||
message={error || undefined}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
{listQuery.isPending ? (
|
||||
<WorkshopSkeleton />
|
||||
) : items.length ? (
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<WorkshopCard client={client} item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
|
||||
<section className={styles.empty}>
|
||||
<IconSearch size={32} aria-hidden />
|
||||
<h2>No creations found</h2>
|
||||
<p>Try a broader search or clear the filters to explore everything the community has shared.</p>
|
||||
<button
|
||||
className="button button--secondary button--sm"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setType("all");
|
||||
setSort("top");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</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 queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 className={styles.cardLink} 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() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopDetailRouteContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopDetailRouteContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const history = useHistory();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moderating, setModerating] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workshop", "detail", id],
|
||||
queryFn: ({ signal }) => client.get(id, signal),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
const voteMutation = useMutation({
|
||||
mutationKey: ["workshop", "vote", id],
|
||||
mutationFn: async () => {
|
||||
await (client.currentUser ?? client.signInWithGitHub());
|
||||
return client.vote(id, 1);
|
||||
},
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }),
|
||||
onError: (caught) => setActionError(caught instanceof Error ? caught.message : "The vote could not be saved."),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["workshop", "delete", id],
|
||||
mutationFn: () => client.delete(id),
|
||||
onSuccess: () => history.push("/workshop"),
|
||||
onError: (caught) =>
|
||||
setActionError(caught instanceof Error ? caught.message : "The submission could not be deleted."),
|
||||
});
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth();
|
||||
}, [client]);
|
||||
|
||||
if (detailQuery.isError)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.error} role="alert">
|
||||
This creation is not saved on this device and Workshop could not be reached.{" "}
|
||||
<button onClick={() => void detailQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
<Link to="/workshop">Back to Workshop</Link>
|
||||
</main>
|
||||
);
|
||||
if (!detailQuery.data) return <WorkshopSkeleton />;
|
||||
|
||||
const submission = detailQuery.data;
|
||||
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"
|
||||
disabled={voteMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
voteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
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"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission permanently?")) {
|
||||
setActionError("");
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
|
||||
<button className="button button--danger" onClick={() => setModerating(true)}>
|
||||
Remove submission
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{actionError}
|
||||
</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 queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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 removeMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "remove", submission.id],
|
||||
mutationFn: () => client.removeSubmission(submission.id, reason),
|
||||
onSuccess: onRemoved,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="remove-submission-title" onClose={onClose}>
|
||||
<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={(event) => {
|
||||
event.preventDefault();
|
||||
removeMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{removeMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{removeMutation.error instanceof Error ? removeMutation.error.message : "Removal failed"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={removeMutation.isPending} className="button button--danger">
|
||||
{removeMutation.isPending ? "Removing…" : "Remove permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length;
|
||||
const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount);
|
||||
useEffect(() => {
|
||||
setScreenshots((current) => current.slice(0, remainingScreenshotSlots));
|
||||
}, [remainingScreenshotSlots]);
|
||||
const saveMutation = useMutation({
|
||||
mutationKey: ["workshop", existing ? "update" : "create", existing?.id],
|
||||
mutationFn: async (input: WorkshopSubmissionInput) => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
if (existing) {
|
||||
return client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
}
|
||||
return client.create(input, screenshots);
|
||||
},
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="submission-form-title" onClose={onClose}>
|
||||
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveMutation.mutate({ type, title, description, content, changelog });
|
||||
}}
|
||||
>
|
||||
<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
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
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, {remainingScreenshotSlots} available)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) =>
|
||||
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
{saveMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{saveMutation.error instanceof Error ? saveMutation.error.message : "Could not save submission"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saveMutation.isPending} className="button button--primary">
|
||||
{saveMutation.isPending ? "Saving…" : existing ? "Save update" : "Publish creation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 reportMutation = useMutation({
|
||||
mutationKey: ["workshop", "report", submissionId],
|
||||
mutationFn: async () => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
return client.report(submissionId, category, explanation);
|
||||
},
|
||||
onSuccess: onClose,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="report-title" onClose={onClose}>
|
||||
<h2 id="report-title">Report submission</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
reportMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{reportMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{reportMutation.error instanceof Error ? reportMutation.error.message : "Could not send report"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={reportMutation.isPending} className="button button--primary">
|
||||
{reportMutation.isPending ? "Sending…" : "Submit report"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopSkeleton() {
|
||||
return (
|
||||
<div className={styles.grid} aria-label="Loading Workshop">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div className={styles.skeleton} key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { type PropsWithChildren, useState } from "react";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const maxAge = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: maxAge,
|
||||
networkMode: "offlineFirst",
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
mutations: { networkMode: "online", retry: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [persister] = useState(() =>
|
||||
createAsyncStoragePersister({
|
||||
key: "homarr-workshop-website-cache-v1",
|
||||
storage: window.localStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={client}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge,
|
||||
buster: "workshop-v1",
|
||||
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { type PropsWithChildren, useEffect, useRef } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { IconBooks, IconCloudOff, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
export function WorkshopServiceState({
|
||||
cached,
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
cached: boolean;
|
||||
message?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.serviceState} aria-live="polite">
|
||||
<span className={styles.serviceStateIcon} aria-hidden>
|
||||
<IconCloudOff size={24} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{cached ? "Browsing saved creations" : "Workshop is taking a break"}</h2>
|
||||
<p>
|
||||
{message ??
|
||||
(cached
|
||||
? "The catalog saved on this device is still available. Live details and community actions will return when the service reconnects."
|
||||
: "Community creations cannot be reached right now. Homarr and its documentation remain fully available.")}
|
||||
</p>
|
||||
<div className={styles.serviceStateActions}>
|
||||
<button className="button button--primary button--sm" onClick={onRetry}>
|
||||
<IconRefresh size={16} /> Check again
|
||||
</button>
|
||||
<Link className="button button--secondary button--sm" to="/docs/management/workshop">
|
||||
<IconBooks size={16} /> Read the guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkshopDialog({
|
||||
titleId,
|
||||
onClose,
|
||||
children,
|
||||
}: PropsWithChildren<{ titleId: string; onClose: () => void }>) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
dialog.showModal();
|
||||
return () => dialog.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className={styles.dialog}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className={styles.dialogSurface}>{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
.page {
|
||||
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
max-width: 1180px;
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.detail h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.35rem, 6vw, 4.25rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 62ch;
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: clamp(1rem, 2vw, 1.12rem);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1rem;
|
||||
padding: 0;
|
||||
margin: 1.1rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.82rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.heroPromises li,
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heroPromises li {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.accountActions :global(.button),
|
||||
.detailActions :global(.button),
|
||||
.serviceStateActions :global(.button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.safety {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 55%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 8%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.safety svg {
|
||||
flex: none;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.safety p {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.toolbar label,
|
||||
.dialog label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
.dialog input,
|
||||
.dialog select,
|
||||
.dialog textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.6rem 0.72rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 9px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-color);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.toolbar input:focus-visible,
|
||||
.toolbar select:focus-visible,
|
||||
.dialog input:focus-visible,
|
||||
.dialog select:focus-visible,
|
||||
.dialog textarea:focus-visible,
|
||||
.pagination button:focus-visible,
|
||||
.rowActions button:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search svg {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0.8rem;
|
||||
bottom: 0.82rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
padding-left: 2.35rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex !important;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: clamp(1.1rem, 3vw, 1.5rem);
|
||||
margin: 1.25rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 45%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 7%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.serviceStateIcon {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-warning-darkest);
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 18%, transparent);
|
||||
}
|
||||
|
||||
.serviceState h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.serviceState p {
|
||||
max-width: 70ch;
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 0.035);
|
||||
transition:
|
||||
transform 200ms var(--workshop-ease),
|
||||
border-color 200ms ease,
|
||||
box-shadow 200ms var(--workshop-ease);
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
border-color: color-mix(in srgb, var(--ifm-color-primary) 55%, var(--ifm-color-emphasis-300));
|
||||
box-shadow: 0 14px 34px rgb(0 0 0 / 0.1);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card img,
|
||||
.cardPlaceholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cardPlaceholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 6%, var(--ifm-color-emphasis-100));
|
||||
font: 750 1.45rem var(--ifm-font-family-monospace);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cardBody h2 {
|
||||
margin: 0.45rem 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.cardLink::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.cardLink:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:has(.cardLink:focus-visible) {
|
||||
outline: 3px solid var(--ifm-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cardBody p {
|
||||
min-height: 2.7em;
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.cardBody small {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 45%, transparent);
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-danger-contrast-foreground);
|
||||
background: var(--ifm-color-danger-contrast-background);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
max-width: 620px;
|
||||
min-height: 310px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 3rem 1.25rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--ifm-color-emphasis-400);
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.empty h2,
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.empty :global(.button) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
min-height: 310px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.detail header {
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.detail h1 {
|
||||
margin-top: 0.5rem;
|
||||
font-size: clamp(2.1rem, 5vw, 3.6rem);
|
||||
}
|
||||
|
||||
.type {
|
||||
display: inline-block;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.6rem;
|
||||
margin: 1.5rem 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
width: min(720px, 85vw);
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
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: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(700px, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 900px);
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: transparent;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.68);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialogSurface {
|
||||
max-height: min(90vh, 900px);
|
||||
overflow-y: auto;
|
||||
padding: clamp(1.15rem, 4vw, 1.6rem);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 16px;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.dialogSurface > h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dialog form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dialog textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dialog .editor {
|
||||
min-height: 240px;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.adminSection {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tableWrap table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tableWrap th,
|
||||
.tableWrap td {
|
||||
padding: 0.75rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-300);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tableWrap tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rowActions button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.accountActions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.page {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.accountActions > *,
|
||||
.detailActions > *,
|
||||
.formActions > *,
|
||||
.serviceStateActions > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
|
||||
@import "tailwindcss/theme" layer(theme);
|
||||
@import "tailwindcss/utilities" layer(utilities);
|
||||
|
||||
@import url(https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap);
|
||||
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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,11 +0,0 @@
|
||||
import React, { type PropsWithChildren, useEffect } from "react";
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
Vendored
-46
@@ -1,46 +0,0 @@
|
||||
const CACHE_NAME = "homarr-site-v2";
|
||||
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const manifest = await fetch("/workshop-assets.json")
|
||||
.then((response) => response.json())
|
||||
.catch(() => []);
|
||||
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
|
||||
}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
|
||||
return Response.error();
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -9,14 +12,10 @@
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@site/*": ["./*"]
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
|
||||
+14
-12
@@ -5,9 +5,21 @@ import "@homarr/common/env";
|
||||
import "@homarr/core/infrastructure/logs/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const homarrVersion = (() => {
|
||||
for (const candidate of ["../../package.json", "./package.json"]) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||
if (pkg.name === "homarr") return pkg.version as string;
|
||||
} catch {}
|
||||
}
|
||||
return "unknown";
|
||||
})();
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin({
|
||||
requestConfig: "../../packages/translation/src/request.ts",
|
||||
@@ -15,7 +27,7 @@ const withNextIntl = createNextIntlPlugin({
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: {
|
||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
||||
HOMARR_VERSION: homarrVersion,
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
@@ -31,19 +43,9 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
turbopack: {
|
||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
||||
ignoreIssue: [
|
||||
{
|
||||
path: "**/*",
|
||||
title: "Encountered unexpected file in NFT list",
|
||||
},
|
||||
],
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
localPatterns: [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "oxfmt .",
|
||||
@@ -50,7 +50,6 @@
|
||||
"@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:",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
httpLink,
|
||||
httpSubscriptionLink,
|
||||
isNonJsonSerializable,
|
||||
loggerLink,
|
||||
splitLink,
|
||||
@@ -25,7 +24,6 @@ import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheBuster,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
@@ -102,17 +100,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
true: splitLink({
|
||||
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
|
||||
true: httpSubscriptionLink({
|
||||
url: getTrpcUrl(),
|
||||
transformer: superjson,
|
||||
eventSourceOptions: { withCredentials: true },
|
||||
}),
|
||||
false: wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
}),
|
||||
true: wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
}),
|
||||
false: splitLink({
|
||||
condition: ({ input }) => isNonJsonSerializable(input),
|
||||
@@ -146,7 +136,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
buster: queryCacheBuster,
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
|
||||
@@ -7,8 +7,6 @@ import "~/styles/gridstack.scss";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -22,7 +20,6 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
@@ -41,7 +38,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
@@ -80,16 +77,14 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
const userId = session?.user.id ?? "anonymous";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense>
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
</>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<Suspense>
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
@@ -126,9 +121,8 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
|
||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||
if (!serialized) return null;
|
||||
|
||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted.clientState?.queries?.length) return null;
|
||||
const persisted = JSON.parse(serialized) as PersistedClient;
|
||||
if (!persisted?.clientState?.queries?.length) return null;
|
||||
|
||||
return <HydrationBoundary state={persisted.clientState} />;
|
||||
} catch (error) {
|
||||
|
||||
@@ -41,12 +41,13 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
if (!hasChangeAccess || isLoading) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
|
||||
@@ -54,9 +55,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
{!demoMode && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
@@ -157,7 +158,7 @@ const AddMenu = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -187,11 +188,11 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) {
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
if (demoMode) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
}
|
||||
open();
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
@@ -11,8 +11,6 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
@@ -47,8 +45,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between">
|
||||
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
|
||||
<Group justify="end">
|
||||
<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 { formatBytes } from "@homarr/common";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||
{formatBytes(file.size)}
|
||||
{humanFileSize(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
|
||||
@@ -142,7 +142,7 @@ export default async function Layout(props: {
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
|
||||
@@ -19,6 +19,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface WidgetRef {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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";
|
||||
@@ -29,7 +28,6 @@ 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")}
|
||||
|
||||
-2
@@ -26,8 +26,6 @@ export const integrationSecretIcons = {
|
||||
url: IconLink,
|
||||
opnsenseApiKey: IconKey,
|
||||
opnsenseApiSecret: IconPassword,
|
||||
patchmonApiKey: IconKey,
|
||||
patchmonApiSecret: IconPassword,
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Alert,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -19,14 +18,14 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, isIntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
@@ -88,14 +87,11 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const session = await auth();
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
const supportedIntegrations = integrations.filter((integration) => isIntegrationKind(integration.kind));
|
||||
const unsupportedCount = integrations.length - supportedIntegrations.length;
|
||||
|
||||
if (supportedIntegrations.length === 0 && unsupportedCount === 0) {
|
||||
if (integrations.length === 0) {
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const groupedIntegrations = supportedIntegrations.reduce(
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
@@ -112,105 +108,88 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{unsupportedCount > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("page.list.outdated.title")}>
|
||||
{t("page.list.outdated.description", { count: unsupportedCount })}
|
||||
</Alert>
|
||||
)}
|
||||
{supportedIntegrations.length === 0 ? (
|
||||
<NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />
|
||||
) : (
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
IconHomeFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconMailForward,
|
||||
IconBuildingStore,
|
||||
IconPhotoFilled,
|
||||
IconPointerFilled,
|
||||
IconSearch,
|
||||
@@ -81,12 +80,6 @@ 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 { formatBytes } from "@homarr/common";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { createLocalImageUrl } from "@homarr/icons/local";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
<TableTd>{formatBytes(media.size)}</TableTd>
|
||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||
<TableTd>
|
||||
{media.creator ? (
|
||||
<Group gap="sm">
|
||||
|
||||
Loaded 100 of 351 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user