Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c39a8854aa | ||
|
|
c32a5317ca | ||
|
|
f18033ddaa | ||
|
|
ff2b589380 | ||
|
|
9bc3802b3b | ||
|
|
2049c6ade0 | ||
|
|
e21a0bb6da | ||
|
|
61f22c8e64 | ||
|
|
6cca757493 | ||
|
|
1102ad8342 | ||
|
|
7c00c18a42 | ||
|
|
ea3dac752f | ||
|
|
f994d665d2 | ||
|
|
3358b6337f | ||
|
|
61d3fd5f08 | ||
|
|
3aac106156 |
No files matched your search
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
|
||||
- Cron jobs: `packages/cron-jobs/src/jobs/`
|
||||
- App routes: `apps/nextjs/src/app/[locale]/`
|
||||
|
||||
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
|
||||
Invoke `documentation-sync` when hidden capabilities or information not evident to an advanced user change, or existing guidance becomes inaccurate; follow the Documentation Sync criteria in `AGENTS.md`. When exposing tRPC through MCP, invoke `mcp-integration`.
|
||||
@@ -1,13 +1,17 @@
|
||||
---
|
||||
name: documentation-sync
|
||||
description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand.
|
||||
description: Write concise Homarr docs for advanced users, covering only hidden capabilities and information not evident from the UI. Use when that information changes or existing guidance becomes inaccurate; skip obvious controls and routine fixes.
|
||||
---
|
||||
|
||||
# Documentation Sync
|
||||
|
||||
Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together.
|
||||
Apply the audience and scope rules in `AGENTS.md` first. Before adding a passage, identify what an advanced user could not learn directly from the interface. If there is no such information, omit it.
|
||||
|
||||
## Map the change
|
||||
For hidden capabilities such as the advanced widget feature, explain discovery, activation, and non-obvious behavior. For visible standard controls such as Delete, omit narration of what the label already conveys. Use the shortest explanation that preserves necessary technical detail; remove redundant prose in the passage being edited.
|
||||
|
||||
When an update is warranted, inspect adjacent pages for structure and types, not as a verbosity target. The mappings below locate needed information; they do not require a page for every feature or code change.
|
||||
|
||||
## Locate a needed update
|
||||
|
||||
| Code change | Documentation target |
|
||||
| ----------------------------------- | -------------------------------------------------------------------- |
|
||||
@@ -40,11 +44,11 @@ For a widget:
|
||||
|
||||
- Add or update `apps/docs/docs/widgets/<slug>/index.ts` with the local `WidgetDefinition` pattern.
|
||||
- Add or update `index.mdx` with the established `WidgetHeader`, `WidgetConfig`, and `AddingWidget` components as applicable.
|
||||
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
|
||||
- Explain only non-obvious option semantics, integration prerequisites, permission constraints, or behavior that the interface does not make clear.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
1. Describe the shipped behavior, not an implementation plan.
|
||||
1. Every passage adds information an advanced user cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
|
||||
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
|
||||
3. Update every affected page and remove superseded guidance.
|
||||
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
|
||||
|
||||
+3
-2
@@ -16,9 +16,10 @@ LOG_LEVEL='info'
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
# Safe local source-development path. SQLite files are ignored by Git.
|
||||
# Set an absolute writable SQLite path so migrations and the app use the same file.
|
||||
# Their working directories differ; do not use a relative path here.
|
||||
# Container images use /appdata/db/db.sqlite by default instead.
|
||||
DB_URL='./db.sqlite'
|
||||
DB_URL=
|
||||
|
||||
# Those are the two ways to use the mysql2 driver:
|
||||
# 1. Using the URL format:
|
||||
|
||||
@@ -25,6 +25,18 @@ body:
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the bug here, screenshots if applicable.
|
||||
- type: input
|
||||
id: exactBuild
|
||||
attributes:
|
||||
label: Exact build or image
|
||||
description: For preview or development builds, include the commit SHA or image digest. A moving tag such as v2 is not enough to identify the build.
|
||||
placeholder: "Commit SHA, image digest, or release version"
|
||||
- type: input
|
||||
id: lastWorkingVersion
|
||||
attributes:
|
||||
label: Last working version
|
||||
description: If this worked before, include the last version or build where you tried the same action. Leave blank if unknown.
|
||||
placeholder: "Version or commit SHA"
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
@@ -33,6 +45,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.77.0
|
||||
- 1.76.2
|
||||
- 1.76.1
|
||||
- 1.76.0
|
||||
|
||||
+10
-57
@@ -97,17 +97,16 @@ jobs:
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm --filter nextjs build
|
||||
pnpm turbo run build --filter=@homarr/nextjs
|
||||
pnpm check:custom-widget-bundle
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
container:
|
||||
name: Container and E2E
|
||||
name: Container build
|
||||
# Same-repository previews already build amd64; only build a fallback when no digest is available.
|
||||
needs: [fast-gate, preview-amd64]
|
||||
if: |
|
||||
!cancelled() && needs.fast-gate.result == 'success' &&
|
||||
needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' &&
|
||||
(
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
@@ -115,10 +114,9 @@ jobs:
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
@@ -126,23 +124,10 @@ jobs:
|
||||
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"
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Reuse amd64 preview image
|
||||
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
|
||||
env:
|
||||
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
|
||||
|
||||
- name: Build container once
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -152,9 +137,7 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: |
|
||||
homarr-ci
|
||||
homarr-e2e
|
||||
tags: homarr-ci
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
@@ -182,37 +165,7 @@ jobs:
|
||||
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
|
||||
env:
|
||||
NODE_ENV: development
|
||||
CI: "true"
|
||||
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
--tag homarr-ci "${secret_args[@]}" .
|
||||
|
||||
preview-amd64:
|
||||
name: Build amd64 preview image
|
||||
@@ -320,10 +273,10 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [container, preview-amd64, preview-arm64]
|
||||
needs: [fast-gate, preview-amd64, preview-arm64]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.container.result == 'success' &&
|
||||
needs.fast-gate.result == 'success' &&
|
||||
needs.preview-amd64.result == 'success' &&
|
||||
needs.preview-arm64.result == 'success' &&
|
||||
(
|
||||
|
||||
@@ -16,10 +16,10 @@ permissions:
|
||||
jobs:
|
||||
create-and-merge-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Discord notification
|
||||
if: ${{ github.event.inputs.send-notifications }}
|
||||
if: ${{ inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
@@ -37,11 +37,22 @@ jobs:
|
||||
branch: next
|
||||
- name: Create pull request
|
||||
id: create-pull-request
|
||||
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
|
||||
run: |
|
||||
pr_url="$(gh pr list --base beta --head next --state open --json url --jq '.[0].url // empty')"
|
||||
if [[ -z "$pr_url" ]]; then
|
||||
pr_url="$(gh pr create \
|
||||
--title "chore(release): release ${{ steps.semver.outputs.next }}-beta" \
|
||||
--body "Detected change to be ${{ steps.semver.outputs.bump }}<br/>Bump version from ${{ steps.semver.outputs.current }} to ${{ steps.semver.outputs.next }}-beta" \
|
||||
--base beta \
|
||||
--head next \
|
||||
--label automerge)"
|
||||
fi
|
||||
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=${pr_url##*/}" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Discord notification
|
||||
if: ${{ github.event.inputs.send-notifications }}
|
||||
if: ${{ inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
@@ -58,7 +69,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
|
||||
run: |
|
||||
gh pr review --approve --body "Automatically approved by GitHub Action"
|
||||
gh pr review "${{ steps.create-pull-request.outputs.url }}" --approve --body "Automatically approved by GitHub Action"
|
||||
- name: Obtain token
|
||||
id: obtainMergeToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
@@ -82,21 +93,21 @@ jobs:
|
||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||
|
||||
- name: Merged Discord notification
|
||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
|
||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
|
||||
- name: Major Bump Discord notification
|
||||
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
|
||||
if: ${{ steps.semver.outputs.bump == 'major' && inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
|
||||
- name: Discord Fail Notification
|
||||
if: failure() && github.event.inputs.send-notifications
|
||||
if: ${{ failure() && inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
|
||||
@@ -2,8 +2,36 @@ name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/workshop.yml"
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "packages/definitions/**"
|
||||
- "packages/workshop/**"
|
||||
- "tooling/typescript/**"
|
||||
- "patches/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".npmrc"
|
||||
push:
|
||||
branches: [dev, v2, release/v2, main]
|
||||
paths:
|
||||
- ".github/workflows/workshop.yml"
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "packages/definitions/**"
|
||||
- "packages/workshop/**"
|
||||
- "tooling/typescript/**"
|
||||
- "patches/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".npmrc"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,21 +43,10 @@ concurrency:
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @homarr/workshop typecheck
|
||||
- run: pnpm --filter @homarr/docs typecheck
|
||||
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish-amd64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
|
||||
@@ -59,7 +59,13 @@ homarr/
|
||||
|
||||
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
|
||||
|
||||
When modifying user-facing code, you MUST also update the corresponding documentation:
|
||||
Write for smart, curious, advanced users. Documentation should contain only information they cannot reasonably discover or understand directly from the interface: hidden capabilities, non-obvious behavior, prerequisites, constraints, configuration contracts, and migration requirements.
|
||||
|
||||
Keep docs concise. Every paragraph must add knowledge beyond visible labels and standard UI conventions. Explain how to discover and use a hidden advanced widget feature; omit explanations that the Delete button deletes an item in edit mode. Avoid UI walkthroughs, exhaustive control descriptions, and repetition. When editing a passage, remove obvious or redundant prose rather than preserving verbosity as a precedent.
|
||||
|
||||
Update docs only when a change introduces or alters that non-obvious information, or makes existing guidance inaccurate. Routine fixes and self-explanatory UI changes need no docs update. A new feature alone does not require a page; apply the same reader-value test.
|
||||
|
||||
When documentation is needed, use these locations:
|
||||
|
||||
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
|
||||
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
|
||||
@@ -86,7 +92,7 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
- Lint: oxlint (not ESLint)
|
||||
- Format: oxfmt (not Prettier)
|
||||
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
|
||||
- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive.
|
||||
- Mantine: use the `mantine` MCP server in `.mcp.json` for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox`, the polymorphic `component` prop, `@mantine/hooks`) and check `packages/ui/` for existing conventions first.
|
||||
- Icons: @tabler/icons-react
|
||||
- Docs app can import from `@homarr/definitions` for shared types
|
||||
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
|
||||
@@ -94,11 +100,15 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
|
||||
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
|
||||
|
||||
## MCP servers
|
||||
|
||||
`.mcp.json` declares project-scoped MCP servers. Keep only keyless definitions there; credentials belong in local dotfiles. See [Mantine LLM tooling](https://mantine.dev/guides/llms/) for client-specific setup.
|
||||
|
||||
## Agent Skills
|
||||
|
||||
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
|
||||
|
||||
- `codebase-context` — architecture, package boundaries, and shared utilities
|
||||
- `documentation-sync` — required user-facing documentation updates
|
||||
- `documentation-sync` — documentation for changes users need explained
|
||||
- `mcp-integration` — safe tRPC-to-MCP exposure
|
||||
- `homarr-custom-widget` — safe Custom JSX v2 authoring
|
||||
@@ -1,3 +1,7 @@
|
||||
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
|
||||
|
||||
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
|
||||
|
||||
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
|
||||
|
||||
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
|
||||
|
||||
@@ -15,19 +15,13 @@ Many of you used the v2 demo when it was still rough, found bugs and came back t
|
||||
|
||||
We are also planning a small batch of **free, limited-edition Homarr swag** for Workshop contributors and a few randomly picked community members. It is not for sale and it is not for profit. No Homarr merch empire, sorry.
|
||||
|
||||
Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
|
||||
Homarr 2.0 builds on the dashboard you already use. Here is what changed.
|
||||
|
||||

|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
:::warning Back up before upgrading
|
||||
|
||||
Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2) and create a
|
||||
[Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume.
|
||||
Backups are boring. Restores are worse.
|
||||
|
||||
:::
|
||||
Update your Homarr image as usual. Homarr runs database migrations automatically when the updated container starts; no manual database migration or separate v2 upgrade procedure is needed. Workshop uses the production service by default, with no preview URL configuration required.
|
||||
|
||||
## Custom Widgets v2 and Workshop
|
||||
|
||||
@@ -150,9 +144,8 @@ Groups use a new Viewer, Editor and Admin matrix. The API, Assistant and MCP fol
|
||||
|
||||
Board startup work runs in parallel, heavy screens load when opened, and identical integration requests share work instead of doing it twice.
|
||||
|
||||
## Before upgrading
|
||||
## Compatibility notes
|
||||
|
||||
- Keep a backup and use the final release image, not a prerelease tag.
|
||||
- Legacy Custom JSX definitions enter a migration state. Homarr keeps encrypted credentials and an archived copy until the v2 definition validates.
|
||||
- Mobile layouts place rail content in the main flow instead of displaying rails.
|
||||
- Custom Widgets remain Beta.
|
||||
@@ -160,8 +153,7 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
|
||||
|
||||
## Links
|
||||
|
||||
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
|
||||
- Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2).
|
||||
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
|
||||
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
|
||||
- Try the [live demo](https://demo.homarr.dev/).
|
||||
- See [installation and update instructions](/docs/getting-started/installation/docker).
|
||||
- Browse [Workshop](https://homarr.dev/workshop).
|
||||
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
|
||||
@@ -90,7 +90,7 @@ homarr run dev
|
||||
homarr run --detach dev
|
||||
homarr run --pr 6441
|
||||
homarr run --pr 6441 --demo
|
||||
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://workshop.example.com/workshop
|
||||
```
|
||||
|
||||
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
|
||||
|
||||
@@ -31,9 +31,13 @@ pnpm install --frozen-lockfile
|
||||
### Run the Homarr application
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Set `DB_URL` to an absolute writable SQLite file path.
|
||||
3. Keep Redis and the optional local databases running in one terminal.
|
||||
4. Apply migrations and start Next.js in another terminal.
|
||||
2. Run `openssl rand -hex 32` twice. Set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
|
||||
values in `.env`; the example leaves both empty. Keep this file private and preserve `SECRET_ENCRYPTION_KEY`
|
||||
when reusing a database.
|
||||
3. Set `DB_URL` to an absolute writable SQLite file path. Migration and application commands run from different
|
||||
workspace directories, so a relative path can point them at different databases.
|
||||
4. Keep Redis and the optional local databases running in one terminal.
|
||||
5. Apply migrations and start Next.js in another terminal.
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
@@ -57,6 +61,20 @@ Homarr becomes the instance administrator.
|
||||
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
|
||||
default data; use `pnpm db:seed` to seed it explicitly later.
|
||||
|
||||
`pnpm dev` starts only Next.js. When your work needs scheduled tasks or live subscriptions, run the relevant service
|
||||
in a separate terminal alongside it:
|
||||
|
||||
```sh
|
||||
pnpm --filter @homarr/tasks dev
|
||||
```
|
||||
|
||||
```sh
|
||||
pnpm --filter @homarr/websocket dev
|
||||
```
|
||||
|
||||
The WebSocket service listens on port 3001. Production startup embeds both services, so these extra commands are
|
||||
only needed for development.
|
||||
|
||||
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
[Workshop development guide](./workshop).
|
||||
|
||||
@@ -80,5 +98,27 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
- `pnpm typecheck` — typecheck all workspaces.
|
||||
- `pnpm test` — unit and contract suites.
|
||||
- `pnpm build` — production monorepo build.
|
||||
- `docker build -t homarr .` — build a production Docker image.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
|
||||
- `docker build -t homarr:local .` — build a production Docker image.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:local` — run it.
|
||||
|
||||
## CI and focused validation
|
||||
|
||||
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
|
||||
|
||||
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
|
||||
|
||||
Choose the smallest relevant suite when changing behavior:
|
||||
|
||||
| Command | Scope | Requirements |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
|
||||
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
|
||||
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
|
||||
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
|
||||
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
|
||||
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
|
||||
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
|
||||
|
||||
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
|
||||
|
||||
Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording.
|
||||
@@ -13,7 +13,8 @@ You can change the user and group by using the `PUID` and `PGID` environment var
|
||||
Important to note is, that you'll also need to change the permissions for the mounted directories accordingly.
|
||||
|
||||
:::info
|
||||
When starting up an updated version of the container it will have about **10 seconds longer to start** as it has to change the permissions of the app directories
|
||||
At startup, Homarr updates ownership of its data directories and runtime caches for the selected user and group.
|
||||
Startup time depends on the amount of persistent data; application code and dependencies do not need ownership changes.
|
||||
:::
|
||||
|
||||
## Without mounted Docker socket
|
||||
|
||||
@@ -24,6 +24,10 @@ see [Managing integrations](/docs/management/integrations#configuration).
|
||||
|
||||
### Update
|
||||
|
||||
Update the image and recreate the container with the same configuration. Homarr runs database migrations automatically
|
||||
at startup, including when moving from v1 to v2; no separate upgrade procedure is needed. Keep the `/appdata` mount and
|
||||
`SECRET_ENCRYPTION_KEY` unchanged. Workshop connects to the production service by default.
|
||||
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
@@ -21,8 +21,8 @@ This method is only useful, when you want to develop on Homarr or extend it with
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [Node.js](https://nodejs.org/en/download/) 24.18 or newer
|
||||
- [pnpm](https://pnpm.io/installation) at the version pinned in the repository's `package.json`
|
||||
- [Redis](https://redis.io/download)
|
||||
|
||||
## Steps
|
||||
@@ -38,9 +38,9 @@ This method is only useful, when you want to develop on Homarr or extend it with
|
||||
Install all dependencies using <code>pnpm install</code>
|
||||
</li>
|
||||
<li>
|
||||
Copy the `.env.example` file to `.env`. Its `DB_URL='./db.sqlite'` value is ready for local use and stores the
|
||||
database in the cloned repository. Replace it with an absolute writable path such as
|
||||
`DB_URL='/home/username/homarr/db.sqlite'` if you want to store the database elsewhere.
|
||||
Copy `.env.example` to `.env` and set `DB_URL` to an absolute writable path, such as
|
||||
`DB_URL='/home/username/homarr/db.sqlite'`. Migration and application commands run from different workspace
|
||||
directories, so a relative path can point them at different databases.
|
||||
</li>
|
||||
<li>
|
||||
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
title: Upgrade from v1 to v2
|
||||
sidebar_position: 2
|
||||
tags:
|
||||
- Getting started
|
||||
- Upgrade
|
||||
- Migration
|
||||
- Backup
|
||||
---
|
||||
|
||||
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
|
||||
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
|
||||
|
||||
:::danger Back up before starting v2
|
||||
|
||||
In Homarr v1, go to **Management → Tools → Backup**, export a backup, and keep the downloaded file somewhere safe
|
||||
before starting v2. See [Backup and restore](/docs/management/backup) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Upgrade
|
||||
|
||||
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
|
||||
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
|
||||
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
|
||||
database migration fails.
|
||||
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
|
||||
5. After validation, keep the version pinned or return to your normal stable release policy.
|
||||
|
||||
For Docker Compose, the change is limited to the image tag before the usual update:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
image: ghcr.io/homarr-labs/homarr:v2.0.0
|
||||
```
|
||||
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose logs --follow homarr
|
||||
```
|
||||
|
||||
Do not add `-v` when stopping the Compose deployment; that can remove named volumes.
|
||||
|
||||
## What v2 migrates
|
||||
|
||||
The startup migration:
|
||||
|
||||
- creates the Custom Widget v2, Assistant, header preference, and branding storage;
|
||||
- adds Base/Mobile layout roles and left/right board gutters;
|
||||
- converts legacy Categories and dynamic sections into Containers and rails;
|
||||
- preserves legacy Custom Widget definitions, encrypted secrets, and board references for explicit migration.
|
||||
|
||||
Legacy Custom Widgets are not silently rewritten. In **Management → Custom Widgets**, entries marked
|
||||
**Migration required** provide a credential-free migration prompt. Review and test the resulting v2 definition before
|
||||
removing the archived v1 version. See [Custom Widgets: Import and Workshop](/docs/management/custom-widgets#import-and-workshop).
|
||||
|
||||
## Verify the upgraded instance
|
||||
|
||||
- Sign in with each enabled authentication method and verify administrator access.
|
||||
- Open every important board on desktop and mobile. Check Containers, former Categories, rails, item placement, and
|
||||
home-board assignments.
|
||||
- Test at least one read and one permitted action for each critical integration. Recheck integration and board access
|
||||
for representative Viewer, Editor, and Admin groups.
|
||||
- Check Docker or Podman endpoints, search engines, uploaded media, trusted certificates, scheduled tasks, WebSockets,
|
||||
and external Redis when used.
|
||||
- Review every legacy Custom Widget and migrate it only after its v2 preview and requests work.
|
||||
- Restart Homarr once more and confirm the logs contain no migration, database, Redis, authentication, or task errors.
|
||||
|
||||
## Roll back
|
||||
|
||||
Use the backup exported before the upgrade if you need to restore your v1 installation. Do not reuse a database that
|
||||
v2 has already migrated with v1.
|
||||
@@ -28,6 +28,8 @@ import { smartHomeExecuteAutomationWidget } from "@site/docs/widgets/smart-home-
|
||||
]}
|
||||
/>
|
||||
|
||||
Entity toggles and automation triggers send JSON requests to Home Assistant. A rejected action is shown as a failure.
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "llama.cpp"
|
||||
description: "Connect Homarr to a llama.cpp llama-server to monitor your local LLM: health, loaded model, generation speed and request status."
|
||||
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 { llamacppIntegration } from ".";
|
||||
import { llamacppWidget } from "@site/docs/widgets/llama-cpp";
|
||||
|
||||
<IntegrationHeader integration={llamacppIntegration} categories={["System Monitoring"]} />
|
||||
|
||||
The llama.cpp integration lets Homarr connect to a [llama-server](https://github.com/ggml-org/llama.cpp) instance so you can see the health of your local LLM, which model is loaded, and how fast it is generating — all from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: llamacppWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your `llama-server`, for example `http://192.168.1.50:8080`. Homarr calls the `/health`, `/v1/models`, `/metrics` and `/slots` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
:::tip Metrics
|
||||
Generation speed and token counters are read from the Prometheus endpoint at `/metrics`. Start `llama-server` with the `--metrics` flag so these values are available:
|
||||
|
||||
```shell
|
||||
llama-server --model /path/to/model.gguf --metrics
|
||||
```
|
||||
|
||||
Without `--metrics` the widget still shows health, model information, and active-request speed from `/slots`; metrics-backed counters and aggregate speed stay empty.
|
||||
:::
|
||||
|
||||
:::info Context and per-request speed
|
||||
Context (KV cache) usage and the in-flight request data are read from the `/slots` endpoint, which is available on every recent llama-server build and requires no extra flags. The per-request generation speed shown while a request is running is derived by tracking the request's decoded token count across polls, so it reflects the average speed of the request that is currently being generated.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
llama-server does not require authentication by default. The integration only needs the URL of your server.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const llamacppIntegration: IntegrationDefinition = {
|
||||
name: "llama.cpp",
|
||||
description:
|
||||
"Connect to a local llama.cpp llama-server to show model, throughput and request status on your dashboard.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
|
||||
path: "../../integrations/llama-cpp",
|
||||
};
|
||||
@@ -30,6 +30,8 @@ import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
|
||||
]}
|
||||
/>
|
||||
|
||||
Disk temperatures are matched to filesystem partitions, including device names without the `/dev/` prefix.
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
@@ -19,6 +19,9 @@ import Alert from "@theme/Admonition";
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
|
||||
|
||||
Disk space uses each pool's root dataset to account for RAIDZ parity and dataset limits. If dataset space is unavailable,
|
||||
Homarr falls back to physical pool space, which reads higher on RAIDZ, and keeps reporting pool health.
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
@@ -23,6 +23,8 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
|
||||
|
||||
CPU usage is averaged across logical processors, including SMT threads.
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
@@ -24,12 +24,14 @@ The What's Up Docker integration lets Homarr connect to your [WUD](https://githu
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr uses `/api/containers` under that URL for both connection testing and widget data.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
|
||||
WUD 9 requires authentication. Enter your WUD username and password in Homarr; the initial admin credentials configured with `WUD_AUTH_ADMIN_USER` and `WUD_AUTH_ADMIN_PASSWORD` work with Basic authentication. Older WUD versions without authentication can leave both fields empty.
|
||||
|
||||
Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside a trusted internal network.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
@@ -40,7 +42,7 @@ WUD does not require authentication by default. If your instance is protected wi
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -27,6 +27,12 @@ session cookie.
|
||||
API keys do not expire automatically and act as the user that created them. Every request uses that user's group and
|
||||
resource permissions.
|
||||
|
||||
## Invitation dates
|
||||
|
||||
`POST /api/invites` and the tRPC `invite.createInvite` procedure accept `expirationDate` as an ISO 8601 timestamp with a
|
||||
timezone, for example `2026-12-01T18:00:00Z`. Homarr validates the timestamp and stores it as a date. tRPC callers should
|
||||
send the ISO string rather than a JavaScript `Date` object.
|
||||
|
||||
## Health probes
|
||||
|
||||
The liveness endpoint does not require authentication:
|
||||
|
||||
@@ -36,6 +36,11 @@ current Base layout.
|
||||
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
|
||||
border color, and CSS classes.
|
||||
|
||||
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
|
||||
will remain when released.
|
||||
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
|
||||
previewed positions without a movement animation and stay there when you release the pointer.
|
||||
|
||||
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
|
||||
scrolls and store their contents separately for each layout.
|
||||
|
||||
|
||||
@@ -64,3 +64,8 @@ Below you can find instructions for some of the integrations.
|
||||
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
|
||||
### Proxmox
|
||||
|
||||
For the default Proxmox VE cluster certificates, trust the cluster CA from `/etc/pve/pve-root-ca.pem` on any node.
|
||||
See [Proxmox certificate management](https://pve.proxmox.com/wiki/Certificate_Management).
|
||||
@@ -57,5 +57,5 @@ Legacy definitions show **Migration required**. The migration prompt excludes cr
|
||||
describe the visible behavior separately, import the returned v2 definition, and verify it before removing the archived
|
||||
version.
|
||||
|
||||
Back up the database and `SECRET_ENCRYPTION_KEY` before upgrading or rolling back. Multi-replica installations must use
|
||||
the same Redis service so cached responses invalidate consistently.
|
||||
Keep `SECRET_ENCRYPTION_KEY` unchanged when updating Homarr so existing credentials remain readable. Multi-replica
|
||||
installations must use the same Redis service so cached responses invalidate consistently.
|
||||
@@ -8,18 +8,17 @@ tags:
|
||||
|
||||
# Model Context Protocol (MCP)
|
||||
|
||||
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
|
||||
current Homarr API to tools for compatible AI clients.
|
||||
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
|
||||
|
||||
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
|
||||
Requests are stateless: each request authenticates independently, without an MCP session ID.
|
||||
|
||||

|
||||
|
||||
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
|
||||
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
|
||||
## Connect a client
|
||||
|
||||
## Configure a client
|
||||
|
||||
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
|
||||
MCP configuration:
|
||||
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
|
||||
replacing the URL and API key:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -34,7 +33,10 @@ MCP configuration:
|
||||
}
|
||||
```
|
||||
|
||||
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
||||
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
|
||||
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
|
||||
|
||||
For clients that only support STDIO, use `mcp-remote`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -47,26 +49,61 @@ Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
||||
}
|
||||
```
|
||||
|
||||
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
|
||||
the catalog by namespace, tool name, or description; matching categories expand automatically.
|
||||
### OAuth
|
||||
|
||||
## Authentication and permissions
|
||||
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
|
||||
Homarr publishes discovery metadata under `/.well-known/`.
|
||||
|
||||
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
|
||||
for clients that support dynamic authorization.
|
||||
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
|
||||
such as `https://homarr.example.com`, without a path.
|
||||
|
||||
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
|
||||
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
|
||||
server and protected resource through Homarr's `/.well-known/` routes.
|
||||
## Permissions and tools
|
||||
|
||||
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
|
||||
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
|
||||
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
|
||||
Inspect the **AI / MCP** tab or your client's tool list for available actions.
|
||||
|
||||
Use HTTPS when the endpoint crosses an untrusted network.
|
||||
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
|
||||
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
||||
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
||||
through MCP.
|
||||
|
||||
## Verify the endpoint
|
||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
|
||||
The 2025 compatibility path accepts a compact `tools/list` request:
|
||||
## Authenticated integration requests
|
||||
|
||||
`integration_request` calls API endpoints without a dedicated tool, using stored integration credentials.
|
||||
Get `integrationId` from `integration_all`. Every method requires **full integration access**: arbitrary GET
|
||||
endpoints can expose credentials or change state. DELETE additionally requires `confirmed: true` after user confirmation.
|
||||
|
||||
Ask your agent to consult the service's official API docs with its browsing tools, or provide the endpoint contract.
|
||||
For example, using the [Sonarr API docs](https://sonarr.tv/docs/api/#v3):
|
||||
|
||||
```json
|
||||
{ "integrationId": "<Sonarr ID>", "method": "GET", "path": "/api/v3/series" }
|
||||
```
|
||||
|
||||
Deleting series 42 **and its episode files**, after confirmation:
|
||||
|
||||
```json
|
||||
{ "integrationId": "<Sonarr ID>", "method": "DELETE", "path": "/api/v3/series/42?deleteFiles=true", "confirmed": true }
|
||||
```
|
||||
|
||||
Supports GET, POST, PUT, PATCH and DELETE with an optional JSON `body` (10 KiB maximum; not allowed for GET).
|
||||
Returns `{status, data}` with parsed JSON, text, or `null` for an empty body, including upstream error responses.
|
||||
Known credentials are redacted. Requests have a 15-second total deadline, a 10-second per-request timeout and a
|
||||
1 MiB response limit. HTTPS uses Homarr's trusted certificates and hostname exceptions.
|
||||
|
||||
Paths follow `new URL(path, integration.url)`: `/api/...` starts at the origin root; `api/...` resolves relative to
|
||||
the configured URL's directory. Absolute URLs, protocol-relative paths, credentials, fragments and redirects are rejected.
|
||||
|
||||
Supported integrations: Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd,
|
||||
Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud. Other authentication schemes
|
||||
are rejected. The built-in Assistant's default mutation approval applies to this tool, including GET calls.
|
||||
|
||||
## Verify the connection
|
||||
|
||||
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
@@ -77,17 +114,14 @@ curl -X POST \
|
||||
https://homarr.example.com/api/mcp
|
||||
```
|
||||
|
||||
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
|
||||
A successful discovery or initialization response alone does not verify that tools can be listed.
|
||||
|
||||
## Tools and resources
|
||||
## Troubleshooting
|
||||
|
||||
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
|
||||
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
|
||||
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
|
||||
|
||||
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
||||
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
||||
through MCP.
|
||||
|
||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
|
||||
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
|
||||
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
|
||||
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
|
||||
of an unsupported date schema.
|
||||
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
|
||||
an alias.
|
||||
@@ -18,6 +18,10 @@ password. Decimal units are the default. Change them from the user form or **Com
|
||||
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
|
||||
Mb/s. Deleting a user does not delete boards they created.
|
||||
|
||||
In demo mode (`DEMO_MODE=true`), user deletion, password changes, and local username changes are disabled for everyone,
|
||||
including administrators, even when `DEMO_READ_ONLY=false` allows other changes. With demo editing enabled, other
|
||||
profile preferences remain editable. This applies to the UI and API.
|
||||
|
||||

|
||||
|
||||
## Groups
|
||||
|
||||
@@ -12,7 +12,7 @@ import { appWidget } from ".";
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
|
||||
check from the tile's edit actions.
|
||||
check from the tile's edit actions. In view mode, right-click uses the browser's link menu; Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep
|
||||
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
|
||||
visual density when a low-column dashboard is enlarged to fill the screen.
|
||||
|
||||
Right-click a bookmark link in view mode to use the browser menu, including opening it in a new tab or copying its address.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import gridIconsOnly from "./img/grid-icons-only.png";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "llama.cpp"
|
||||
description: "Displays the health, loaded model and generation speed of a local llama.cpp llama-server."
|
||||
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 { llamacppWidget } from ".";
|
||||
import { llamacppIntegration } from "@site/docs/integrations/llama-cpp";
|
||||
|
||||
<WidgetHeader widget={llamacppWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget shows the live status of your local [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server`: whether it is online, which model is loaded (with quantization, context size, file size and parameter count), the generation speed in tokens per second, and how many requests are being processed. When the server is started with `--metrics`, it also reports total token counts and the prompt cache hit rate.
|
||||
|
||||
### Generation speed
|
||||
|
||||
The widget picks the most meaningful speed for the current state (hover the speed for an explanation of which one is shown):
|
||||
|
||||
- **While a request is being generated** — the average speed of that request since it started, derived from the token count reported by `/slots`.
|
||||
- **While the server is busy but the per-request data is unavailable** — the live speed from the `/metrics` rate gauge.
|
||||
- **While the server is idle** — the average generation speed since the server started (cumulative tokens ÷ seconds), so the value stays meaningful instead of dropping to zero.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: llamacppIntegration,
|
||||
note: "Displays health, model details, generation speed and request status",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={llamacppWidget.configuration} />
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
export const llamacppWidget: WidgetDefinition = {
|
||||
icon: IconCpu,
|
||||
name: "llama.cpp",
|
||||
description: "Shows the health, loaded model and generation speed of a local llama.cpp llama-server.",
|
||||
path: "../../widgets/llama-cpp",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show title",
|
||||
description: "Displays the llama.cpp label and status badge at the top of the widget",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show model info",
|
||||
description: "Displays the loaded model name, quantization, context size and file size",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show cache hit rate",
|
||||
description: "Displays the prompt cache hit rate as a percentage bar",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show context usage",
|
||||
description: "Displays the context (KV cache) token usage as a progress bar",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@ import temperature from "./img/temperature.png";
|
||||
### Interactions
|
||||
|
||||
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
||||
- After a successful toggle, the displayed state refreshes immediately.
|
||||
- If Homarr cannot refresh an entity, the last known state stays visible but toggling is disabled until the connection recovers.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
@@ -51,4 +51,6 @@ import withDisplayname from "./img/with-displayname.png";
|
||||
|
||||
### Configuration
|
||||
|
||||
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
|
||||
|
||||
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
||||
+5
-2
@@ -2304,7 +2304,8 @@
|
||||
"traefik",
|
||||
"customApi",
|
||||
"assistant",
|
||||
"wud"
|
||||
"wud",
|
||||
"llamacpp"
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
@@ -2572,7 +2573,9 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expirationDate": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
|
||||
describe("Copy AI prompt", () => {
|
||||
it("uses one self-contained prompt without an embedded-skill branch", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("buildCustomWidgetAiPrompt");
|
||||
expect(source).not.toContain("embedded-authoring-prompt");
|
||||
expect(source).not.toContain("Menu");
|
||||
});
|
||||
|
||||
it("copies a complete prompt within the public size limit", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
|
||||
expect(prompt.length).toBeLessThanOrEqual(12_000);
|
||||
expect(prompt).toContain("exactly one complete `json` fenced block");
|
||||
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
|
||||
});
|
||||
|
||||
it("keeps AI actions and section navigation readable on narrow screens", () => {
|
||||
const card = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
const styles = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
|
||||
"utf8",
|
||||
);
|
||||
const actions = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
expect(card).toContain('<Accordion variant="contained">');
|
||||
expect(card).not.toContain("defaultValue");
|
||||
expect(actions).toContain("<ActionIcon");
|
||||
expect(styles).toContain(".sectionNav > a");
|
||||
expect(styles).toContain("flex: 0 0 auto");
|
||||
});
|
||||
});
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
|
||||
|
||||
describe("legacy custom widget migration UI", () => {
|
||||
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
|
||||
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
|
||||
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
|
||||
|
||||
expect(list).toContain("widget.migrationRequired");
|
||||
expect(list).toContain('t("page.list.migrationInstructions")');
|
||||
expect(actions).toContain("legacyMigrationPrompt.fetch");
|
||||
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
|
||||
expect(actions).toContain("navigator.clipboard.readText()");
|
||||
expect(actions).toContain('t("action.pasteMigration")');
|
||||
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
|
||||
expect(actions).toContain("legacyId={widget.id}");
|
||||
});
|
||||
|
||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
||||
// The import pipeline is shared by the dialog and the Workshop install page.
|
||||
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||
|
||||
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(importer).toMatch(
|
||||
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
|
||||
);
|
||||
expect(importer).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(importer).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
expect(dialog).toContain("useCustomWidgetImport({");
|
||||
expect(dialog).toContain("legacyId,");
|
||||
});
|
||||
});
|
||||
-11
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
@@ -76,16 +75,6 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("Custom Widget preview panel", () => {
|
||||
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toContain("forceColorScheme");
|
||||
expect(source).not.toContain("onThemeChange");
|
||||
});
|
||||
|
||||
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
mutate(
|
||||
{
|
||||
expirationDate: new Date(values.expirationDate),
|
||||
expirationDate: new Date(values.expirationDate).toISOString(),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import {
|
||||
createAssistantGenerationAccessToken,
|
||||
getAssistantRequestContextEntitiesAsync,
|
||||
@@ -547,19 +547,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
|
||||
try {
|
||||
const procedure = mcpTool.pathInRouter.reduce<unknown>(
|
||||
(current, segment) =>
|
||||
(typeof current === "object" || typeof current === "function") && current !== null
|
||||
? (current as Record<string, unknown>)[segment]
|
||||
: undefined,
|
||||
caller,
|
||||
);
|
||||
if (typeof procedure !== "function") {
|
||||
throw new Error("Procedure not callable");
|
||||
}
|
||||
const result = await (procedure as (value: unknown) => Promise<unknown>)(
|
||||
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
|
||||
);
|
||||
const result = await callMcpTool(caller, mcpTool, executionInput);
|
||||
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
|
||||
return toAssistantToolOutput(result, {
|
||||
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type * as CatalogModule from "../_extract-tools";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
extractCatalog: vi.fn(),
|
||||
ipAddress: vi.fn(() => "127.0.0.1"),
|
||||
toolProcedure: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
@@ -14,7 +17,9 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
||||
vi.mock("@homarr/api/mcp", () => ({
|
||||
vi.mock("@homarr/api/mcp", async () => ({
|
||||
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
|
||||
extractMcpToolsFromProcedures: mocks.extractCatalog,
|
||||
createTRPCContext: vi.fn(() => ({})),
|
||||
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
||||
}));
|
||||
@@ -54,6 +59,7 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "List boards",
|
||||
type: "query",
|
||||
pathInRouter: ["board", "getAllBoards"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
@@ -61,6 +67,7 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "List Custom Widgets",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "list"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
@@ -68,6 +75,7 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "Search Workshop",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "workshopSearch"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
@@ -305,3 +313,21 @@ describe("MCP protocol errors", () => {
|
||||
expect(metadata).not.toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
|
||||
test("logs catalog omissions only when the shared catalog is first built", async () => {
|
||||
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
|
||||
const tools = [{ name: "valid" }];
|
||||
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
|
||||
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
|
||||
expect(extractMcpTools()).toBe(tools);
|
||||
expect(extractMcpTools()).toBe(tools);
|
||||
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
|
||||
});
|
||||
|
||||
test("rejects unauthenticated tool calls before execution", async () => {
|
||||
mocks.authenticate.mockResolvedValue(null);
|
||||
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.toolProcedure).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
const logger = createLogger({ module: "mcp-catalog" });
|
||||
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
|
||||
|
||||
export function extractMcpTools() {
|
||||
if (cache) return cache;
|
||||
|
||||
if (cache) return cache.tools;
|
||||
cache = extractMcpToolsFromProcedures(mcpRouter);
|
||||
|
||||
return cache;
|
||||
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
|
||||
return cache.tools;
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { McpTool } from "@homarr/api/mcp";
|
||||
import { appManageSchema } from "@homarr/validation/app";
|
||||
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
|
||||
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
|
||||
|
||||
import { createMcpProtocolHandler } from "./_protocol";
|
||||
|
||||
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
|
||||
|
||||
const modernMetadata = {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
|
||||
@@ -52,7 +58,7 @@ const echoTool: McpTool = {
|
||||
description: "Echo a message",
|
||||
type: "query",
|
||||
pathInRouter: ["echo"],
|
||||
inputValidator: echoValidator,
|
||||
inputMode: "object",
|
||||
inputSchema: z.toJSONSchema(echoValidator),
|
||||
};
|
||||
|
||||
@@ -163,3 +169,197 @@ describe("MCP v2 protocol", () => {
|
||||
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
|
||||
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
|
||||
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
|
||||
|
||||
const call = async (
|
||||
handler: ReturnType<typeof createMcpProtocolHandler>,
|
||||
name: string,
|
||||
argumentsValue: Record<string, unknown>,
|
||||
) => {
|
||||
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
|
||||
if (!modern) {
|
||||
request = new Request("http://homarr.test/api/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: argumentsValue },
|
||||
}),
|
||||
});
|
||||
}
|
||||
const response = await handler.fetch(request);
|
||||
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
|
||||
return (await parseResponse(response)).result;
|
||||
};
|
||||
|
||||
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
|
||||
const transform = vi.fn((value: string) => `${value}!`);
|
||||
const execution = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({
|
||||
update: exposed
|
||||
.input(
|
||||
z.looseObject({
|
||||
name: z.string().transform(transform),
|
||||
limit: z.number().default(7),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => execution(input)),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.diagnostics).toEqual([]);
|
||||
const realCaller = router.createCaller({ user: "one" });
|
||||
const dispatch = vi.fn(realCaller.update);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: { update: dispatch },
|
||||
tools: catalog.tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
const argumentsValue = { name: "hello", extra: "preserved" };
|
||||
const result = await call(handler, "update", argumentsValue);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
|
||||
expect(transform).toHaveBeenCalledTimes(1);
|
||||
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
|
||||
await call(handler, "update", { name: "hello", limit: "7" });
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(execution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
|
||||
const execution = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({
|
||||
bounded: exposed
|
||||
.input(z.object({ count: z.number().min(2) }))
|
||||
.input(z.object({ count: z.number().max(5) }))
|
||||
.mutation(({ input }) => execution(input)),
|
||||
refined: exposed
|
||||
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
|
||||
.mutation(({ input }) => execution(input)),
|
||||
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
|
||||
none: exposed.query(({ input }) => ({ absent: input === undefined })),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "one" }),
|
||||
tools: catalog.tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
|
||||
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
|
||||
expect(execution).not.toHaveBeenCalled();
|
||||
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
|
||||
expect(execution).toHaveBeenCalledWith({ count: 3 });
|
||||
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
|
||||
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
|
||||
});
|
||||
|
||||
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
|
||||
const mutation = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "admin" }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
const argumentsValue = {
|
||||
name: `${" ".repeat(64)}App `,
|
||||
description: " description ",
|
||||
iconUrl: " icon ",
|
||||
href: null,
|
||||
pingUrl: " https://example.com ",
|
||||
};
|
||||
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
|
||||
expect(mutation).toHaveBeenCalledExactlyOnceWith({
|
||||
name: "App",
|
||||
description: "description",
|
||||
iconUrl: "icon",
|
||||
href: null,
|
||||
pingUrl: "https://example.com",
|
||||
});
|
||||
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
|
||||
const mutation = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({
|
||||
invite: exposed
|
||||
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
|
||||
.mutation(({ input }) => mutation(input)),
|
||||
});
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "admin" }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
|
||||
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
|
||||
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
|
||||
}
|
||||
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
|
||||
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
|
||||
}
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("enforces tRPC permissions before executing mutations", async () => {
|
||||
const mutation = vi.fn(() => "changed");
|
||||
const router = trpc.router({
|
||||
restricted: exposed
|
||||
.use(({ ctx, next }) => {
|
||||
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
|
||||
return next();
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(mutation),
|
||||
});
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "guest" }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Forbidden",
|
||||
});
|
||||
expect((await call(handler, "restricted", {})).isError).toBe(true);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("isolates authenticated callers when reusing catalog schemas", async () => {
|
||||
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
|
||||
const { tools } = extractMcpToolsFromProcedures(router);
|
||||
const handlers = ["alice", "bob"].map((user) =>
|
||||
createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Failed",
|
||||
}),
|
||||
);
|
||||
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
|
||||
expect(results.map((result) => result.content)).toEqual([
|
||||
[{ type: "text", text: '"alice"' }],
|
||||
[{ type: "text", text: '"bob"' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
|
||||
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import { callMcpTool } from "@homarr/api/mcp";
|
||||
import type { McpTool } from "@homarr/api/mcp";
|
||||
|
||||
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
|
||||
import { serializeMcpToolResult } from "./_serialize-result";
|
||||
|
||||
interface CreateMcpProtocolHandlerOptions {
|
||||
caller: unknown;
|
||||
@@ -15,15 +17,6 @@ interface CreateMcpProtocolHandlerOptions {
|
||||
onProtocolError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const resolveProcedure = (caller: unknown, path: string[]) =>
|
||||
path.reduce<unknown>(
|
||||
(current, segment) =>
|
||||
(typeof current === "object" || typeof current === "function") && current !== null
|
||||
? (current as Record<string, unknown>)[segment]
|
||||
: undefined,
|
||||
caller,
|
||||
);
|
||||
|
||||
export const createMcpProtocolHandler = ({
|
||||
caller,
|
||||
tools,
|
||||
@@ -52,25 +45,16 @@ export const createMcpProtocolHandler = ({
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputValidator,
|
||||
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const procedure = resolveProcedure(caller, tool.pathInRouter);
|
||||
if (typeof procedure !== "function") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const input = Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
|
||||
const result = await callMcpTool(caller, tool, args);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(toAssistantToolOutput(result)),
|
||||
text: serializeMcpToolResult(toAssistantToolOutput(result)),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serializeMcpToolResult } from "./_serialize-result";
|
||||
|
||||
describe("serializeMcpToolResult", () => {
|
||||
it("serializes objects as JSON strings", () => {
|
||||
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
|
||||
});
|
||||
|
||||
it("serializes primitive values", () => {
|
||||
expect(serializeMcpToolResult("hello")).toBe('"hello"');
|
||||
expect(serializeMcpToolResult(42)).toBe("42");
|
||||
expect(serializeMcpToolResult(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("serializes null as the string null", () => {
|
||||
expect(serializeMcpToolResult(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("serializes void results as the string null so MCP content text is always a string", () => {
|
||||
expect(serializeMcpToolResult(undefined)).toBe("null");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serializeMcpToolResult(result: unknown): string {
|
||||
return JSON.stringify(result) ?? "null";
|
||||
}
|
||||
@@ -5,10 +5,9 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getWidgetName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { InlineConfirmMenuItem } from "@homarr/ui";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||
|
||||
@@ -40,6 +39,7 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
||||
const tItem = useI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
@@ -143,14 +143,21 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<InlineConfirmMenuItem
|
||||
<Menu.Item
|
||||
color="red"
|
||||
confirmLabel={tItem("remove.message")}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onConfirm={() => removeItem({ itemId: item.id })}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
openConfirmModal({
|
||||
title: tItem("remove.message"),
|
||||
children: label,
|
||||
labels: { confirm: tItem("action.remove") },
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</InlineConfirmMenuItem>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -244,7 +244,13 @@ export const WidgetContextMenu = ({
|
||||
setMenuOpened(false);
|
||||
}, []);
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
|
||||
if (
|
||||
!session ||
|
||||
!settings.enableRightClickOnWidgets ||
|
||||
isEditMode ||
|
||||
item.kind === "app" ||
|
||||
item.kind === "bookmarks"
|
||||
) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionCollapse } from "./section-collapse";
|
||||
import { useSectionContext } from "./section-context";
|
||||
import { useOpenSectionApps } from "./use-open-section-apps";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
@@ -25,6 +26,7 @@ interface Props {
|
||||
|
||||
export const BoardContainerSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useI18n("section.container");
|
||||
const tSection = useI18n("section");
|
||||
@@ -39,8 +41,26 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
let labelLeft = 8;
|
||||
let labelRight = 8;
|
||||
if (isEditMode) {
|
||||
// Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap.
|
||||
labelLeft = 36;
|
||||
if (parentSection.kind === "container") labelLeft = 68;
|
||||
} else if (options.showOpenAll) {
|
||||
labelRight = 40;
|
||||
}
|
||||
// Expanded controls sit on the border like the ordinary label, without reserving a header row.
|
||||
const toggleLayout = isVisuallyCollapsed
|
||||
? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" }
|
||||
: {
|
||||
top: "calc(var(--mantine-spacing-xs) * -1)",
|
||||
left: labelLeft,
|
||||
w: "auto",
|
||||
h: 20,
|
||||
maw: `calc(100% - ${labelLeft + labelRight}px)`,
|
||||
};
|
||||
const toggleIcon = isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />;
|
||||
|
||||
return (
|
||||
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
|
||||
@@ -68,24 +88,14 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
px={12}
|
||||
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
|
||||
{...toggleLayout}
|
||||
px={6}
|
||||
ps={isVisuallyCollapsed ? labelLeft : 6}
|
||||
pe={isVisuallyCollapsed ? labelRight : 6}
|
||||
radius="sm"
|
||||
variant="default"
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
leftSection={
|
||||
options.showLabel ? (
|
||||
isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
leftSection={options.showLabel && toggleIcon}
|
||||
onClick={toggle}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
@@ -94,13 +104,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
data-board-container-label
|
||||
title={options.showLabel ? label : undefined}
|
||||
>
|
||||
{options.showLabel ? (
|
||||
label
|
||||
) : isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)}
|
||||
{options.showLabel ? label : toggleIcon}
|
||||
</Button>
|
||||
)}
|
||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
.board-grid-editor {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
inset: 0 0 auto;
|
||||
margin: 0 auto;
|
||||
overflow: visible;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
zoom: var(--board-grid-content-scale, 1);
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
@@ -20,14 +22,7 @@
|
||||
width: var(--board-grid-preview-width) !important;
|
||||
height: var(--board-grid-preview-height) !important;
|
||||
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
|
||||
transition:
|
||||
translate 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
@@ -109,8 +104,8 @@
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
|
||||
/* Displaced entries must snap with the preview too; animating their offsets can trail a new swap target. */
|
||||
body[data-board-grid-interacting] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
@@ -138,6 +133,21 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
|
||||
}
|
||||
|
||||
.board-grid-placeholder[data-grid-placeholder-type="section"] {
|
||||
clip-path: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.board-grid-placeholder[data-grid-placeholder-type="section"]::after {
|
||||
position: absolute;
|
||||
inset: var(--board-container-inset, 0px);
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.board-grid-resize-outline {
|
||||
z-index: 1002;
|
||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||
|
||||
@@ -415,7 +415,11 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
sectionId: grid.id,
|
||||
placements: grid.placements,
|
||||
}));
|
||||
commitSectionGrids(snapshots);
|
||||
const hasChanges = commitSectionGrids(snapshots);
|
||||
if (!hasChanges) {
|
||||
clearInteraction();
|
||||
return;
|
||||
}
|
||||
|
||||
const placement = state.grids
|
||||
.flatMap((grid) => grid.placements)
|
||||
@@ -429,7 +433,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
)} ${placement.h}`,
|
||||
);
|
||||
}
|
||||
clearInteraction();
|
||||
// Cache subscribers receive the committed board asynchronously. Keep the preview until
|
||||
// the board-change layout effect clears it, so displaced items never flash at their old positions.
|
||||
activeRef.current = null;
|
||||
},
|
||||
[announce, clearInteraction, commitSectionGrids, currentLayoutId, t],
|
||||
);
|
||||
|
||||
@@ -136,6 +136,7 @@ export const GridPreviewLayer = ({
|
||||
style={getLogicalItemStyle(targetPlacement)}
|
||||
data-grid-placeholder-for={interaction?.activeId}
|
||||
data-grid-placeholder-mode={interaction?.mode}
|
||||
data-grid-placeholder-type={targetPlacement.type}
|
||||
data-grid-x={targetPlacement.x}
|
||||
data-grid-y={targetPlacement.y}
|
||||
data-grid-w={targetPlacement.w}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
while growing during a drag happening anywhere else on the board - any container (not just
|
||||
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
|
||||
inside that section's DOM tree, so the stray value cascades straight through and silently
|
||||
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
|
||||
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
|
||||
unset, not merely inherited. Reset it here so every container can never inherit a stray
|
||||
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx, which only
|
||||
kicks in when the property is entirely unset, not merely inherited. Reset it here so every
|
||||
container can never inherit a stray
|
||||
value from anywhere above it, no matter what any ancestor's own drag state is doing.
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
||||
.viewport[data-section-kind="container"] {
|
||||
@@ -46,7 +46,6 @@
|
||||
|
||||
.editorGrid {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.editorPortalHost {
|
||||
@@ -63,6 +62,11 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* SectionGrid fits its content to the same inset in both view and edit mode. */
|
||||
.staticItem[data-type="section"] > .contentMount {
|
||||
inset: var(--board-container-inset, 0px);
|
||||
}
|
||||
|
||||
.staticItem[data-type="item"] > .contentMount {
|
||||
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -29,6 +29,9 @@ import { useSectionItems } from "../use-section-items";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
const GridContentScaleContext = createContext(1);
|
||||
const CONTAINER_CARD_INSET = 5;
|
||||
|
||||
interface SectionGridProps {
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
columnCount: number;
|
||||
@@ -50,6 +53,7 @@ export const SectionGrid = ({
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const canvasScale = useBoardCanvasScale();
|
||||
const parentContentScale = useContext(GridContentScaleContext);
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const editorRegistry = useGridEditorRegistry();
|
||||
const editorHostRef = useRef<HTMLDivElement>(null);
|
||||
@@ -143,68 +147,37 @@ export const SectionGrid = ({
|
||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||
const viewportRowCount =
|
||||
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
|
||||
// A container's own visible card is inset from its allocated board cell by the board's
|
||||
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
|
||||
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
|
||||
// any widget, so it gets that same gap). A widget's content just fills whatever size its
|
||||
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
|
||||
// the same column/row counts used to allocate the *outer*, uninset cell - so without
|
||||
// subtracting that gap back out here, a container's inner grid renders larger than its own
|
||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
|
||||
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
|
||||
// not like the grid's own un-compensated logical-pixel math, so it needs the same
|
||||
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
|
||||
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
|
||||
// all, the header bar covers the first row of the container's content instead of sitting
|
||||
// above it. Only the vertical axis is affected - the toggle spans the full width already.
|
||||
const collapsibleHeaderInset =
|
||||
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
|
||||
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
||||
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset);
|
||||
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
||||
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
||||
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
||||
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
|
||||
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
|
||||
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
|
||||
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
|
||||
// corrected to compensate so icon/text sizing inside the container isn't affected.
|
||||
const parentScale = canvasScale * parentContentScale;
|
||||
const effectiveCanvasScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1;
|
||||
// Match the card's inset without changing its persisted grid footprint. Include ancestor
|
||||
// container zoom so equally sized nested containers keep distinct borders at every depth.
|
||||
let outerCardInset = 0;
|
||||
if (section.kind === "container") {
|
||||
outerCardInset = (2 * CONTAINER_CARD_INSET) / effectiveCanvasScale;
|
||||
}
|
||||
const fullGridWidth = getLogicalGridSize(columnCount);
|
||||
const fullGridHeight = getLogicalGridSize(rowCount);
|
||||
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
|
||||
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
|
||||
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
|
||||
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
|
||||
// describe the real visible card size in both cases (they equal the non-scrollable values when
|
||||
// rowCount and viewportRowCount are the same), so use those instead.
|
||||
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
|
||||
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
|
||||
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
|
||||
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
|
||||
// be left with a small amount of unfilled slack when width/height need different ratios to
|
||||
// exactly fit - centered below so it's even on both sides rather than left-aligned.
|
||||
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
|
||||
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
|
||||
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
|
||||
// zero or negative - combinedUiScale divides by this value, and this value is also used as
|
||||
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
|
||||
const containerContentScale =
|
||||
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
|
||||
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
|
||||
: 1;
|
||||
const logicalWidth = Math.max(1, fullGridWidth - outerCardInset);
|
||||
const viewportHeight = Math.max(1, fullViewportHeight - outerCardInset);
|
||||
// Fit square cells into the inset card. Scrollable grids use the visible row count,
|
||||
// not their full content height. Collapse controls never change this geometry.
|
||||
let containerContentScale = 1;
|
||||
if (section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0) {
|
||||
containerContentScale = Math.max(
|
||||
0.01,
|
||||
Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1),
|
||||
);
|
||||
}
|
||||
const contentScale = parentContentScale * containerContentScale;
|
||||
const effectiveContentScale = effectiveCanvasScale * containerContentScale;
|
||||
// Compute the combined value in JS rather than a CSS calc() referencing the existing
|
||||
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
|
||||
// sequential/temporal order the way normal variables do, so any calc() on this element that
|
||||
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
|
||||
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
|
||||
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
|
||||
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
|
||||
const combinedUiScale = calculateBoardUiScale(canvasScale) / contentScale;
|
||||
// A collapsed container's compact coordinates are display-only. Its own
|
||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||
@@ -300,8 +273,9 @@ export const SectionGrid = ({
|
||||
{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
||||
marginTop: collapsibleHeaderInset || undefined,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
"--board-grid-content-scale": containerContentScale,
|
||||
"--board-container-inset": `${CONTAINER_CARD_INSET / effectiveContentScale}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-section-id={section.id}
|
||||
@@ -320,14 +294,17 @@ export const SectionGrid = ({
|
||||
height: fullGridHeight,
|
||||
zoom: containerContentScale,
|
||||
margin: containerContentScale < 1 ? "0 auto" : undefined,
|
||||
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
|
||||
"--board-canvas-inverse-scale": 1 / effectiveContentScale,
|
||||
"--board-canvas-ui-scale": combinedUiScale,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||
>
|
||||
<SectionContent />
|
||||
<GridContentScaleContext.Provider value={contentScale}>
|
||||
<SectionContent />
|
||||
</GridContentScaleContext.Provider>
|
||||
</Box>
|
||||
<div ref={editorHostRef} className={classes.editorPortalHost} />
|
||||
</Box>
|
||||
@@ -335,9 +312,6 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Matches the collapsible container toggle's h={24} in container-section.tsx.
|
||||
const CONTAINER_HEADER_HEIGHT = 24;
|
||||
|
||||
const INTERACTIVE_GRID_SELECTOR =
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||
|
||||
@@ -33,8 +33,8 @@ export const useGridLayoutActions = () => {
|
||||
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
|
||||
),
|
||||
);
|
||||
let hasChanges = false;
|
||||
updateBoard((previous) => {
|
||||
let hasChanges = false;
|
||||
const items = previous.items.map((item) => {
|
||||
const layouts = item.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
|
||||
@@ -97,6 +97,7 @@ export const useGridLayoutActions = () => {
|
||||
|
||||
return hasChanges ? { ...previous, items, sections } : previous;
|
||||
});
|
||||
return hasChanges;
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
--border-color: rgb(
|
||||
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
|
||||
);
|
||||
/* Keep the border visual without letting it shrink the square-cell grid's content box. */
|
||||
border-width: 0;
|
||||
box-shadow: inset 0 0 0 calc(1px * var(--board-canvas-inverse-scale, 1)) var(--border-color);
|
||||
}
|
||||
|
||||
.itemCard:global(.iframe-wrapper) {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("DynamicBreadcrumb", () => {
|
||||
it("falls back to the raw segment when a path segment has no translation", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Dynamic ids (e.g. /manage/custom-widgets/workshop/<id>) have no message and are
|
||||
// not mapped while their page loads; resolving them must not raise MISSING_MESSAGE.
|
||||
expect(source).toContain("let label = pathnamePart");
|
||||
expect(source).toContain("if (mappedValue !== undefined)");
|
||||
expect(source).toContain("t.has(labelKey)");
|
||||
expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
|
||||
|
||||
const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
|
||||
|
||||
const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx";
|
||||
const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx";
|
||||
const browseRoutePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/page.tsx";
|
||||
const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx";
|
||||
const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx";
|
||||
|
||||
describe("Workshop widget install flow", () => {
|
||||
afterEach(() => {
|
||||
document.head.innerHTML = "";
|
||||
@@ -39,136 +30,4 @@ describe("Workshop widget install flow", () => {
|
||||
|
||||
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
|
||||
});
|
||||
|
||||
it("installs from a page with a sticky footer instead of stacked modals", () => {
|
||||
const detail = read(detailPage);
|
||||
|
||||
expect(detail).toContain("ManageStickyFooter");
|
||||
expect(detail).toContain("useCustomWidgetImport");
|
||||
// Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved.
|
||||
expect(detail).not.toContain("Modal.Stack");
|
||||
expect(detail).not.toContain("useModalsStack");
|
||||
expect(detail).not.toContain("CustomWidgetImportDialog");
|
||||
});
|
||||
|
||||
it("browses the Workshop as a card grid with per-item Workshop links", () => {
|
||||
const browse = read(browsePage);
|
||||
const route = read(browseRoutePage);
|
||||
|
||||
expect(browse).toContain("WorkshopSubmissionGrid");
|
||||
expect(browse).toContain("SearchInput");
|
||||
expect(browse).toContain("TablePagination");
|
||||
expect(browse).toContain("CustomWidgetTabs");
|
||||
expect(route).toContain("resolveHomarrUrlConfig");
|
||||
expect(route).toContain("workshopWebUrl={workshopWebUrl}");
|
||||
expect(browse).toContain("workshopWebUrl: string");
|
||||
expect(browse).toContain("href={workshopWebUrl}");
|
||||
expect(browse).toContain('workshopWebUrl.replace(/\\/+$/u, "")');
|
||||
expect(browse).toContain("encodeURIComponent(item.id)");
|
||||
expect(browse).toContain("WorkshopVoteControl");
|
||||
expect(browse).not.toContain("Modal");
|
||||
});
|
||||
|
||||
it("renders the grid with real cards and screenshot previews", () => {
|
||||
const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx");
|
||||
const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx");
|
||||
|
||||
expect(grid).toContain("SimpleGrid");
|
||||
expect(card).toContain("Card.Section");
|
||||
expect(card).toContain("PREVIEW_HEIGHT");
|
||||
});
|
||||
|
||||
it("shows the viewer's own vote as a filled control", () => {
|
||||
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
|
||||
|
||||
expect(vote).toContain("useWorkshopUserVotesQuery");
|
||||
expect(vote).toContain("IconArrowBigUpFilled");
|
||||
expect(vote).toContain("IconArrowBigDownFilled");
|
||||
expect(vote).toContain('color={upvoted ? "red" : "gray"}');
|
||||
expect(vote).toContain("aria-pressed={upvoted}");
|
||||
});
|
||||
|
||||
it("previews screenshots large with a zoom lightbox", () => {
|
||||
const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx");
|
||||
const detail = read(detailPage);
|
||||
|
||||
expect(screenshots).toContain("1200x800");
|
||||
expect(screenshots).toContain("zoomScreenshot");
|
||||
expect(screenshots).toContain('import { Lightbox } from "@mantine/lightbox"');
|
||||
expect(screenshots).toContain("withThumbnails");
|
||||
expect(screenshots).toContain("withZoom");
|
||||
expect(screenshots).toContain("withKeyboardEvents");
|
||||
expect(screenshots).not.toMatch(/<Modal\b/u);
|
||||
expect(detail).toContain("WorkshopScreenshots");
|
||||
});
|
||||
|
||||
it("publishes from a page with the same sticky footer", () => {
|
||||
const publish = read(publishPage);
|
||||
|
||||
expect(publish).toContain("ManageStickyFooter");
|
||||
expect(publish).not.toContain("useModalsStack");
|
||||
expect(publish).not.toMatch(/<Modal\b/u);
|
||||
});
|
||||
|
||||
it("imports Custom CSS from a single modal", () => {
|
||||
const css = read(cssButton);
|
||||
|
||||
expect(css).toContain("WorkshopSubmissionGrid");
|
||||
expect(css).toContain("useConfirmModal");
|
||||
expect(css).not.toContain("useModalsStack");
|
||||
expect(css).not.toContain("Modal.Stack");
|
||||
});
|
||||
|
||||
it("cannot import the same widget twice and returns to the installed list", () => {
|
||||
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const detail = read(detailPage);
|
||||
|
||||
// The mutation creates a new record per call, so a second click must be impossible.
|
||||
expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;");
|
||||
expect(importer).toContain("setSucceeded(true)");
|
||||
expect(detail).toContain("router.push(installedHref)");
|
||||
expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}");
|
||||
// The heavy edit form is no longer the post-install destination.
|
||||
expect(detail).not.toContain("/manage/custom-widgets/edit/");
|
||||
});
|
||||
|
||||
it("keeps typed credentials across a background refetch of the submission", () => {
|
||||
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const detail = read(detailPage);
|
||||
|
||||
// Voting invalidates the detail query. If the setup reset keyed off the widget
|
||||
// object identity, the refetch would silently clear part-typed API keys.
|
||||
expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})");
|
||||
expect(importer).toContain("[sourcesKey]");
|
||||
expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])");
|
||||
expect(detail).toContain("const content = detail.data?.content;");
|
||||
expect(detail).toContain("[content]");
|
||||
});
|
||||
|
||||
it("keeps the sign-in hint reachable when voting is unavailable", () => {
|
||||
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
|
||||
|
||||
// Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper.
|
||||
expect(vote).toContain('canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>');
|
||||
});
|
||||
|
||||
it("cannot publish the same widget twice", () => {
|
||||
const publish = read(publishPage);
|
||||
|
||||
expect(publish).toContain("if (createSubmission.isPending) return;");
|
||||
expect(publish).toContain("createSubmission.isPending ||");
|
||||
});
|
||||
|
||||
it("no longer ships the stacked-modal install components", () => {
|
||||
for (const removed of [
|
||||
"apps/nextjs/src/components/workshop/workshop-browser.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-install-button.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-installer.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-publish-modal.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-submission-list.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-submission-item.tsx",
|
||||
]) {
|
||||
expect(() => read(removed)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ pnpm --filter @homarr/docs typecheck
|
||||
```
|
||||
|
||||
The integration suite uses a disposable Compose project. `pnpm test:workshop-image` additionally builds and smoke-tests
|
||||
the combined production image.
|
||||
the combined production image. Both commands are manual and do not run in CI. Workshop CI validates Compose configuration only when its workflow or Docker build inputs change, then publishes images on release branches. The main Fast gate owns workspace typechecking.
|
||||
|
||||
## Data safety
|
||||
|
||||
|
||||
@@ -1,48 +1,7 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
const read = (path) => readFile(resolve(path), "utf8");
|
||||
|
||||
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
|
||||
const clientExport = packageJson.exports?.["./client"];
|
||||
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
|
||||
await access(resolve("packages/workshop", clientExport));
|
||||
|
||||
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
|
||||
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
|
||||
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
|
||||
}
|
||||
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
|
||||
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
|
||||
}
|
||||
if (!hook.includes('username: "name"') || hook.includes("onRecordAuthWithOAuth2Request")) {
|
||||
throw new Error("Workshop identity must use PocketBase's direct GitHub username mapping");
|
||||
}
|
||||
if (hook.includes("validateAndNormalizeSubmission")) {
|
||||
throw new Error("PocketBase must store Workshop submissions without interpreting their content");
|
||||
}
|
||||
for (const artifact of [
|
||||
"apps/workshop/pb_hooks/widget-validator.js",
|
||||
"apps/workshop/pb_hooks/widget-validator.bundle.cjs",
|
||||
"packages/workshop/src/pocketbase-validator.ts",
|
||||
"scripts/build-workshop-validator.mjs",
|
||||
]) {
|
||||
await access(resolve(artifact))
|
||||
.then(() => {
|
||||
throw new Error(`Workshop validator artifact must stay removed: ${artifact}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
|
||||
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
|
||||
}
|
||||
if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) {
|
||||
throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots");
|
||||
}
|
||||
const read = (path) => readFile(path, "utf8");
|
||||
|
||||
const hookUtilsModule = { exports: {} };
|
||||
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
|
||||
@@ -69,73 +28,4 @@ for (const expected of [
|
||||
}
|
||||
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
|
||||
|
||||
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
|
||||
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
|
||||
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
|
||||
}
|
||||
|
||||
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
|
||||
for (const required of ['name: "assistant_quotas"', 'name: "assistant_global_quota"', "idx_assistant_quotas_user"]) {
|
||||
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
|
||||
}
|
||||
for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) {
|
||||
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
|
||||
}
|
||||
|
||||
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
|
||||
for (const required of [
|
||||
'removeIndex("idx_assistant_quotas_user")',
|
||||
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
|
||||
]) {
|
||||
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
|
||||
}
|
||||
|
||||
const providerBackend = await read("apps/workshop/homarr_provider.go");
|
||||
for (const required of [
|
||||
'"homarr/model"',
|
||||
'apis.RequireAuth("users")',
|
||||
"DefaultActivityLoggerMiddlewareId",
|
||||
'os.Getenv("OPENROUTER_API_KEY")',
|
||||
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
|
||||
'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")',
|
||||
'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")',
|
||||
'"data_collection": "deny"',
|
||||
'"zdr": true',
|
||||
]) {
|
||||
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
|
||||
}
|
||||
if (/logger\(\)|\.Logger\(|log\.Print/u.test(providerBackend)) {
|
||||
throw new Error("The Homarr provider must not log request content or user tokens");
|
||||
}
|
||||
for (const required of [
|
||||
"workshop_migration_state",
|
||||
"addedUserFields",
|
||||
"cloneRule(users.listRule)",
|
||||
"CREATE TRIGGER submissions_revision_cas",
|
||||
"NEW.expectedRevision != OLD.revision",
|
||||
"users.passwordAuth = state.users.passwordAuth",
|
||||
"users.oauth2 = state.users.oauth2",
|
||||
"app.delete(users)",
|
||||
]) {
|
||||
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
|
||||
}
|
||||
|
||||
const workflow = await read(".github/workflows/workshop.yml");
|
||||
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
|
||||
|
||||
const entrypoint = await read("apps/workshop/entrypoint.sh");
|
||||
if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) {
|
||||
throw new Error("Workshop must publish its API URL when the container starts");
|
||||
}
|
||||
|
||||
const docsConfig = await read("apps/docs/docusaurus.config.ts");
|
||||
if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) {
|
||||
throw new Error("Workshop must load the container runtime configuration before the documentation bundle");
|
||||
}
|
||||
if (!docsConfig.includes('path: "/workshop/:id"') || !docsConfig.includes("priority: -1")) {
|
||||
throw new Error("Workshop submission routes must not capture the moderation page");
|
||||
}
|
||||
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
|
||||
await access(resolve("apps/docs/src/pages/workshop/admin/index.tsx"));
|
||||
|
||||
console.log("Workshop static contracts passed");
|
||||
console.log("Workshop social metadata rendering passed");
|
||||
@@ -16,8 +16,7 @@ const adminCredentials = {
|
||||
password: "Comp(exP4sswOrd",
|
||||
};
|
||||
|
||||
// This is an opt-in documentation asset generator, not part of the normal E2E gate.
|
||||
// Run it explicitly with UPDATE_DOCS_SCREENSHOTS=true when the documented UI changes.
|
||||
// Run this opt-in asset generator with pnpm test:docs-screenshots when the documented UI changes.
|
||||
describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant documentation screenshots", () => {
|
||||
test("captures the management page and conversation panel", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import type { Browser } from "@playwright/test";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import type { MockApiServer } from "./shared/mock-api-server";
|
||||
import { startMockApiServerAsync } from "./shared/mock-api-server";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
const initialTemplate =
|
||||
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
|
||||
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
|
||||
|
||||
describe("Custom JSX v2 workbench", () => {
|
||||
test.skip("creates, previews, edits, and immediately adds a widget to a board", async () => {
|
||||
let mockApi: MockApiServer | undefined;
|
||||
let homarrContainer: StartedTestContainer | undefined;
|
||||
let browser: Browser | undefined;
|
||||
|
||||
try {
|
||||
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await seedAdminUserAsync(db, adminCredentials);
|
||||
homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
|
||||
waitUntil: "commit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.locator("button[type='submit']").click();
|
||||
await signedIn;
|
||||
|
||||
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
|
||||
await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2");
|
||||
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
|
||||
await page.getByRole("combobox", { name: "Network access" }).click();
|
||||
await page.getByRole("option", { name: "private" }).click();
|
||||
await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status");
|
||||
await page.getByRole("button", { name: "Add option" }).click();
|
||||
const option = page.getByRole("group", { name: "Option 1" });
|
||||
await option.getByRole("textbox", { name: "Option name" }).fill("label");
|
||||
await option.getByRole("textbox", { name: "Default" }).fill("E2E");
|
||||
const request = page.getByRole("group", { name: "status" });
|
||||
await request.getByRole("button", { name: "Query, body and behavior" }).click();
|
||||
await request.getByRole("button", { name: "Add query value" }).click();
|
||||
await request.getByRole("textbox", { name: "Query key" }).fill("label");
|
||||
await request.getByRole("combobox", { name: "Value source" }).click();
|
||||
await page.getByRole("option", { name: "Widget option" }).click();
|
||||
await request.getByRole("combobox", { name: "Option" }).first().click();
|
||||
await page.getByRole("option", { name: "label" }).click();
|
||||
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
|
||||
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
|
||||
await page.locator("#jsx-editor").fill(initialTemplate);
|
||||
|
||||
await page.getByRole("button", { name: "Test and preview" }).last().click();
|
||||
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const editPage = page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000, waitUntil: "commit" });
|
||||
await page.getByRole("button", { name: "Create", exact: true }).last().click();
|
||||
await editPage;
|
||||
|
||||
await page.locator("#jsx-editor").fill(updatedTemplate);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).last().click();
|
||||
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.goto(baseUrl);
|
||||
const tourOverlay = page.locator('[data-onboarding-tour-overlay="true"]');
|
||||
const skipTour = page.getByRole("button", { name: "Skip tour", exact: true });
|
||||
try {
|
||||
await skipTour.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await skipTour.click();
|
||||
await expect(tourOverlay).toHaveCount(0);
|
||||
} catch (error) {
|
||||
if ((await tourOverlay.count()) > 0) throw error;
|
||||
}
|
||||
const editToggle = page.getByTestId("board-edit-mode-toggle");
|
||||
await editToggle.click();
|
||||
await expect(editToggle).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Add board content", exact: true }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Create" })
|
||||
.getByRole("button", { name: /^Add widget / })
|
||||
.click();
|
||||
const picker = page.getByRole("dialog").last();
|
||||
await picker.getByRole("button", { name: /^E2E Custom JSX v2,/ }).click();
|
||||
const addDialog = page.getByRole("dialog").last();
|
||||
await expect(addDialog.getByText("E2E Custom JSX v2")).toBeVisible();
|
||||
await addDialog.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
|
||||
await editToggle.click();
|
||||
await expect(editToggle).toHaveAttribute("aria-pressed", "false", { timeout: 15_000 });
|
||||
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
|
||||
describe("Home", () => {
|
||||
test("should open with status code 200", async () => {
|
||||
// Arrange
|
||||
const homarrContainer = await createHomarrContainer().start();
|
||||
|
||||
try {
|
||||
// Act
|
||||
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
|
||||
|
||||
// Assert
|
||||
expect(homeResponse.status).toBe(200);
|
||||
} finally {
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
+1
-100
@@ -1,11 +1,9 @@
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import AdmZip from "adm-zip";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
@@ -30,48 +28,6 @@ const cleanupSqliteDbAsync = async ({ db, localMountPath }: SqliteTestDatabase)
|
||||
await rm(localMountPath, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
const createRestoreArchiveAsync = async () => {
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
try {
|
||||
await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
|
||||
await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: "restored-base",
|
||||
boardId: "restored-board",
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values({
|
||||
id: "restored-root",
|
||||
boardId: "restored-board",
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
db.$client.close();
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
|
||||
zip.addFile(
|
||||
"metadata.json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
homarrVersion: "e2e",
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: "0".repeat(64),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return zip.toBuffer();
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
};
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test.each([
|
||||
{ name: "mobile", width: 320, height: 740 },
|
||||
@@ -180,61 +136,6 @@ describe("Onboarding", () => {
|
||||
90_000,
|
||||
);
|
||||
|
||||
test.skip("SQLite onboarding restores a validated backup and survives the restart", async () => {
|
||||
const restoreArchive = await createRestoreArchiveAsync();
|
||||
const invalidArchive = new AdmZip();
|
||||
invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database"));
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { localMountPath } = sqlite;
|
||||
const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
let initialContainerStopped = false;
|
||||
let restoredContainer: StartedTestContainer | undefined;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${initialBaseUrl}/init`);
|
||||
await page.getByRole("button", { name: "Restore backup" }).click();
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: "missing-metadata.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: invalidArchive.toBuffer(),
|
||||
});
|
||||
await page.getByText("Invalid backup: missing metadata.json").waitFor();
|
||||
await page.getByRole("button", { name: "Change file" }).click();
|
||||
|
||||
await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive });
|
||||
await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 });
|
||||
expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true);
|
||||
await page.getByRole("button", { name: "Continue to restore" }).click();
|
||||
await page.getByPlaceholder("I understand").fill("I understand");
|
||||
const restoreResponse = page.waitForResponse(
|
||||
(response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Restore Database" }).click();
|
||||
expect((await restoreResponse).status()).toBe(200);
|
||||
|
||||
await initialContainer.stop();
|
||||
initialContainerStopped = true;
|
||||
restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${restoredBaseUrl}/boards/restored`);
|
||||
await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 });
|
||||
expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/);
|
||||
} finally {
|
||||
try {
|
||||
await browser.close();
|
||||
if (!initialContainerStopped) await initialContainer.stop();
|
||||
if (restoredContainer) await restoredContainer.stop();
|
||||
} finally {
|
||||
await cleanupSqliteDbAsync(sqlite);
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
|
||||
const sqlite = await createSqliteDbFileAsync();
|
||||
const { db, localMountPath } = sqlite;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { getContainerRuntimeClient, TestContainers } from "testcontainers";
|
||||
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
|
||||
|
||||
export interface MockApiServer {
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startMockApiServerAsync(responseBody: unknown): Promise<MockApiServer> {
|
||||
const body = JSON.stringify(responseBody);
|
||||
const server: Server = await new Promise((resolve) => {
|
||||
const candidate = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(body);
|
||||
});
|
||||
candidate.listen(0, "0.0.0.0", () => resolve(candidate));
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("Failed to start mock API server");
|
||||
}
|
||||
|
||||
try {
|
||||
await TestContainers.exposeHostPorts(address.port);
|
||||
const client = await getContainerRuntimeClient();
|
||||
const forwarder = (await client.container.list()).find(
|
||||
(container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
|
||||
);
|
||||
const forwarderAddress = forwarder
|
||||
? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
|
||||
: undefined;
|
||||
if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
|
||||
|
||||
return {
|
||||
url: `http://${forwarderAddress}:${address.port}`,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
server.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -9,7 +9,7 @@ http {
|
||||
|
||||
# Route websockets traffic to port 3001
|
||||
location /websockets {
|
||||
proxy_pass http://${HOSTNAME}:3001;
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
@@ -18,7 +18,7 @@ http {
|
||||
|
||||
# Route all other traffic to port 3000
|
||||
location / {
|
||||
proxy_pass http://${HOSTNAME}:3000;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
+6
-3
@@ -49,12 +49,15 @@
|
||||
"scripts:update-openapi": "tsx ./scripts/check-openapi-schema.mts --write",
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
"start": "pnpm -F nextjs start",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\"",
|
||||
"test:coverage": "pnpm test --coverage.enabled",
|
||||
"test:integration": "cross-env NODE_ENV=development CI=true vitest run --project integration --maxWorkers 2",
|
||||
"test:docs-screenshots": "cross-env UPDATE_DOCS_SCREENSHOTS=true NODE_ENV=development CI=true vitest run --project docs-screenshots",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e --maxWorkers 2",
|
||||
"test:workshop": "sh apps/workshop/test.sh",
|
||||
"test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs",
|
||||
"test:workshop-image": "sh apps/workshop/test-image.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\" --ui",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
|
||||
@@ -51,11 +51,13 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/workshop": "workspace:^0.1.0",
|
||||
"@kubernetes/client-node": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@trpc/tanstack-react-query": "catalog:",
|
||||
"ajv": "catalog:",
|
||||
"lodash.clonedeep": "catalog:",
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
|
||||
+116
-62
@@ -1,3 +1,5 @@
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { addFormats, AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
|
||||
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -15,23 +17,35 @@ export interface McpTool {
|
||||
type: "query" | "mutation";
|
||||
pathInRouter: string[];
|
||||
inputSchema: z.core.JSONSchema.JSONSchema;
|
||||
inputValidator: z.ZodObject;
|
||||
inputMode: "none" | "object";
|
||||
}
|
||||
|
||||
const emptyInput = z.object({});
|
||||
export interface McpToolDiagnostic {
|
||||
name: string;
|
||||
path: string;
|
||||
reason: "unsupported_input" | "invalid_schema" | "duplicate_name" | "invalid_name";
|
||||
}
|
||||
|
||||
export interface McpToolCatalog {
|
||||
tools: McpTool[];
|
||||
diagnostics: McpToolDiagnostic[];
|
||||
}
|
||||
|
||||
const emptyInput = z.strictObject({});
|
||||
const schemaEngine = new Ajv2020({
|
||||
strict: false,
|
||||
validateSchema: true,
|
||||
coerceTypes: false,
|
||||
useDefaults: false,
|
||||
removeAdditional: false,
|
||||
});
|
||||
addFormats(schemaEngine);
|
||||
const schemaValidator = new AjvJsonSchemaValidator(schemaEngine);
|
||||
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
|
||||
|
||||
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
|
||||
|
||||
const getObjectInput = (input: z.ZodType) => {
|
||||
const getObjectInput = (input: unknown): z.ZodObject | null => {
|
||||
if (input instanceof z.ZodObject) return input;
|
||||
|
||||
if (input instanceof z.ZodOptional) {
|
||||
const unwrappedInput = input.unwrap();
|
||||
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
|
||||
}
|
||||
|
||||
if (input instanceof z.ZodOptional) return getObjectInput(input.unwrap());
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -50,70 +64,110 @@ const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const getProcedureInput = (inputs: z.ZodType[]) => {
|
||||
if (inputs.length === 0) return emptyInput;
|
||||
if (inputs.length === 1) {
|
||||
const input = inputs[0] ?? emptyInput;
|
||||
return getObjectInput(input) ?? input;
|
||||
}
|
||||
|
||||
const objectInputs = inputs.map(getObjectInput);
|
||||
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
|
||||
const properties = schema.properties ?? {};
|
||||
const required = (schema.required ?? []).filter((key) => {
|
||||
const property = properties[key];
|
||||
return !property || typeof property !== "object" || !("default" in property);
|
||||
});
|
||||
|
||||
return {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
};
|
||||
};
|
||||
|
||||
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
|
||||
router: Router<TRoot, TRecord>,
|
||||
): McpTool[] {
|
||||
): McpToolCatalog {
|
||||
const tools: McpTool[] = [];
|
||||
const diagnostics: McpToolDiagnostic[] = [];
|
||||
const names = new Map<string, string[]>();
|
||||
|
||||
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
|
||||
if (!isProcedure(procedure)) continue;
|
||||
const definition = procedure["_def"];
|
||||
if (definition.type === "subscription") continue;
|
||||
|
||||
const metadata = getMcpMetadata(definition.meta);
|
||||
if (!metadata?.enabled) continue;
|
||||
|
||||
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
|
||||
const procedureInput = getProcedureInput(inputs);
|
||||
|
||||
if (
|
||||
procedureInput === null ||
|
||||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
|
||||
) {
|
||||
console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
|
||||
const name = metadata.name ?? path.replaceAll(".", "_");
|
||||
const paths = names.get(name) ?? [];
|
||||
paths.push(path);
|
||||
names.set(name, paths);
|
||||
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) {
|
||||
diagnostics.push({ name, path, reason: "invalid_name" });
|
||||
continue;
|
||||
}
|
||||
const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
|
||||
|
||||
tools.push({
|
||||
name: metadata.name ?? path.replaceAll(".", "_"),
|
||||
description: metadata.description ?? "",
|
||||
type: definition.type,
|
||||
pathInRouter: path.split("."),
|
||||
inputValidator,
|
||||
inputSchema: normalizeJsonSchema(
|
||||
z.toJSONSchema(inputValidator, {
|
||||
unrepresentable: "any",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const inputs = definition.inputs;
|
||||
let inputMode: McpTool["inputMode"] = "object";
|
||||
if (inputs.every((input) => input instanceof z.ZodUndefined || input instanceof z.ZodVoid)) inputMode = "none";
|
||||
const objectInputs = inputs.map(getObjectInput);
|
||||
if (inputMode === "object" && !objectInputs.every((input) => input !== null)) {
|
||||
diagnostics.push({ name, path, reason: "unsupported_input" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
let input: z.ZodType = emptyInput;
|
||||
if (inputMode === "object") {
|
||||
const objects = objectInputs.filter((schema): schema is z.ZodObject => schema !== null);
|
||||
input = objects[0] ?? emptyInput;
|
||||
for (const object of objects.slice(1)) input = z.intersection(input, object);
|
||||
}
|
||||
const inputSchema = z.toJSONSchema(input, {
|
||||
io: "input",
|
||||
override: ({ zodSchema, jsonSchema }) => {
|
||||
if (zodSchema["_zod"].def.type !== "string") return;
|
||||
if (zodSchema["_zod"].def.checks?.some((check) => check["_zod"].def.check === "overwrite")) {
|
||||
// trim/case normalization runs before these checks in tRPC. They cannot constrain raw JSON.
|
||||
delete jsonSchema.minLength;
|
||||
delete jsonSchema.maxLength;
|
||||
delete jsonSchema.pattern;
|
||||
delete jsonSchema.allOf;
|
||||
delete jsonSchema.format;
|
||||
}
|
||||
// Zod's ISO regex permits minute precision; JSON Schema date-time requires seconds.
|
||||
if (jsonSchema.format === "date-time" && typeof jsonSchema.pattern === "string") {
|
||||
delete jsonSchema.format;
|
||||
}
|
||||
},
|
||||
});
|
||||
// Chained tRPC object inputs produce allOf; MCP requires an explicit object root.
|
||||
if (inputSchema.type !== undefined && inputSchema.type !== "object") throw new Error("Invalid root");
|
||||
inputSchema.type = "object";
|
||||
// Zod and the SDK disagree on JSON Schema vocabulary types; compilation checks the actual schema.
|
||||
schemaValidator.getValidator(inputSchema as Parameters<typeof schemaValidator.getValidator>[0]);
|
||||
tools.push({
|
||||
name,
|
||||
description: metadata.description ?? "",
|
||||
type: definition.type,
|
||||
pathInRouter: path.split("."),
|
||||
inputMode,
|
||||
inputSchema,
|
||||
});
|
||||
} catch {
|
||||
// Conversion errors can contain schema metadata. Only expose a stable, safe reason.
|
||||
diagnostics.push({ name, path, reason: "invalid_schema" });
|
||||
}
|
||||
}
|
||||
|
||||
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
const duplicates = new Set<string>();
|
||||
for (const [name, paths] of names) {
|
||||
if (paths.length < 2) continue;
|
||||
duplicates.add(name);
|
||||
for (const path of paths) diagnostics.push({ name, path, reason: "duplicate_name" });
|
||||
}
|
||||
return {
|
||||
tools: tools
|
||||
.filter((tool) => !duplicates.has(tool.name))
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name)),
|
||||
diagnostics: diagnostics.toSorted(
|
||||
(left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dispatch raw JSON through the caller so tRPC owns parsing, transforms, and authorization. */
|
||||
export async function callMcpTool(caller: unknown, tool: McpTool, input: unknown) {
|
||||
let procedure = caller;
|
||||
for (const segment of tool.pathInRouter) {
|
||||
if ((typeof procedure !== "object" && typeof procedure !== "function") || procedure === null) {
|
||||
throw new Error("Tool is not callable");
|
||||
}
|
||||
procedure = (procedure as Record<string, unknown>)[segment];
|
||||
}
|
||||
if (typeof procedure !== "function") throw new Error("Tool is not callable");
|
||||
let procedureInput = input;
|
||||
if (tool.inputMode === "none") procedureInput = undefined;
|
||||
else if (input === undefined) procedureInput = {};
|
||||
return await (procedure as (value: unknown) => Promise<unknown>)(procedureInput);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export { createTRPCContext } from "./trpc";
|
||||
export { extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export { callMcpTool, extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export type { McpTool } from "./mcp-tools";
|
||||
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
||||
import { CustomWidgetDomainError, executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
|
||||
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { protectedProcedure } from "../../trpc";
|
||||
|
||||
export const integrationRequestSchema = z.object({
|
||||
integrationId: z.string().min(1).max(128),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
path: z.string().min(1).max(8192),
|
||||
body: z.json().optional(),
|
||||
confirmed: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Required for DELETE after the user confirms the deletion and its effects"),
|
||||
});
|
||||
|
||||
export function resolveIntegrationRequestUrl(baseUrl: string, path: string): URL {
|
||||
// Reject parser normalization tricks as well as ordinary absolute URLs.
|
||||
// eslint-disable-next-line no-control-regex -- Reject URL parser control-character normalization.
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || /[\\\s\u0000-\u001f\u007f]/u.test(path)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "path must be a relative URL on the integration origin" });
|
||||
}
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
const target = new URL(path, base);
|
||||
if (
|
||||
!["http:", "https:"].includes(base.protocol) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
target.origin !== base.origin ||
|
||||
target.username ||
|
||||
target.password ||
|
||||
target.hash
|
||||
)
|
||||
throw new Error();
|
||||
return target;
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "path must resolve to the integration origin without credentials or a fragment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Match the existing adapters; never guess authentication from secret names alone.
|
||||
const requestAuth = {
|
||||
sonarr: "apiKeyHeader",
|
||||
radarr: "apiKeyHeader",
|
||||
lidarr: "apiKeyHeader",
|
||||
readarr: "apiKeyHeader",
|
||||
prowlarr: "apiKeyHeader",
|
||||
overseerr: "apiKeyHeader",
|
||||
jellyseerr: "apiKeyHeader",
|
||||
seerr: "apiKeyHeader",
|
||||
immich: "apiKeyHeader",
|
||||
slskd: "apiKeyHeader",
|
||||
homeAssistant: "bearer",
|
||||
coolify: "bearer",
|
||||
audiobookshelf: "bearer",
|
||||
speedtestTracker: "bearer",
|
||||
adGuardHome: "basic",
|
||||
nextcloud: "basic",
|
||||
} satisfies Partial<Record<IntegrationKind, string>>;
|
||||
|
||||
export function resolveIntegrationRequestAuth(
|
||||
kind: IntegrationKind,
|
||||
secrets: CustomWidgetAuthConfig["secrets"],
|
||||
): CustomWidgetAuthConfig {
|
||||
if (!(kind in requestAuth)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This integration's authentication is not supported by integration_request",
|
||||
});
|
||||
}
|
||||
const type = requestAuth[kind as keyof typeof requestAuth];
|
||||
let required = ["apiKey"];
|
||||
if (type === "basic") required = ["username", "password"];
|
||||
if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration credentials are incomplete" });
|
||||
}
|
||||
return { type, secrets };
|
||||
}
|
||||
|
||||
export function redactIntegrationResponse(data: unknown, secrets: CustomWidgetAuthConfig["secrets"]): unknown {
|
||||
const values = secrets.map(({ value }) => value).filter(Boolean);
|
||||
const username = secrets.find(({ kind }) => kind === "username")?.value;
|
||||
const password = secrets.find(({ kind }) => kind === "password")?.value;
|
||||
if (username && password) values.push(`${username}:${password}`);
|
||||
const sensitive = [
|
||||
...new Set(
|
||||
values.flatMap((value) => [
|
||||
value,
|
||||
JSON.stringify(value).slice(1, -1),
|
||||
encodeURIComponent(value),
|
||||
Buffer.from(value).toString("base64"),
|
||||
]),
|
||||
),
|
||||
].toSorted((a, b) => b.length - a.length);
|
||||
const redact = (value: string) => {
|
||||
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
|
||||
return value;
|
||||
};
|
||||
const visit = (value: unknown): unknown => {
|
||||
if (typeof value === "string") return redact(value);
|
||||
if (
|
||||
(typeof value === "number" || typeof value === "boolean" || value === null) &&
|
||||
redact(String(value)) !== String(value)
|
||||
)
|
||||
return "[REDACTED]";
|
||||
if (Array.isArray(value)) return value.map(visit);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return visit(data);
|
||||
}
|
||||
|
||||
export const integrationRequestProcedure = protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Make an authenticated HTTP request to a configured integration using its stored credentials. Get integrationId from integration_all. Every method requires hasFullAccess because arbitrary endpoints can expose service credentials or mutate state. DELETE requires confirmed=true after user confirmation, including effects such as deleting files. Returns {status,data}, including upstream error statuses. Use the service's API documentation to choose the method, relative path and optional JSON body; consult official docs with your browsing tools, or ask the user for API docs if unavailable. Absolute URLs and redirects are rejected. 15 second deadline, 1 MiB response, 10 KiB JSON body. Supports Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud; other authentication schemes are rejected. Never suggest bypassing a permission denial.",
|
||||
},
|
||||
})
|
||||
.input(integrationRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, ctx.session.user.id) });
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.integrationId),
|
||||
with: {
|
||||
secrets: true,
|
||||
userPermissions: { where: eq(integrationUserPermissions.userId, ctx.session.user.id) },
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groups.map(({ groupId }) => groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Arbitrary GET endpoints can expose service credentials or mutate state.
|
||||
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Missing integration permission: hasFullAccess" });
|
||||
}
|
||||
const targetUrl = resolveIntegrationRequestUrl(integration.url, input.path);
|
||||
if (input.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
|
||||
}
|
||||
if (input.method === "GET" && input.body !== undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "GET requests cannot include a body" });
|
||||
}
|
||||
// Decrypt only after authorization, using the same snapshot as the URL and permissions.
|
||||
// Do not log upstream data or retain error causes.
|
||||
try {
|
||||
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
|
||||
const auth = resolveIntegrationRequestAuth(integration.kind, secrets);
|
||||
let tls: CustomWidgetHttpRequest["tls"];
|
||||
if (targetUrl.protocol === "https:") {
|
||||
const [ca, hostnames] = await Promise.all([
|
||||
getAllTrustedCertificatesAsync(),
|
||||
getTrustedCertificateHostnamesAsync(),
|
||||
]);
|
||||
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
|
||||
}
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: integration.url,
|
||||
targetUrl,
|
||||
method: input.method,
|
||||
body: JSON.stringify(input.body),
|
||||
auth,
|
||||
tls,
|
||||
networkScope: "loopback",
|
||||
kind: "action", // Disable redirects for reads and writes alike; never cache this tool.
|
||||
timeoutMs: 15_000,
|
||||
textFallback: true,
|
||||
});
|
||||
return { status: response.status, data: redactIntegrationResponse(response.data, secrets) };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw new TRPCError({ code: error.code, message: error.message });
|
||||
if (error instanceof CustomWidgetDomainError) {
|
||||
throw new TRPCError({
|
||||
code: error.code,
|
||||
message: "Integration request failed (timeout, response limit, redirect or invalid target)",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Integration request failed" });
|
||||
}
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/va
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||
import { integrationRequestProcedure } from "./integration-request";
|
||||
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
@@ -49,6 +50,7 @@ const logger = createLogger({ module: "integrationRouter" });
|
||||
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
request: integrationRequestProcedure,
|
||||
getKinds: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
|
||||
@@ -56,7 +56,7 @@ export const inviteRouter = createTRPCRouter({
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
expirationDate: z.date(),
|
||||
expirationDate: z.iso.datetime({ offset: true }),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ id: z.string(), token: z.string() }))
|
||||
@@ -80,7 +80,7 @@ export const inviteRouter = createTRPCRouter({
|
||||
|
||||
await ctx.db.insert(invites).values({
|
||||
id,
|
||||
expirationDate: input.expirationDate,
|
||||
expirationDate: new Date(input.expirationDate),
|
||||
creatorId: ctx.session.user.id,
|
||||
token,
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("create should create a new invite expiring on the specified date with
|
||||
|
||||
// Act
|
||||
const result = await caller.createInvite({
|
||||
expirationDate,
|
||||
expirationDate: expirationDate.toISOString(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -454,7 +454,7 @@ export const userRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true, provider: true },
|
||||
columns: { name: true, email: true, provider: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
@@ -472,6 +472,13 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (isDemoMode && input.name !== user.name) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Username changes are disabled in demo mode",
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
|
||||
|
||||
const emailDirty = input.email && user.email !== input.email;
|
||||
@@ -497,6 +504,13 @@ export const userRouter = createTRPCRouter({
|
||||
mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (isDemoMode) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User deletion is disabled in demo mode",
|
||||
});
|
||||
}
|
||||
|
||||
// Only admins and user itself can delete a user
|
||||
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
@@ -519,6 +533,13 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (isDemoMode) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Password changes are disabled in demo mode",
|
||||
});
|
||||
}
|
||||
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users' passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
|
||||
@@ -46,4 +46,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
|
||||
llamacpp: lazy(() => import("./llamacpp").then((mod) => mod.llamacppRouter)),
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { llamacppStatsRequestHandler } from "@homarr/request-handler/llamacpp";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const llamacppRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Returns health, loaded model and Prometheus metrics (generation speed, token counts, request queue) from a llama.cpp (llama-server) integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "llamacpp"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
return { stats: mockWidgetData.llamacpp, updatedAt: new Date(mockWidgetData.timestamp) };
|
||||
}
|
||||
const handler = llamacppStatsRequestHandler.handler({ ...ctx.integration, kind: "llamacpp" }, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
stats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
|
||||
return "http://127.0.0.1:3000";
|
||||
}
|
||||
|
||||
export const trpcPath = "/api/trpc";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBqzCCAVCgAwIBAgIUdAqiYlhFGtcr9T6hsvClk9u82qUwCgYIKoZIzj0EAwIw
|
||||
GzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDAgFw0yNjA5MTIxNzUwMTNaGA8y
|
||||
MTI2MDgxOTE3NTAxM1owGzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDBZMBMG
|
||||
ByqGSM49AgEGCCqGSM49AwEHA0IABABTFnPW0ov08mlsG71BBDZBDoNE5kA3KkvO
|
||||
Az0lU6EKjz7E2GFVmamqhVK5hwiaVsq5/mY8FbQ/gFLcz2XvJvmjcDBuMB0GA1Ud
|
||||
DgQWBBSS9fRJbILG0XNpKxBCmkVXdjlLADAfBgNVHSMEGDAWgBSS9fRJbILG0XNp
|
||||
KxBCmkVXdjlLADAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEGludGVncmF0
|
||||
aW9uLnRlc3QwCgYIKoZIzj0EAwIDSQAwRgIhAMpKXcPe+rJdM4pD3ujedAhoL8q3
|
||||
r5KDdLIRfcB6eBDaAiEA1jvGgVqurMY2EsUu2+6Z2gizxDlO8CNcC2a3S/YJCsU=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdtCOWMLD3Jpsd+Ly
|
||||
cVh8NDvA13f3idlev6Da4sCGSD6hRANCAAQAUxZz1tKL9PJpbBu9QQQ2QQ6DROZA
|
||||
NypLzgM9JVOhCo8+xNhhVZmpqoVSuYcImlbKuf5mPBW0P4BS3M9l7yb5
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,300 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { createServer as createHttpsServer } from "node:https";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import type { IntegrationPermission } from "@homarr/definitions";
|
||||
|
||||
import { createTRPCRouter } from "../trpc";
|
||||
import {
|
||||
integrationRequestProcedure,
|
||||
integrationRequestSchema,
|
||||
redactIntegrationResponse,
|
||||
resolveIntegrationRequestAuth,
|
||||
resolveIntegrationRequestUrl,
|
||||
} from "../router/integration/integration-request";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
const trust = vi.hoisted(() => ({
|
||||
certificates: vi.fn(async (): Promise<string[]> => []),
|
||||
hostnames: vi.fn(async (): Promise<{ hostname: string; thumbprint: string }[]> => []),
|
||||
}));
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: trust.certificates,
|
||||
getTrustedCertificateHostnamesAsync: trust.hostnames,
|
||||
}));
|
||||
|
||||
const router = createTRPCRouter({ request: integrationRequestProcedure });
|
||||
const secret = "test-credential-not-for-output";
|
||||
const calls: { method: string | undefined; path: string | undefined; auth: string | undefined; body: string }[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += String(chunk);
|
||||
calls.push({ method: req.method, path: req.url, auth: req.headers["x-api-key"] as string | undefined, body });
|
||||
if (req.url === "/slow") return;
|
||||
if (req.url === "/redirect") {
|
||||
res.writeHead(302, { Location: "/echo" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.url === "/large") {
|
||||
res.end("x".repeat(1024 * 1024 + 1));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/echo") {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ [secret]: [secret, encodeURIComponent(secret), Buffer.from(secret).toString("base64")] }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/text") {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(`invalid JSON ${secret}`);
|
||||
return;
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify([{ id: 42, title: "Example series" }]));
|
||||
});
|
||||
let baseUrl: string;
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function fixture(permission?: IntegrationPermission, setup?: (db: ReturnType<typeof createDb>) => Promise<void>) {
|
||||
const db = createDb();
|
||||
await db.insert(users).values([{ id: "reader" }, { id: "other" }]);
|
||||
await db.insert(integrations).values({ id: "sonarr", name: "Sonarr", kind: "sonarr", url: baseUrl });
|
||||
await db.insert(integrationSecrets).values({ integrationId: "sonarr", kind: "apiKey", value: encryptSecret(secret) });
|
||||
// Another user's full permission must never grant this caller access.
|
||||
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "other", permission: "full" });
|
||||
if (permission)
|
||||
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "reader", permission });
|
||||
const session = {
|
||||
user: { id: "reader", permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
await setup?.(db);
|
||||
return router.createCaller({ db, session, deviceType: undefined });
|
||||
}
|
||||
|
||||
const get = { integrationId: "sonarr", method: "GET", path: "/api/v3/series" } as const;
|
||||
|
||||
describe("integration_request validation", () => {
|
||||
test.each([
|
||||
"https://evil.com/x",
|
||||
"https://example.com/x",
|
||||
"//evil.com/x",
|
||||
"\\\\evil.com/x",
|
||||
"/\\evil.com",
|
||||
" https://evil.com",
|
||||
"https:\nevil.com",
|
||||
"/x#fragment",
|
||||
])("rejects %s", (path) => {
|
||||
expect(() => resolveIntegrationRequestUrl("https://example.com/base/", path)).toThrow();
|
||||
});
|
||||
test.each(["../series", "../../series", "%2e%2e/series"])("normalizes same-origin traversal %s", (path) => {
|
||||
expect(resolveIntegrationRequestUrl("https://example.com/base/", path).href).toBe("https://example.com/series");
|
||||
});
|
||||
test("preserves URL resolution semantics and query parameters", () => {
|
||||
expect(resolveIntegrationRequestUrl("https://example.com/base/", "series?id=42").href).toBe(
|
||||
"https://example.com/base/series?id=42",
|
||||
);
|
||||
expect(resolveIntegrationRequestUrl("https://example.com/base/", "/series").pathname).toBe("/series");
|
||||
});
|
||||
test("accepts only JSON bodies and the specified methods", () => {
|
||||
expect(integrationRequestSchema.safeParse({ ...get, body: new Date() }).success).toBe(false);
|
||||
expect(integrationRequestSchema.safeParse({ ...get, method: "HEAD" }).success).toBe(false);
|
||||
});
|
||||
test("resolves supported auth and rejects unsupported or incomplete credentials", () => {
|
||||
expect(resolveIntegrationRequestAuth("sonarr", [{ kind: "apiKey", value: secret }]).type).toBe("apiKeyHeader");
|
||||
expect(resolveIntegrationRequestAuth("homeAssistant", [{ kind: "apiKey", value: secret }]).type).toBe("bearer");
|
||||
expect(
|
||||
resolveIntegrationRequestAuth("adGuardHome", [
|
||||
{ kind: "username", value: "user" },
|
||||
{ kind: "password", value: secret },
|
||||
]).type,
|
||||
).toBe("basic");
|
||||
expect(() => resolveIntegrationRequestAuth("qBittorrent", [])).toThrow("not supported");
|
||||
expect(() => resolveIntegrationRequestAuth("sonarr", [])).toThrow("incomplete");
|
||||
});
|
||||
test.each([42, true, false, null])("redacts echoed primitive credential %s", (value) => {
|
||||
expect(redactIntegrationResponse(value, [{ kind: "password", value: String(value) }])).toBe("[REDACTED]");
|
||||
});
|
||||
test("redacts JSON-escaped credentials embedded in text", () => {
|
||||
const password = 'quoted"password\\with-newline\n';
|
||||
const encoded = JSON.stringify(password).slice(1, -1);
|
||||
expect(
|
||||
redactIntegrationResponse({ message: `password=${encoded}` }, [{ kind: "password", value: password }]),
|
||||
).toEqual({ message: "password=[REDACTED]" });
|
||||
});
|
||||
test("redacts basic credentials and JSON keys without damaging JSON", () => {
|
||||
const credentials = Buffer.from(`user:${secret}`).toString("base64");
|
||||
expect(
|
||||
redactIntegrationResponse({ [secret]: `Basic ${credentials}` }, [
|
||||
{ kind: "username", value: "user" },
|
||||
{ kind: "password", value: secret },
|
||||
]),
|
||||
).toEqual({ "[REDACTED]": "Basic [REDACTED]" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration_request with a mock integration server", () => {
|
||||
test("GET authenticates with stored credentials and full permission", async () => {
|
||||
const caller = await fixture("full");
|
||||
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: [{ id: 42, title: "Example series" }] });
|
||||
expect(calls.at(-1)).toMatchObject({ method: "GET", path: "/api/v3/series", auth: secret });
|
||||
});
|
||||
test("keeps credentials and URL from the same integration snapshot", async () => {
|
||||
const rotated = "credential-for-the-new-origin";
|
||||
const caller = await fixture("full", async (db) => {
|
||||
const snapshot = await db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, "sonarr"),
|
||||
with: {
|
||||
secrets: true,
|
||||
userPermissions: { where: eq(integrationUserPermissions.userId, "reader") },
|
||||
groupPermissions: true,
|
||||
},
|
||||
});
|
||||
// Simulate a URL/key rotation immediately after the first SELECT completed.
|
||||
await db.update(integrations).set({ url: "https://new-origin.example" }).where(eq(integrations.id, "sonarr"));
|
||||
await db.update(integrationSecrets).set({ value: encryptSecret(rotated) });
|
||||
vi.spyOn(db.query.integrations, "findFirst").mockResolvedValueOnce(snapshot);
|
||||
});
|
||||
await caller.request(get);
|
||||
expect(calls.at(-1)?.auth).toBe(secret);
|
||||
});
|
||||
test("uses Homarr's trusted certificates and hostname exceptions without disabling TLS checks", async () => {
|
||||
// This committed certificate/key pair is only for the loopback mock server.
|
||||
const cert = readFileSync(new URL("./fixtures/integration-request-cert.pem", import.meta.url), "utf8");
|
||||
const key = readFileSync(new URL("./fixtures/integration-request-key.pem", import.meta.url), "utf8");
|
||||
const https = createHttpsServer({ cert, key }, (_req, res) => res.end('{"trusted":true}'));
|
||||
await new Promise<void>((resolve) => https.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const caller = await fixture("full", async (db) => {
|
||||
await db.update(integrations).set({ url: `https://127.0.0.1:${(https.address() as AddressInfo).port}` });
|
||||
});
|
||||
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
trust.certificates.mockResolvedValue([cert]);
|
||||
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
trust.hostnames.mockResolvedValue([
|
||||
{ hostname: "127.0.0.1", thumbprint: new X509Certificate(cert).fingerprint256 },
|
||||
]);
|
||||
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: { trusted: true } });
|
||||
} finally {
|
||||
trust.certificates.mockResolvedValue([]);
|
||||
trust.hostnames.mockResolvedValue([]);
|
||||
https.closeAllConnections();
|
||||
await new Promise<void>((resolve) => https.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
test("denies GET without full permission even when another user has full access", async () => {
|
||||
const caller = await fixture();
|
||||
const before = calls.length;
|
||||
await expect(caller.request(get)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing integration permission: hasFullAccess",
|
||||
});
|
||||
expect(calls).toHaveLength(before);
|
||||
});
|
||||
test.each(["GET", "POST", "PUT", "PATCH", "DELETE"] as const)(
|
||||
"denies %s with only use permission",
|
||||
async (method) => {
|
||||
const caller = await fixture("use");
|
||||
const before = calls.length;
|
||||
await expect(caller.request({ ...get, method, confirmed: true })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing integration permission: hasFullAccess",
|
||||
});
|
||||
expect(calls).toHaveLength(before);
|
||||
},
|
||||
);
|
||||
test("interact access does not authorize arbitrary endpoints", async () => {
|
||||
const caller = await fixture("interact");
|
||||
await expect(caller.request(get)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing integration permission: hasFullAccess",
|
||||
});
|
||||
});
|
||||
test("DELETE requires confirmation and preserves deleteFiles", async () => {
|
||||
const caller = await fixture("full");
|
||||
const input = { ...get, method: "DELETE", path: "/api/v3/series/42?deleteFiles=true" } as const;
|
||||
const before = calls.length;
|
||||
await expect(caller.request(input)).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
|
||||
expect(calls).toHaveLength(before);
|
||||
await expect(caller.request({ ...input, confirmed: true })).resolves.toEqual({ status: 204, data: null });
|
||||
expect(calls.at(-1)).toMatchObject({ method: "DELETE", path: input.path, auth: secret });
|
||||
});
|
||||
test("sends a JSON body with full access", async () => {
|
||||
const caller = await fixture("full");
|
||||
await caller.request({ ...get, method: "PATCH", body: { monitored: false } });
|
||||
expect(calls.at(-1)?.body).toBe('{"monitored":false}');
|
||||
});
|
||||
test.each(["https://evil.com/x", "//evil.com/x"])("rejects %s before HTTP", async (path) => {
|
||||
const caller = await fixture("full");
|
||||
const before = calls.length;
|
||||
await expect(caller.request({ ...get, path })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(calls).toHaveLength(before);
|
||||
});
|
||||
test("never follows a redirect", async () => {
|
||||
const caller = await fixture("full");
|
||||
const before = calls.length;
|
||||
await expect(caller.request({ ...get, path: "/redirect" })).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
expect(calls).toHaveLength(before + 1);
|
||||
});
|
||||
test("enforces the response and request caps", async () => {
|
||||
const caller = await fixture("full");
|
||||
await expect(caller.request({ ...get, path: "/large" })).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" });
|
||||
await expect(caller.request({ ...get, method: "POST", body: "x".repeat(10 * 1024) })).rejects.toMatchObject({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
test("redacts upstream JSON and text, including error responses", async () => {
|
||||
const caller = await fixture("full");
|
||||
await expect(caller.request({ ...get, path: "/echo" })).resolves.toEqual({
|
||||
status: 400,
|
||||
data: { "[REDACTED]": ["[REDACTED]", "[REDACTED]", "[REDACTED]"] },
|
||||
});
|
||||
await expect(caller.request({ ...get, path: "/text" })).resolves.toEqual({
|
||||
status: 500,
|
||||
data: "invalid JSON [REDACTED]",
|
||||
});
|
||||
});
|
||||
test("honors the executor's total deadline without logging credentials", async () => {
|
||||
const logError = vi.fn();
|
||||
await expect(
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl,
|
||||
targetUrl: `${baseUrl}/slow`,
|
||||
method: "GET",
|
||||
networkScope: "loopback",
|
||||
kind: "action",
|
||||
timeoutMs: 30,
|
||||
auth: { type: "apiKeyHeader", secrets: [{ kind: "apiKey", value: secret }] },
|
||||
logError,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_GATEWAY", reason: "timeout" });
|
||||
expect(JSON.stringify(logError.mock.calls)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import type { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { mcpRouter } from "../mcp";
|
||||
import { extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
import type { McpMeta } from "../mcp-tools";
|
||||
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
|
||||
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
@@ -84,6 +87,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"wud_getStats",
|
||||
],
|
||||
mutation: [
|
||||
"integration_request",
|
||||
"app_create",
|
||||
"app_delete",
|
||||
"app_update",
|
||||
@@ -157,7 +161,7 @@ function actualToolInventory() {
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
>;
|
||||
return extractMcpToolsFromProcedures(mcpRouter)
|
||||
.map((tool) => {
|
||||
.tools.map((tool) => {
|
||||
const procedure = procedures[tool.pathInRouter.join(".")];
|
||||
const type = procedure?.["_def"]?.type;
|
||||
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
|
||||
@@ -175,7 +179,7 @@ describe("production MCP router", () => {
|
||||
});
|
||||
|
||||
test("keeps every secret-bearing tool behind a mutation procedure", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||
string,
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
@@ -189,7 +193,7 @@ describe("production MCP router", () => {
|
||||
});
|
||||
|
||||
test("gives every production tool a description", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
|
||||
"Get the current Custom Widget authoring instructions.",
|
||||
@@ -197,16 +201,17 @@ describe("production MCP router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP tools are deterministically ordered and retain executable schemas", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
test("MCP tools are deterministically ordered and have valid production schemas", () => {
|
||||
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
const toolNames = tools.map((tool) => tool.name);
|
||||
|
||||
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
|
||||
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
|
||||
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
|
||||
});
|
||||
|
||||
test("publishes both Custom Widget template input formats", () => {
|
||||
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
|
||||
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
|
||||
(candidate) => candidate.name === "customWidget_validateTemplate",
|
||||
);
|
||||
|
||||
@@ -355,3 +360,119 @@ describe("custom widget authoring procedure access", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP catalog diagnostics", () => {
|
||||
const trpc = initTRPC.meta<McpMeta>().create();
|
||||
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
|
||||
|
||||
test("omits unsupported parsers and malformed schemas without losing valid tools", () => {
|
||||
const router = trpc.router({
|
||||
good: exposed.input(z.object({ name: z.string() })).query(() => "ok"),
|
||||
date: exposed.input(z.object({ date: z.date() })).query(() => "unused"),
|
||||
scalar: exposed.input(z.string()).query(() => "unused"),
|
||||
parser: exposed.input({ parse: (value: unknown) => value }).query(() => "unused"),
|
||||
malformed: exposed
|
||||
.input(z.object({ value: z.string().meta({ type: "invalid-json-type" }) }))
|
||||
.query(() => "unused"),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
|
||||
expect(catalog.diagnostics).toEqual([
|
||||
{ name: "date", path: "date", reason: "invalid_schema" },
|
||||
{ name: "malformed", path: "malformed", reason: "invalid_schema" },
|
||||
{ name: "parser", path: "parser", reason: "unsupported_input" },
|
||||
{ name: "scalar", path: "scalar", reason: "unsupported_input" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits all duplicate names even when one procedure has an invalid schema", () => {
|
||||
const shared = trpc.procedure.meta({ mcp: { enabled: true, name: "shared" } });
|
||||
const catalog = extractMcpToolsFromProcedures(
|
||||
trpc.router({
|
||||
first: shared.query(() => "first"),
|
||||
second: shared.input(z.object({ date: z.date() })).query(() => "second"),
|
||||
good: exposed.query(() => "good"),
|
||||
invalidName: trpc.procedure.meta({ mcp: { enabled: true, name: "bad name" } }).query(() => "unused"),
|
||||
}),
|
||||
);
|
||||
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
|
||||
expect(catalog.diagnostics.filter((diagnostic) => diagnostic.reason === "duplicate_name")).toEqual([
|
||||
{ name: "shared", path: "first", reason: "duplicate_name" },
|
||||
{ name: "shared", path: "second", reason: "duplicate_name" },
|
||||
]);
|
||||
expect(catalog.diagnostics).toContainEqual({ name: "bad name", path: "invalidName", reason: "invalid_name" });
|
||||
});
|
||||
|
||||
test("distinguishes empty object input from no input for shared Assistant dispatch", async () => {
|
||||
const router = trpc.router({
|
||||
none: exposed.query(({ input }) => input),
|
||||
explicitNone: exposed.input(z.undefined()).query(({ input }) => input),
|
||||
object: exposed.input(z.object({ value: z.string().default("default") })).query(({ input }) => input),
|
||||
optional: exposed
|
||||
.input(z.object({ value: z.string().default("default") }).optional())
|
||||
.query(({ input }) => input),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.diagnostics).toEqual([]);
|
||||
for (const tool of catalog.tools) {
|
||||
const result = await callMcpTool(router.createCaller({}), tool, {});
|
||||
if (tool.inputMode === "none") expect(result).toBeUndefined();
|
||||
else expect(result).toEqual({ value: "default" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"production catalog registers through the real protocol handler (modern=%s)",
|
||||
async (modern) => {
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(mcpRouter);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: {},
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Tool failed",
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (modern) {
|
||||
headers["Mcp-Method"] = "tools/list";
|
||||
headers["Mcp-Name"] = "homarr";
|
||||
params["_meta"] = {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": { name: "catalog-test", version: "1" },
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
};
|
||||
}
|
||||
const response = await handler.fetch(
|
||||
new Request("http://homarr.test/api/mcp", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
|
||||
const body = await response.text();
|
||||
let payload = body;
|
||||
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
|
||||
payload =
|
||||
body
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice(6) ?? "";
|
||||
}
|
||||
const result = JSON.parse(payload) as {
|
||||
error?: unknown;
|
||||
result: { tools: { name: string; inputSchema: unknown }[] };
|
||||
};
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.result.tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))).toEqual(
|
||||
tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -14,7 +14,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { Agent } from "undici";
|
||||
|
||||
import type { CustomJsxNetworkScope } from "../core";
|
||||
@@ -191,7 +192,11 @@ export function assertSafeStaticHeaders(headers: Record<string, string> | undefi
|
||||
}
|
||||
}
|
||||
|
||||
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
|
||||
export function createPinnedAgent(
|
||||
addresses: ResolvedAddress[],
|
||||
timeoutMs: number,
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
|
||||
) {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
|
||||
@@ -204,7 +209,7 @@ export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: numbe
|
||||
else callback(null, selected.address, selected.family);
|
||||
};
|
||||
return new Agent({
|
||||
connect: { lookup: customLookup },
|
||||
connect: { ...tls, lookup: customLookup },
|
||||
connectTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { STATUS_CODES } from "node:http";
|
||||
import { Headers, Response } from "undici";
|
||||
|
||||
@@ -52,6 +53,9 @@ export interface CustomWidgetHttpRequest {
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
timeoutMs?: number;
|
||||
textFallback?: boolean;
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
|
||||
@@ -73,7 +77,10 @@ type RequestHopResult =
|
||||
|
||||
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
Math.min(input.timeoutMs ?? MAX_REQUEST_DURATION_MS, MAX_REQUEST_DURATION_MS),
|
||||
);
|
||||
try {
|
||||
return await performRequestWithinDeadline(input, controller.signal);
|
||||
} catch (error) {
|
||||
@@ -113,6 +120,7 @@ async function performRequestWithinDeadline(
|
||||
const dispatcher = createPinnedAgent(
|
||||
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
input.tls,
|
||||
);
|
||||
const headers = buildHeaders(input, currentUrl, currentBody);
|
||||
const controller = new AbortController();
|
||||
@@ -141,7 +149,7 @@ async function performRequestWithinDeadline(
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: await parseResponseBody(response),
|
||||
data: await parseResponseBody(response, input.textFallback),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -45,7 +45,7 @@ async function readLimitedBody(response: Response): Promise<string> {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
export async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
|
||||
const text = await readLimitedBody(response);
|
||||
if (!text) return null;
|
||||
try {
|
||||
@@ -54,7 +54,7 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
return json;
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
if (response.headers.get("content-type")?.toLowerCase().includes("json")) {
|
||||
if (!textFallback && response.headers.get("content-type")?.toLowerCase().includes("json")) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
return text;
|
||||
|
||||
@@ -37,12 +37,6 @@ describe("authoring resources", () => {
|
||||
expect(bundledContent).toContain(content.trim());
|
||||
}
|
||||
expect(bundledContent).toContain(skill.skillMd.trim());
|
||||
expect(bundledContent).toContain("coordinated set of widgets");
|
||||
expect(bundledContent).toContain("customWidget_validateTemplate");
|
||||
expect(bundledContent).toContain("customWidget_findComponents");
|
||||
expect(bundledContent).toContain("Batch selected non-obvious binding or interaction documentation");
|
||||
expect(bundledContent).not.toContain("at most eight named component documents");
|
||||
expect(bundledContent).toContain("Do not use an unlabeled decorative icon as an empty state");
|
||||
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
|
||||
|
||||
const entrypoint = getCustomWidgetSkillEntrypoint();
|
||||
@@ -50,36 +44,7 @@ describe("authoring resources", () => {
|
||||
expect.arrayContaining([expect.objectContaining({ name: "runtime", tool: "customWidget_getReference" })]),
|
||||
);
|
||||
expect(JSON.stringify(entrypoint)).not.toContain("# Runtime");
|
||||
expect(entrypoint.skillMd).toContain("Context routing");
|
||||
expect(entrypoint.skillMd).toContain("customWidget_validateTemplate");
|
||||
expect(entrypoint.skillMd).toContain("customWidget_previewReviseTemplate");
|
||||
expect(entrypoint.skillMd).toContain("map every requested capability");
|
||||
expect(entrypoint.skillMd).toContain("purpose-specific visual signature");
|
||||
expect(entrypoint.skillMd).toContain("asymmetric priority metric/action");
|
||||
expect(entrypoint.skillMd).toContain("new manifest");
|
||||
expect(entrypoint.skillMd).not.toContain("To install an existing widget");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("SubFetch");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain(
|
||||
'require `trigger: "manual"` on request and `SubFetch`',
|
||||
);
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("RefreshButton");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("owns loading/error/retry");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("need literal `requestId`");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain('requestId="search" label="Run again"');
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("Render load queries directly");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("entire JSON response");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("never map the envelope itself");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("immediately hides its prior result");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("Every stateful control must use `bind`");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("resetKey={inputs.search}");
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain('Date.toLocaleString(value, "en-US", "UTC")');
|
||||
expect(getCustomWidgetSkillReference("runtime").content).toContain("Never invent a formatter component");
|
||||
const schemaReference = getCustomWidgetSkillReference("schema").content;
|
||||
expect(schemaReference).toContain('"default": {');
|
||||
expect(schemaReference).toContain('"trigger": "manual"');
|
||||
expect(schemaReference).toContain('"kind": "action"');
|
||||
expect(schemaReference).toContain('"apiKeyHeader"');
|
||||
expect(schemaReference).toContain("is the required source ID");
|
||||
expect(Buffer.byteLength(schemaReference, "utf8")).toBeLessThan(4_000);
|
||||
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(3_000);
|
||||
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(
|
||||
@@ -107,8 +72,6 @@ describe("authoring resources", () => {
|
||||
const serviceDashboard = getCustomWidgetExample("service-dashboard");
|
||||
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
|
||||
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
|
||||
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
|
||||
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
|
||||
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
|
||||
expect(catalog.sharedProps).toMatchObject({
|
||||
count: expect.any(Number),
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
|
||||
|
||||
describe("Workshop navigation translations", () => {
|
||||
test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => {
|
||||
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
|
||||
management?: { navbar?: { items?: { workshop?: unknown } } };
|
||||
navigationStructure?: {
|
||||
manage?: {
|
||||
workshop?: unknown;
|
||||
"custom-widgets"?: {
|
||||
workshop?: { label?: unknown };
|
||||
publish?: { label?: unknown };
|
||||
};
|
||||
};
|
||||
};
|
||||
customWidget?: { page?: { tabs?: Record<string, unknown> } };
|
||||
workshop?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// The Workshop is reached through the Custom Widgets tabs, not its own navbar entry.
|
||||
expect(messages.management?.navbar?.items?.workshop).toBeUndefined();
|
||||
expect(messages.navigationStructure?.manage?.workshop).toBeUndefined();
|
||||
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop");
|
||||
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop");
|
||||
expect(messages.customWidget?.page?.tabs).toMatchObject({
|
||||
installed: "Installed",
|
||||
workshop: "Workshop",
|
||||
ariaLabel: expect.any(String),
|
||||
});
|
||||
|
||||
expect(messages.workshop).toMatchObject({
|
||||
communityMember: "Community member",
|
||||
outdated: "Outdated",
|
||||
install: "Install",
|
||||
back: expect.any(String),
|
||||
listAriaLabel: expect.any(String),
|
||||
installReviewTitle: expect.any(String),
|
||||
emptyFilteredDescription: expect.any(String),
|
||||
importCssConfirm: expect.any(String),
|
||||
reportCount: expect.any(String),
|
||||
reportWarning: expect.any(String),
|
||||
reportVisibility: expect.any(String),
|
||||
screenshotAlt: expect.any(String),
|
||||
upvote: expect.any(String),
|
||||
downvote: expect.any(String),
|
||||
reportCategory: expect.objectContaining({
|
||||
outdated: expect.any(String),
|
||||
malicious: expect.any(String),
|
||||
spam: expect.any(String),
|
||||
copyright: expect.any(String),
|
||||
inappropriate: expect.any(String),
|
||||
other: expect.any(String),
|
||||
}),
|
||||
publish: expect.objectContaining({
|
||||
invalidScreenshot: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("drops the message keys that only existed for the stacked install modals", async () => {
|
||||
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
|
||||
workshop?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) {
|
||||
expect(messages.workshop).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -505,6 +505,7 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "llama-cpp"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
|
||||
@@ -503,6 +503,7 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "llama-cpp"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
|
||||
@@ -488,6 +488,7 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "llama-cpp"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
|
||||
@@ -11,6 +11,7 @@ export const assistantProviderIds = [
|
||||
"together",
|
||||
"ollama",
|
||||
"lm-studio",
|
||||
"llama-cpp",
|
||||
"custom",
|
||||
] as const;
|
||||
|
||||
@@ -156,6 +157,15 @@ export const assistantProviderPresets = {
|
||||
discoveryAuthentication: "bearer",
|
||||
iconUrl: `${providerIconBaseUrl}/lmstudio.svg`,
|
||||
},
|
||||
"llama-cpp": {
|
||||
label: "llama.cpp",
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: false,
|
||||
category: "local",
|
||||
discoveryAuthentication: "bearer",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
|
||||
},
|
||||
custom: {
|
||||
label: undefined,
|
||||
baseUrl: "",
|
||||
|
||||
@@ -333,6 +333,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/traefik"
|
||||
| "/docs/integrations/archiveteam-warrior"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/integrations/llama-cpp"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/air-quality"
|
||||
@@ -390,4 +391,5 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/assistant"
|
||||
| "/docs/widgets/whats-up-docker";
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| "/docs/widgets/llama-cpp";
|
||||
@@ -60,4 +60,5 @@ export const widgetDocSlugs = {
|
||||
customApi: "custom-api",
|
||||
assistant: "assistant",
|
||||
wud: "whats-up-docker",
|
||||
llamacpp: "llama-cpp",
|
||||
} satisfies Record<WidgetKind, string | null>;
|
||||
@@ -545,6 +545,14 @@ export const integrationDefs = {
|
||||
documentationSlug: "whats-up-docker",
|
||||
defaultPort: 3000,
|
||||
},
|
||||
llamacpp: {
|
||||
name: "llama.cpp",
|
||||
secretKinds: [[]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
|
||||
category: ["systemMonitoring"],
|
||||
documentationSlug: "llama-cpp",
|
||||
defaultPort: 8080,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
@@ -713,6 +721,7 @@ export const integrationCategories = [
|
||||
"uptimeMonitoring",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
"systemMonitoring",
|
||||
] as const;
|
||||
|
||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||
@@ -48,6 +48,7 @@ describe("widget integration config", () => {
|
||||
archiveTeamWarrior: 1,
|
||||
anchorNote: 1,
|
||||
wud: 1,
|
||||
llamacpp: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,7 @@ export const widgetIntegrationConfigs = {
|
||||
anchorNote: { supportedIntegrations: ["anchor", "mock"], maxIntegrations: 1 },
|
||||
traefik: { supportedIntegrations: ["traefik", "mock"] },
|
||||
wud: { supportedIntegrations: ["wud", "mock"], maxIntegrations: 1 },
|
||||
llamacpp: { supportedIntegrations: ["llamacpp", "mock"], maxIntegrations: 1 },
|
||||
} satisfies Partial<Record<WidgetKind, WidgetIntegrationConfig>>;
|
||||
|
||||
export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs;
|
||||
|
||||
@@ -58,6 +58,7 @@ export const widgetKinds = [
|
||||
"customApi",
|
||||
"assistant",
|
||||
"wud",
|
||||
"llamacpp",
|
||||
] as const;
|
||||
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -110,6 +110,8 @@ const integrationCreators = {
|
||||
traefik: async (input: IntegrationInput) =>
|
||||
new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
|
||||
wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
|
||||
llamacpp: async (input: IntegrationInput) =>
|
||||
new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input),
|
||||
} satisfies Record<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
|
||||
|
||||
type IntegrationCreators = typeof integrationCreators;
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user