Compare commits

..
Author SHA1 Message Date
ajnart 0f1734d22e docs(mcp): restore custom widget authoring guidance 2026-09-10 19:24:32 +02:00
233 changed files with 24076 additions and 29439 deletions

No files matched your search

+1 -1
View File
@@ -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]/`
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`.
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
+5 -9
View File
@@ -1,17 +1,13 @@
---
name: documentation-sync
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.
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.
---
# Documentation Sync
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.
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.
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
## Map the change
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
@@ -44,11 +40,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.
- Explain only non-obvious option semantics, integration prerequisites, permission constraints, or behavior that the interface does not make clear.
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
## Completion criteria
1. Every passage adds information an advanced user cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
1. Describe the shipped behavior, not an implementation plan.
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.
@@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 {
}
```
`sources.default` is required. HTTP source properties: `name?`, `baseUrl`, `networkScope`, and `auth?`:
The object key `default` is the required source ID; `default` is not a property on a source. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`:
```json
{
@@ -40,12 +40,10 @@ interface HomarrCustomWidgetV2 {
}
```
Integration sources use `{"type":"integration","integrationKind":"sonarr"}`. Bind `integrationId` from `integration_all`; exports omit it. Calls require full integration access. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions.
Auth is `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. A request defaults to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Set `trigger: "manual"` for a parameterized query. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. Do not use `load: false`.
Auth: `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. Requests default to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Parameterized queries need `trigger: "manual"`. Actions default to manual/modify; DELETE requires full permission and confirmation. Do not use `load: false`.
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately.
Paths use `{option:name}` and `{param:name}`; query/body references use `{ "$option": "name" }` and `{ "$param": "name" }`. Constants stay primitive (`take: 10`); `$param` is only for manual helpers, never load queries. Names and types are inferred.
Paths bind `{option:name}` and `{param:name}`; query/body references bind `{ "$option": "name" }` and `{ "$param": "name" }`. Use primitive constants (`take: 10`). `$param` is only for manual requests.
Options require `label`, `control`, and `default`. Optional fields: `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, `group`.
Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`.
+2 -3
View File
@@ -16,10 +16,9 @@ LOG_LEVEL='info'
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
# 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.
# Safe local source-development path. SQLite files are ignored by Git.
# Container images use /appdata/db/db.sqlite by default instead.
DB_URL=
DB_URL='./db.sqlite'
# Those are the two ways to use the mysql2 driver:
# 1. Using the URL format:
-13
View File
@@ -25,18 +25,6 @@ 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:
@@ -45,7 +33,6 @@ 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
+8 -19
View File
@@ -16,10 +16,10 @@ permissions:
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -37,22 +37,11 @@ jobs:
branch: next
- name: Create pull request
id: create-pull-request
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"
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'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -69,7 +58,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review "${{ steps.create-pull-request.outputs.url }}" --approve --body "Automatically approved by GitHub Action"
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
@@ -93,21 +82,21 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && inputs.send-notifications }}
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.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' && inputs.send-notifications }}
if: ${{ steps.semver.outputs.bump == 'major' && github.event.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() && inputs.send-notifications }}
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
+3 -13
View File
@@ -59,13 +59,7 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
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:
When modifying user-facing code, you MUST also update the corresponding documentation:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -92,7 +86,7 @@ When documentation is needed, use these locations:
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- 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.
- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive.
- 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.
@@ -100,15 +94,11 @@ When documentation is needed, use these locations:
- 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` — documentation for changes users need explained
- `documentation-sync` — required user-facing documentation updates
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
-4
View File
@@ -1,7 +1,3 @@
## [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)
+14 -6
View File
@@ -15,13 +15,19 @@ 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 builds on the dashboard you already use. Here is what changed.
Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
![A populated Homarr 2.0 dashboard with compact widgets, an Assistant, and an application rail](./img/homarr-v2-dashboard.webp)
<!-- truncate -->
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.
:::warning Back up before upgrading
Database migrations run automatically when Homarr starts. 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.
:::
## Custom Widgets v2 and Workshop
@@ -144,8 +150,9 @@ 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.
## Compatibility notes
## Before upgrading
- 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.
@@ -153,7 +160,8 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
## Links
- Try the [live demo](https://demo.homarr.dev/).
- See [installation and update instructions](/docs/getting-started/installation/docker).
- Browse [Workshop](https://homarr.dev/workshop).
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
- Follow the [v2 beta setup 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).
- 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://workshop.example.com/workshop
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
@@ -31,13 +31,9 @@ pnpm install --frozen-lockfile
### Run the Homarr application
1. Copy `.env.example` to `.env`.
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.
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.
```sh
cp .env.example .env
@@ -61,20 +57,6 @@ 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).
@@ -98,8 +80,8 @@ 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:local .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:local` — run it.
- `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.
## CI and focused validation
@@ -13,8 +13,7 @@ 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
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.
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
:::
## Without mounted Docker socket
@@ -24,10 +24,6 @@ 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/) 24.18 or newer
- [pnpm](https://pnpm.io/installation) at the version pinned in the repository's `package.json`
- [Node.js](https://nodejs.org/en/download/)
- [pnpm](https://pnpm.io/installation)
- [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 `.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.
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.
</li>
<li>
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
@@ -0,0 +1,88 @@
---
title: Try the v2 beta
sidebar_position: 2
tags:
- Getting started
- Upgrade
- Migration
- Backup
---
This page covers the temporary Homarr v2 public beta. Try the [live demo](https://app-v2.preview.homarr.dev/)
without installing anything, or run the beta image published in the
[public beta announcement](https://github.com/homarr-labs/homarr/issues/6600):
```sh
docker pull ghcr.io/homarr-labs/homarr-test:v2
```
For a fresh beta installation, follow the [Docker installation guide](/docs/getting-started/installation/docker),
use `ghcr.io/homarr-labs/homarr-test:v2` as the image, and use a separate `/appdata` volume.
Set `WORKSHOP_API_URL=https://v2.preview.homarr.dev` to use the preview Workshop.
Homarr runs database migrations automatically at startup; no manual database migration command is needed.
Once v2 is released, existing installations can use their normal update process. The beta image below is temporary.
:::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.
:::
## Try the beta with existing v1 data
1. Change the Homarr image to `ghcr.io/homarr-labs/homarr-test:v2`.
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. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
Keep your other existing Compose settings, including environment variables. For Docker Compose, update the image and preview Workshop URL before the usual update:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr-test:v2
environment:
WORKSHOP_API_URL: https://v2.preview.homarr.dev
```
```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,8 +28,6 @@ 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 />
@@ -1,55 +0,0 @@
---
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: [],
},
]}
/>
@@ -1,9 +0,0 @@
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,8 +30,6 @@ 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,9 +19,6 @@ 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,8 +23,6 @@ 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,14 +24,12 @@ 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 uses `/api/containers` under that URL for both connection testing and widget data.
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.
:::
### Secrets
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.
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.
<IntegrationSecrets
secrets={[
@@ -42,7 +40,7 @@ Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside
{
credentials: ["username", "password"],
steps: [
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
@@ -64,8 +64,3 @@ 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.
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.
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.
@@ -3,28 +3,10 @@
Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets
that combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials,
query string, or fragment, and sources can be `public`, `private`, or `loopback`. Authentication is `none`, `bearer`,
`basic`, an API-key header, or an API-key query parameter. HTTP source credentials belong to the widget but remain outside its manifest.
`basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest.
Source origins are fixed by the definition and source-setup flow; widget options and invocation parameters cannot redirect a
request to a different origin.
## Existing integrations
Sources can reuse any configured HTTP integration, including its saved credentials and login session. WebSocket-only integrations and the internal mock integration are excluded:
```json
{
"sources": { "default": { "type": "integration", "integrationKind": "sonarr" } },
"requests": { "queue": { "path": "/api/v3/queue" } }
}
```
Select the local instance during source setup. Homarr uses its current URL, API key, and trusted certificates;
paths append to the saved URL, including reverse-proxy subpaths. Changing the integration updates all sources bound to it.
Deleting it leaves the widget unconfigured until another instance is selected. The binding is shared by every placement
of that widget definition. Exports and Workshop submissions retain the required type and omit the local integration ID.
Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path. Expired sessions are refreshed; queries can retry once, while actions require another explicit invocation.
## Named requests
Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `permission` have sensible defaults, so a load query can be as small as:
@@ -18,10 +18,6 @@ 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.
![The Users management page](../img/users.png)
## Groups
+1 -1
View File
@@ -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. In view mode, right-click uses the browser's link menu; Homarr's widget menu remains available in edit mode.
check from the tile's edit actions.
### Adding the widget
@@ -22,8 +22,6 @@ 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";
@@ -1,43 +0,0 @@
---
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} />
-37
View File
@@ -1,37 +0,0 @@
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,7 +41,6 @@ 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,6 +51,4 @@ 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} />
@@ -32,7 +32,6 @@ import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { integrationDefs, isHttpIntegrationKind } from "@homarr/definitions";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
@@ -97,15 +96,7 @@ const isNotFound = (caught: unknown) =>
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
type WidgetSource = HomarrCustomWidgetV2["sources"][string];
const sourceHost = (source: WidgetSource) => {
if (source.type === "integration") {
if (isHttpIntegrationKind(source.integrationKind))
return `${integrationDefs[source.integrationKind].name} integration`;
return `${source.integrationKind} integration`;
}
const { baseUrl } = source;
const sourceHost = (baseUrl: string) => {
try {
return new URL(baseUrl).host;
} catch {
@@ -113,30 +104,26 @@ const sourceHost = (source: WidgetSource) => {
}
};
const sourceAuthLabel = (source: WidgetSource) => {
if (source.type === "integration") return "Uses stored integration credentials";
const { auth } = source;
if (auth === "none") return "No credentials";
if (auth === "basic") return "Basic auth";
if (auth === "bearer") return "Bearer token";
if (auth.type === "apiKeyHeader") return `API key header · ${auth.name}`;
return `API key query · ${auth.name}`;
const sourceAuthLabel = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => {
if (typeof auth === "string")
return auth === "none" ? "No credentials" : auth === "basic" ? "Basic auth" : "Bearer token";
return auth.type === "apiKeyHeader" ? `API key header · ${auth.name}` : `API key query · ${auth.name}`;
};
const sourceNeedsSecret = (source: WidgetSource) => source.type === "integration" || source.auth !== "none";
const sourceNeedsSecret = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => auth !== "none";
const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
const sources = Object.entries(widget.sources);
const requests = Object.values(widget.requests);
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const queryCount = requests.filter((request) => request.kind === "query").length;
const actionCount = requests.filter((request) => request.kind === "action").length;
const credentialLabel =
protectedSources.length === 0
? "None required"
: protectedSources.length === 1
? sourceAuthLabel(protectedSources[0][1])
: `${protectedSources.length} authenticated sources`;
? sourceAuthLabel(protectedSources[0][1].auth)
: `${protectedSources.length} credentials required`;
return (
<section
@@ -165,9 +152,9 @@ const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
<dd className="mt-1.5 space-y-1.5">
{sources.slice(0, 2).map(([id, source]) => (
<span key={id} className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate font-mono text-xs font-medium">{sourceHost(source)}</span>
<span className="truncate font-mono text-xs font-medium">{sourceHost(source.baseUrl)}</span>
<Badge variant="secondary" className="shrink-0 font-normal">
{source.networkScope ?? "integration"}
{source.networkScope}
</Badge>
</span>
))}
@@ -549,7 +536,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const displayedContent = formatWorkshopContent(submission.type, submission.content);
const socialTitle = `${submission.title} · Homarr Workshop`;
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
@@ -792,11 +779,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{source.name || id}</span>
<Badge variant="secondary" className="shrink-0">
{source.networkScope ?? "integration"}
{source.networkScope}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">{sourceHost(source)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source)}</p>
<p className="truncate text-xs text-muted-foreground">{sourceHost(source.baseUrl)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source.auth)}</p>
</div>
))}
</div>
+1 -2
View File
@@ -2304,8 +2304,7 @@
"traefik",
"customApi",
"assistant",
"wud",
"llamacpp"
"wud"
]
},
"options": {
@@ -16,9 +16,6 @@ import {
} from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
import { useI18n } from "@homarr/translation/client";
interface RequestDetails {
@@ -27,7 +24,10 @@ interface RequestDetails {
kinds: Array<"apiKey" | "username" | "password">;
expiresAt: number;
status: "pending" | "completed";
source: CustomWidgetSource;
source: {
baseUrl: string;
networkScope: "public" | "private" | "loopback";
};
}
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
@@ -35,7 +35,6 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const tSecret = useI18n("customWidget.secret");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [integrationId, setIntegrationId] = useState<string>();
const [baseUrl, setBaseUrl] = useState("");
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
const [error, setError] = useState<string | null>(null);
@@ -48,9 +47,8 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const body = (await response.json()) as RequestDetails | { error: string };
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
setDetails(body);
setBaseUrl(body.source.baseUrl ?? "");
setNetworkScope(body.source.networkScope ?? "public");
setIntegrationId(body.source.integrationId);
setBaseUrl(body.source.baseUrl);
setNetworkScope(body.source.networkScope);
})
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
.finally(() => setLoading(false));
@@ -60,12 +58,10 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
setSaving(true);
setError(null);
try {
let configuration: unknown = { baseUrl, networkScope, secrets: values };
if (details?.source.type === "integration") configuration = { integrationId, secrets: {} };
const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(configuration),
body: JSON.stringify({ baseUrl, networkScope, secrets: values }),
});
const body = (await response.json()) as { status?: string; error?: string };
if (!response.ok) throw new Error(body.error ?? t("saveError"));
@@ -106,31 +102,20 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
{t("source", { name: details.sourceName })}
</Text>
</Card>
{details.source.type === "integration" && (
<IntegrationSourceSelect
kind={details.source.integrationKind}
integrationId={integrationId}
onChange={setIntegrationId}
/>
)}
{details.source.type !== "integration" && (
<>
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
</>
)}
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
{details.kinds.map((kind) => {
const Input = kind === "username" ? TextInput : PasswordInput;
return (
@@ -147,10 +132,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
})}
<Button
loading={saving}
disabled={
(details.source.type === "integration" ? !integrationId : !URL.canParse(baseUrl)) ||
details.kinds.some((kind) => !values[kind])
}
disabled={!URL.canParse(baseUrl) || details.kinds.some((kind) => !values[kind])}
onClick={() => void submit()}
>
{t("save")}
@@ -4,15 +4,9 @@ import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from
import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type {
CustomWidgetHttpSource,
CustomWidgetSource,
CustomWidgetSourceUrlIssue,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -58,6 +52,9 @@ export function CustomWidgetSourceField({
onRemove,
}: CustomWidgetSourceFieldProps) {
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<Fieldset legend={index === 0 ? t("primary") : source.name}>
@@ -76,41 +73,72 @@ export function CustomWidgetSourceField({
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
/>
</Group>
<Select
label={t("sourceType")}
data={[
{ value: "http", label: t("manualSource") },
{ value: "integration", label: t("existingIntegration") },
]}
value={source.type ?? "http"}
allowDeselect={false}
onChange={(type) => {
if (type === "integration") onUpdate(index, { type, integrationKind: "sonarr" });
else if (type === "http")
onUpdate(index, { type, baseUrl: "https://example.com", networkScope: "public", auth: "none" });
}}
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
{source.type === "integration" && (
<IntegrationSourceSelect
kind={source.integrationKind}
integrationId={source.integrationId}
onChange={(integrationId) => onUpdate(index, { integrationId })}
onKindChange={(integrationKind) => onUpdate(index, { integrationKind, integrationId: undefined })}
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{source.type !== "integration" && (
<CustomWidgetHttpSourceFields
source={source}
index={index}
form={form}
definitionId={definitionId}
clearSecretPending={clearSecretPending}
onUpdate={onUpdate}
onSetAuthentication={onSetAuthentication}
onSetSecret={onSetSecret}
onClearSecret={onClearSecret}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
@@ -126,88 +154,3 @@ export function CustomWidgetSourceField({
</Fieldset>
);
}
function CustomWidgetHttpSourceFields({
source,
index,
form,
definitionId,
clearSecretPending,
onUpdate,
onSetAuthentication,
onSetSecret,
onClearSecret,
}: Omit<CustomWidgetSourceFieldProps, "source" | "onRemove"> & { source: CustomWidgetHttpSource & { id: string } }) {
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) => value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
</>
);
}
@@ -28,12 +28,6 @@ export function CustomWidgetSourcesEditor({
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
const update = (index: number, changes: Partial<CustomWidgetSource> & { id?: string }) => {
if (changes.type) {
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== sources[index]?.id),
);
}
const previousId = sources[index]?.id;
const nextId = changes.id ?? previousId;
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
@@ -48,7 +42,7 @@ export function CustomWidgetSourcesEditor({
Object.fromEntries(
sourceEntries.map(([id, source], i) => [
i === index ? nextId : id,
i === index ? { ...(changes.type ? { name: source.name } : source), ...changes, id: undefined } : source,
i === index ? { ...source, ...changes, id: undefined } : source,
]),
),
null,
@@ -1,6 +1,5 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import {
@@ -23,7 +22,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
@@ -191,7 +190,7 @@ export function WorkshopDetail({ id }: { id: string }) {
</Text>
</Box>
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
<SourceSetupPanel
<CustomWidgetSourceSetupPanel
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
import {
claimCustomWidgetConfigurationRequest,
assertCustomWidgetIntegrationBindings,
completeCustomWidgetConfigurationRequest,
configureCustomWidgetSourceFromRequest,
configurePreviewSessionSource,
@@ -13,7 +12,6 @@ import {
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
import { db } from "@homarr/db";
import { auth } from "@homarr/auth/next";
import { adminRoute } from "../../admin";
import { readConfigurationRequestBody } from "../body";
@@ -54,40 +52,17 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
}
const body = parsedBody.data;
let candidate: unknown;
if (pending.source.type === "integration") {
if (
!body.integrationId ||
body.baseUrl !== undefined ||
body.networkScope !== undefined ||
Object.keys(body.secrets).length > 0
) {
return NextResponse.json({ error: "Select an existing integration." }, { status: 400 });
}
candidate = { ...pending.source, integrationId: body.integrationId };
} else {
if (body.integrationId)
return NextResponse.json({ error: "HTTP sources cannot bind an integration." }, { status: 400 });
candidate = { ...pending.source, baseUrl: body.baseUrl, networkScope: body.networkScope };
}
const sourceResult = customWidgetSourceSchema.safeParse(candidate);
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body.baseUrl,
networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
{ error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." },
{ status: 400 },
);
}
try {
await assertCustomWidgetIntegrationBindings(
{ db, session: await auth() },
{ [pending.sourceId]: sourceResult.data },
);
} catch {
return NextResponse.json(
{ error: "The selected integration is unavailable or has a different type." },
{ status: 400 },
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
@@ -120,7 +95,6 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
const result = await configureCustomWidgetSourceFromRequest(db, {
definitionId: claimed.target.id,
sourceId: claimed.sourceId,
integrationId: sourceResult.data.integrationId,
baseUrl: sourceResult.data.baseUrl,
networkScope: sourceResult.data.networkScope,
secrets,
@@ -1,17 +1,11 @@
import { z } from "zod/v4";
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
const configurationRequestBodySchema = z
.strictObject({
baseUrl: z.string().optional(),
integrationId: z.string().min(1).max(100).optional(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
secrets: z.record(z.string(), z.string()),
})
.refine(
(value) => Boolean(value.integrationId) || (value.baseUrl !== undefined && value.networkScope !== undefined),
"Provide an integration or HTTP connection",
);
const configurationRequestBodySchema = z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]),
secrets: z.record(z.string(), z.string()),
});
export async function readConfigurationRequestBody(
request: Request,
+1 -2
View File
@@ -4,7 +4,6 @@ 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;
@@ -54,7 +53,7 @@ export const createMcpProtocolHandler = ({
content: [
{
type: "text" as const,
text: serializeMcpToolResult(toAssistantToolOutput(result)),
text: JSON.stringify(toAssistantToolOutput(result)),
},
],
};
@@ -1,23 +0,0 @@
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");
});
});
@@ -1,3 +0,0 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
@@ -5,9 +5,10 @@ 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 { useConfirmModal, useModalAction } from "@homarr/modals";
import { 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";
@@ -39,7 +40,6 @@ 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,21 +143,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
onClick={() => {
setIsMenuOpen(false);
openConfirmModal({
title: tItem("remove.message"),
children: label,
labels: { confirm: tItem("action.remove") },
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
onConfirm={() => removeItem({ itemId: item.id })}
>
{tItem("action.remove")}
</Menu.Item>
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -244,13 +244,7 @@ export const WidgetContextMenu = ({
setMenuOpened(false);
}, []);
if (
!session ||
!settings.enableRightClickOnWidgets ||
isEditMode ||
item.kind === "app" ||
item.kind === "bookmarks"
) {
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
@@ -11,7 +11,6 @@ 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";
@@ -26,7 +25,6 @@ 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");
@@ -41,26 +39,8 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
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} />;
const labelLeft = 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
@@ -88,14 +68,24 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
{...toggleLayout}
px={6}
ps={isVisuallyCollapsed ? labelLeft : 6}
pe={isVisuallyCollapsed ? labelRight : 6}
top={0}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
px={12}
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
radius="sm"
variant="default"
justify={options.showLabel ? "flex-start" : "center"}
leftSection={options.showLabel && toggleIcon}
leftSection={
options.showLabel ? (
isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)
) : undefined
}
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
@@ -104,7 +94,13 @@ export const BoardContainerSection = ({ section }: Props) => {
data-board-container-label
title={options.showLabel ? label : undefined}
>
{options.showLabel ? label : toggleIcon}
{options.showLabel ? (
label
) : isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)}
</Button>
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
@@ -133,21 +133,6 @@ 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));
@@ -136,7 +136,6 @@ 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, 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 (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
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"] {
@@ -62,9 +62,9 @@
overflow: visible;
}
/* SectionGrid fits its content to the same inset in both view and edit mode. */
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
.staticItem[data-type="section"] > .contentMount {
inset: var(--board-container-inset, 0px);
inset: 0;
}
.staticItem[data-type="item"] > .contentMount {
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -29,9 +29,6 @@ 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;
@@ -53,7 +50,6 @@ 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);
@@ -147,37 +143,36 @@ export const SectionGrid = ({
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
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 effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
// 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);
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;
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
// nested content uniformly scaled within the remaining height so labels and icons retain their
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
// 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) / contentScale;
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
// 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);
@@ -273,9 +268,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}
@@ -294,17 +289,14 @@ export const SectionGrid = ({
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
"--board-canvas-inverse-scale": 1 / effectiveContentScale,
"--board-canvas-ui-scale": combinedUiScale,
...(containerContentScale < 1 ? { "--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}
>
<GridContentScaleContext.Provider value={contentScale}>
<SectionContent />
</GridContentScaleContext.Provider>
<SectionContent />
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
@@ -312,6 +304,9 @@ 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"]);
@@ -1,10 +1,9 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import type { ModalProps } from "@mantine/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetImport } from "./use-custom-widget-import";
@@ -68,7 +67,7 @@ export function CustomWidgetImportDialog({
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
}}
>
<SourceSetupPanel
<CustomWidgetSourceSetupPanel
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
@@ -1,6 +1,5 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
@@ -9,7 +8,11 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import { createCustomWidgetSourceSetupValues, isCustomWidgetSourceSetupReady } from "@homarr/custom-widgets/workbench";
import {
createCustomWidgetSourceSetupValues,
CustomWidgetSourceSetupPanel,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupMessages, CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -65,14 +68,6 @@ export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }:
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
);
if (setup.integrationKind) {
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
integrationId: value.integrationId,
});
continue;
}
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
@@ -114,7 +109,7 @@ export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }:
</Text>
) : (
<Stack gap="md">
<SourceSetupPanel
<CustomWidgetSourceSetupPanel
setups={setups}
values={values}
messages={messages}
@@ -1,81 +0,0 @@
"use client";
import { Alert, Select, Stack, Text } from "@mantine/core";
import { CustomWidgetSourceSetupPanel } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupPanelProps } from "@homarr/custom-widgets/workbench";
import { clientApi } from "@homarr/api/client";
import type { CustomWidgetIntegrationSource } from "@homarr/custom-widgets/core";
import { integrationDefs, integrationKinds, isHttpIntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
const httpIntegrationKinds = integrationKinds.filter(isHttpIntegrationKind);
interface IntegrationSourceSelectProps {
kind: CustomWidgetIntegrationSource["integrationKind"];
integrationId?: string;
onChange(integrationId: string | undefined): void;
onKindChange?(kind: CustomWidgetIntegrationSource["integrationKind"]): void;
}
export function IntegrationSourceSelect({ kind, integrationId, onChange, onKindChange }: IntegrationSourceSelectProps) {
const t = useI18n("customWidget.workbench.sources");
const query = clientApi.integration.all.useQuery();
const integrations = (query.data ?? []).filter(
(integration) => integration.kind === kind && integration.permissions.hasFullAccess,
);
const unavailable = Boolean(integrationId && query.isSuccess && !integrations.some(({ id }) => id === integrationId));
return (
<Stack gap="sm">
{onKindChange && (
<Select
label={t("integrationType")}
searchable
data={httpIntegrationKinds.map((value) => ({ value, label: integrationDefs[value].name }))}
value={kind}
allowDeselect={false}
onChange={(value) => {
const selected = httpIntegrationKinds.find((candidate) => candidate === value);
if (selected) onKindChange(selected);
}}
/>
)}
<Select
label={t("selectIntegration")}
description={isHttpIntegrationKind(kind) ? integrationDefs[kind].name : kind}
data={integrations.map(({ id, name }) => ({ value: id, label: name }))}
value={integrationId ?? null}
searchable
clearable
disabled={query.isPending}
nothingFoundMessage={t("noIntegrations")}
error={unavailable && t("integrationUnavailable")}
onChange={(value) => onChange(value ?? undefined)}
/>
{query.error && <Alert color="red">{query.error.message}</Alert>}
<Text size="xs" c="dimmed">
{t("integrationDescription")}
</Text>
</Stack>
);
}
export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) {
return (
<CustomWidgetSourceSetupPanel
{...props}
renderIntegrationSource={(setup, value) => {
const kind = httpIntegrationKinds.find((candidate) => candidate === setup.integrationKind);
if (!kind) return null;
return (
<IntegrationSourceSelect
kind={kind}
integrationId={value.integrationId}
onChange={(integrationId) => props.onChange(setup.sourceId, { ...value, integrationId })}
/>
);
}}
/>
);
}
@@ -60,7 +60,7 @@ export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCusto
Object.fromEntries(
Object.entries(values).map(([sourceId, value]) => [
sourceId,
{ baseUrl: value.baseUrl, networkScope: value.networkScope, integrationId: value.integrationId },
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
]),
),
),
@@ -1,5 +1,5 @@
interface PublishableWorkshopDefinition {
sources: Record<string, { name?: string; networkScope?: string }>;
sources: Record<string, { name?: string; networkScope: string }>;
}
export const serializeWorkshopDefinition = (definition: unknown): string => {
@@ -29,5 +29,5 @@ export async function publishWorkshopDefinition({
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
source.networkScope === undefined || source.networkScope === "public" ? [] : [source.name ?? sourceId],
source.networkScope === "public" ? [] : [source.name ?? sourceId],
);
+2 -2
View File
@@ -9,7 +9,7 @@ http {
# Route websockets traffic to port 3001
location /websockets {
proxy_pass http://127.0.0.1:3001;
proxy_pass http://${HOSTNAME}: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://127.0.0.1:3000;
proxy_pass http://${HOSTNAME}: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;
@@ -5,5 +5,3 @@ export {
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
export { assertCustomWidgetIntegrationBindings } from "./router/custom-widget/source-resolver";
@@ -1,4 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -45,7 +44,6 @@ export const creationProcedures = {
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition", { id, name: definition.name });
@@ -117,7 +115,6 @@ export const creationProcedures = {
const secrets = Object.keys(session.sources).flatMap((sourceId) =>
getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })),
);
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition from tested preview", {
@@ -1,5 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -62,7 +60,6 @@ export const customWidgetRouter = createTRPCRouter({
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
return customWidgetDefinitionSchema.parse({ ...current, ...changes });
});
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
const secretRows = secrets?.map((secret) => ({
@@ -109,7 +106,7 @@ export const customWidgetRouter = createTRPCRouter({
}
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
@@ -154,7 +151,7 @@ export const customWidgetRouter = createTRPCRouter({
}
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
transaction
.delete(customWidgetSecrets)
@@ -1,4 +1,3 @@
import { resolveCustomWidgetSource } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -154,19 +153,31 @@ export const managementQueryProcedures = {
});
try {
const values = resolveOptionRequestValues(request, input.params);
const connection = await resolveCustomWidgetSource(ctx, source, request, () =>
stored.secrets
.filter((secret) => secret.sourceId === request.source)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const response = await executeCustomWidgetRequest({
...connection,
targetUrl: renderRequestTarget(connection.baseUrl, request, values),
baseUrl: source.baseUrl,
targetUrl: renderRequestTarget(source.baseUrl, request, values),
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth:
request.auth === "none" || authType === "none"
? undefined
: {
type: authType,
secrets: stored.secrets
.filter((secret) => secret.sourceId === request.source)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
},
networkScope: source.networkScope,
kind: "query",
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}:${connection.cacheVersion}`,
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`,
cacheTtlSeconds: request.cacheSeconds ?? 30,
});
return {
@@ -1,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import type { InferSelectModel } from "drizzle-orm";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -51,12 +50,12 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
sources: Object.entries(widget.sources).map(([id, source]) => ({
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
id,
name: source.name,
origin: source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin,
networkScope: source.networkScope,
authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
name,
origin: new URL(baseUrl).origin,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCount: Object.keys(widget.requests).length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
@@ -111,11 +110,11 @@ export function mapAvailableCustomWidget(definition: StoredDefinition) {
options: widget.options,
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
template: widget.template,
sources: Object.entries(widget.sources).map(([id, source]) => ({
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
id,
name: source.name,
networkScope: source.networkScope,
authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
name,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCapabilities: Object.entries(widget.requests).map(
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
@@ -29,10 +29,10 @@ export const previewActionProcedures = {
if (definition?.kind !== "action")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const request = { id: input.requestId, ...definition };
const resolved = await getPreviewRequestSource(ctx, session, request);
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolveCustomWidgetRequestValues(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.baseUrl, request, params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
@@ -66,12 +66,13 @@ export const previewActionProcedures = {
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
...resolved,
baseUrl: resolved.source.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "action",
});
if (response.ok && request.invalidates?.length) {
@@ -1,5 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -94,7 +92,6 @@ const previewCreateProcedure = permissionRequiredProcedure
const definition = parseCustomWidgetAuthoringInput(() =>
normalizeCustomWidgetAuthoringDefinition(input.definition),
);
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
const optionIssues = validateCustomWidgetOptions(definition.options, options);
if (optionIssues.length > 0) {
@@ -118,7 +115,7 @@ const previewCreateProcedure = permissionRequiredProcedure
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
const authType = getCustomWidgetSourceAuthType(submittedSource);
const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
const missingReplacement = requiredSecretKinds(authType).some(
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
);
@@ -145,7 +142,7 @@ const previewCreateProcedure = permissionRequiredProcedure
const invalid = secrets.find((secret) => {
const source = definition.sources[secret.sourceId];
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
authType === "basic"
? ["username", "password"]
@@ -1,4 +1,3 @@
import { resolveCustomWidgetSource } from "./source-resolver";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
@@ -34,15 +33,25 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
}
};
export async function getPreviewRequestSource(
ctx: Parameters<typeof resolveCustomWidgetSource>[0],
session: CustomWidgetPreviewSession,
request: CustomJsxRequest,
) {
const source = session.sources[request.source];
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
const source = session.sources[sourceId];
if (!source) return null;
return resolveCustomWidgetSource(ctx, source, request, () => getPreviewSessionSecrets(session, request.source));
}
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const auth =
authType === "none"
? undefined
: {
type: authType,
secrets: getPreviewSessionSecrets(session, sourceId),
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
return { source, auth };
};
export function resolvePreviewRequestParams(
request: CustomJsxRequest,
@@ -38,10 +38,10 @@ export const previewQueryProcedures = {
if (definition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const request = { id: input.requestId, ...definition };
const resolved = await getPreviewRequestSource(ctx, session, request);
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolvePreviewRequestParams(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.baseUrl, request, params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
@@ -52,14 +52,15 @@ export const previewQueryProcedures = {
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
...resolved,
baseUrl: resolved.source.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}:${resolved.cacheVersion}`,
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheSeconds,
});
await recordPreviewJournal(session, {
@@ -15,8 +15,7 @@ type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
type ConfigureCustomWidgetSourceInput = {
definitionId: string;
sourceId: string;
baseUrl?: string;
integrationId?: string;
baseUrl: string;
networkScope?: CustomWidgetSource["networkScope"];
secrets: CustomWidgetCreateInput["secrets"];
expectedSource?: CustomWidgetSource;
@@ -221,17 +220,11 @@ function prepareSourceUpdate(
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
throw new SourceConfigurationError("binding-changed");
}
let configuration: unknown;
if (previousSource.type === "integration") {
configuration = { ...previousSource, integrationId: input.integrationId };
} else {
configuration = {
...previousSource,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? previousSource.networkScope,
};
}
const source = customWidgetSourceSchema.parse(configuration);
const source = customWidgetSourceSchema.parse({
...previousSource,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? previousSource.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
return {
source,
@@ -16,7 +16,6 @@ export function requiredSecretKinds(authType: string) {
* origin, while the network scope and auth destination are part of the binding.
*/
export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
if (left.type === "integration" || right.type === "integration") return false;
return (
new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
left.networkScope === right.networkScope &&
@@ -25,12 +24,12 @@ export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidg
}
export function assertSecretSources(
sources: Record<string, { auth?: string | { type: string }; type?: string }>,
sources: Record<string, { auth: string | { type: string } }>,
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
) {
const invalid = secrets.find((secret) => {
const source = sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth?.type;
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
});
if (invalid) {
@@ -1,5 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -60,19 +58,12 @@ export const secretProcedures = {
sourceConfigure: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Configure one custom widget source. For an integration source, provide integrationId from integration_all (matching integrationKind); omit URL and secrets. Requires administrator permission.",
},
})
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
.input(
z.object({
definitionId: z.string(),
sourceId: z.string(),
baseUrl: z.string().optional(),
integrationId: z.string().min(1).max(100).optional(),
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
secrets: customWidgetSecretsInputSchema.default([]),
}),
@@ -85,34 +76,22 @@ export const secretProcedures = {
const definition = parseStoredCustomWidgetDefinition(stored);
const current = definition.sources[input.sourceId];
if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
let configuration: unknown;
if (current.type === "integration") {
if (
!input.integrationId ||
input.baseUrl !== undefined ||
input.networkScope !== undefined ||
input.secrets.length > 0
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Integration sources require only an integrationId" });
}
configuration = { ...current, integrationId: input.integrationId };
} else {
if (input.integrationId)
throw new TRPCError({ code: "BAD_REQUEST", message: "HTTP sources cannot bind an integration" });
configuration = {
...current,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? current.networkScope,
};
}
const source = customWidgetSourceSchema.parse(configuration);
await assertCustomWidgetIntegrationBindings(ctx, { [input.sourceId]: source });
const source = customWidgetSourceSchema.parse({
...current,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? current.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
const configuredSource = await configureCustomWidgetSource(ctx.db, { ...input, expectedSource: current });
const configuredSource = await configureCustomWidgetSource(ctx.db, {
definitionId: input.definitionId,
sourceId: input.sourceId,
baseUrl: source.baseUrl,
networkScope: input.networkScope,
secrets: input.secrets,
});
return {
definitionId: input.definitionId,
sourceId: input.sourceId,
integrationId: configuredSource.integrationId,
baseUrl: configuredSource.baseUrl,
networkScope: configuredSource.networkScope,
configuredSecrets: input.secrets.map(({ kind }) => kind),
@@ -131,7 +110,7 @@ export const secretProcedures = {
}
let widgetName: string;
let source: { id: string; name: string; auth: CustomWidgetSource["auth"]; value: CustomWidgetSource } | undefined;
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -165,7 +144,7 @@ export const secretProcedures = {
target = { type: "preview", id: preview.id };
}
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const request = await createCustomWidgetConfigurationRequest({
userId: ctx.session.user.id,
target,
@@ -1,85 +0,0 @@
import { TRPCError } from "@trpc/server";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import { isHttpIntegrationKind } from "@homarr/definitions";
import type { IntegrationHttpContext } from "../integration/integration-http";
import {
getIntegrationForHttpRequest,
getIntegrationHttpCacheVersion,
getIntegrationHttpConnection,
} from "../integration/integration-http";
export async function assertCustomWidgetIntegrationBindings(
ctx: IntegrationHttpContext,
sources: Record<string, CustomWidgetSource>,
) {
for (const source of Object.values(sources)) {
if (source.type !== "integration" || !source.integrationId) continue;
await resolveIntegration(ctx, source);
}
}
async function resolveIntegration(
ctx: IntegrationHttpContext,
source: Extract<CustomWidgetSource, { type: "integration" }>,
) {
if (!source.integrationId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Select an existing integration for this widget source",
});
}
const integration = await getIntegrationForHttpRequest(ctx, source.integrationId);
if (!isHttpIntegrationKind(integration.kind) || integration.kind !== source.integrationKind) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "The selected integration is unavailable or has a different type",
});
}
return integration;
}
export async function resolveCustomWidgetSource(
ctx: IntegrationHttpContext,
source: CustomWidgetSource,
request: Pick<CustomJsxRequest, "kind" | "method" | "auth">,
getSecrets: () => Array<{ kind: string; value: string }>,
): Promise<
Pick<
CustomWidgetHttpRequest,
"baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets" | "resolveConnectionAsync"
> & {
cacheVersion: string;
}
> {
if (source.type !== "integration") {
const authType = getCustomWidgetSourceAuthType(source);
let auth: CustomWidgetHttpRequest["auth"];
if (request.auth !== "none" && authType !== "none") {
auth = { type: authType, secrets: getSecrets() };
if (typeof source.auth === "object") auth.headerName = source.auth.name;
}
return { baseUrl: source.baseUrl, networkScope: source.networkScope, auth, cacheVersion: "" };
}
const integration = await resolveIntegration(ctx, source);
return getIntegrationHttpConnection(integration, request.auth !== "none");
}
export async function getCustomWidgetIntegrationCacheVersions(
ctx: IntegrationHttpContext,
sources: Record<string, CustomWidgetSource>,
) {
return Promise.all(
Object.entries(sources).flatMap(([sourceId, source]) => {
if (source.type !== "integration") return [];
return [
resolveIntegration(ctx, source)
.then((integration) => `${sourceId}:${getIntegrationHttpCacheVersion(integration)}`)
.catch(() => `${sourceId}:unavailable`),
];
}),
);
}
@@ -1,5 +1,3 @@
import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
@@ -26,7 +24,7 @@ export const transferProcedures = {
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return exportCustomWidgetDefinition(parseStoredCustomWidgetDefinition(definition));
return parseStoredCustomWidgetDefinition(definition);
}),
exportLegacy: permissionRequiredProcedure
@@ -59,7 +57,6 @@ export const transferProcedures = {
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
assertSecretSources(input.widget.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
logger.info("Imported custom widget definition", { id, name: input.widget.name });
@@ -83,7 +80,6 @@ export const transferProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
assertSecretSources(input.widget.sources, input.secrets);
const [legacy, current] = await Promise.all([
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
@@ -161,7 +157,7 @@ function canPreserveLegacySecrets(
widget: z.infer<typeof customWidgetImportSchema>,
) {
const source = widget.sources.default;
if (!source || source.type === "integration" || getAuthType(source.auth) !== legacy.authType) return false;
if (!source || getAuthType(source.auth) !== legacy.authType) return false;
if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) return false;
const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined;
return headerName === (legacy.headerName ?? undefined);
@@ -1,5 +1,3 @@
import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -58,7 +56,7 @@ async function getWorkshopWidget(submissionId: string) {
if (!validation.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
}
return { submission, widget: exportCustomWidgetDefinition(validation.data) };
return { submission, widget: validation.data };
} catch (error) {
if (error instanceof TRPCError) throw error;
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
@@ -74,8 +72,7 @@ const workshopSearchInputSchema = z.object({
const sourceOverridesSchema = z.record(
customWidgetIdentifierSchema,
z.strictObject({
baseUrl: z.string().optional(),
integrationId: z.string().min(1).max(100).optional(),
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
}),
);
@@ -168,14 +165,12 @@ export const workshopProcedures = {
sourceId,
{
baseUrl: source.baseUrl,
integrationId: source.integrationId,
networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public",
},
]),
),
),
});
await assertCustomWidgetIntegrationBindings(ctx, configured.sources);
assertSecretSources(configured.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, input.secrets);
const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
@@ -1,126 +0,0 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
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 } from "@homarr/custom-widgets/server";
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import { getIntegrationHttpAuthenticationAsync } from "@homarr/integrations/factory";
import type { IntegrationHttpAuthentication } from "@homarr/integrations/factory";
export interface IntegrationHttpContext {
db: Database;
session: Session | null;
}
export async function getIntegrationForHttpRequest(ctx: IntegrationHttpContext, integrationId: string) {
const userId = ctx.session?.user.id ?? "";
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId) });
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, integrationId),
with: {
secrets: true,
userPermissions: { where: eq(integrationUserPermissions.userId, userId) },
groupPermissions: {
where: inArray(integrationGroupPermissions.groupId, groups.map(({ groupId }) => groupId).concat("")),
},
},
});
// An arbitrary read can expose service credentials or perform a mutation. Native
// integration use/interact permissions authorize only Homarr's bounded operations.
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Arbitrary integration requests require full integration access",
});
}
return integration;
}
type HttpIntegration = Awaited<ReturnType<typeof getIntegrationForHttpRequest>>;
export function getIntegrationHttpCacheVersion(integration: HttpIntegration) {
return createHash("sha256")
.update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets]))
.digest("hex")
.slice(0, 16);
}
export async function getIntegrationHttpConnection(integration: HttpIntegration, authenticate = true) {
try {
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
let baseUrl = integration.url;
if (integration.kind === "ical") baseUrl = secrets.find(({ kind }) => kind === "url")?.value ?? "";
return {
baseUrl,
networkScope: "loopback" as const,
pathPrefix: new URL(baseUrl).pathname,
redactSecrets: secrets,
cacheVersion: getIntegrationHttpCacheVersion(integration),
resolveConnectionAsync: async () => {
try {
let authentication: IntegrationHttpAuthentication = {};
if (authenticate) {
authentication = await getIntegrationHttpAuthenticationAsync({
...integration,
externalUrl: null,
decryptedSecrets: secrets,
});
}
const auth = toWidgetAuth(authentication, secrets);
const url = new URL(authentication.baseUrl ?? baseUrl);
let tls: CustomWidgetHttpRequest["tls"];
if (url.protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
return { baseUrl: url.href, auth, tls, pathPrefix: url.pathname, redactSecrets: auth.secrets };
} catch {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "Integration connection could not be resolved",
});
}
},
};
} catch {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration connection could not be resolved" });
}
}
function toWidgetAuth(
authentication: IntegrationHttpAuthentication,
secrets: CustomWidgetAuthConfig["secrets"],
): CustomWidgetAuthConfig {
const authSecrets = [...secrets, ...(authentication.redactValues ?? []).map((value) => ({ kind: "session", value }))];
const refresh = authentication.refreshAsync;
return {
...authentication,
type: "integration",
secrets: authSecrets,
refreshAsync:
refresh &&
(async () => {
try {
return toWidgetAuth(await refresh(), secrets);
} catch {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "Integration session could not be refreshed",
});
}
}),
};
}
@@ -42,8 +42,7 @@ if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
if (!pokedex) throw new Error("Pokédex bundled widget is missing");
const jellyfinDefaultSource = jellyfin.sources.default;
if (!jellyfinDefaultSource || jellyfinDefaultSource.type === "integration")
throw new Error("Jellyfin default source is missing");
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
@@ -182,7 +181,7 @@ describe("custom widget definition persistence", () => {
}
const defaultSource = previewSession.sources.default;
if (!defaultSource || defaultSource.type === "integration") throw new Error("Pokédex preview source is missing");
if (!defaultSource) throw new Error("Pokédex preview source is missing");
await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []);
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
@@ -676,7 +675,7 @@ describe("custom widget definition persistence", () => {
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const defaultSource = definition.sources.default;
if (!defaultSource || defaultSource.type === "integration") throw new Error("Jellyfin default source is missing");
if (!defaultSource) throw new Error("Jellyfin default source is missing");
await db
.update(customWidgetDefinitions)
.set(
@@ -721,7 +720,7 @@ describe("custom widget definition persistence", () => {
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const expectedSource = definition.sources.default;
if (!expectedSource || expectedSource.type === "integration" || typeof expectedSource.auth === "string") {
if (!expectedSource || typeof expectedSource.auth === "string") {
throw new Error("Jellyfin default source must use header authentication");
}
await db
+1 -22
View File
@@ -454,7 +454,7 @@ export const userRouter = createTRPCRouter({
}
const user = await ctx.db.query.users.findFirst({
columns: { name: true, email: true, provider: true },
columns: { email: true, provider: true },
where: eq(users.id, input.id),
});
@@ -472,13 +472,6 @@ 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;
@@ -504,13 +497,6 @@ 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({
@@ -533,13 +519,6 @@ 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) {
+27 -15
View File
@@ -1,4 +1,3 @@
import { getCustomWidgetIntegrationCacheVersions, resolveCustomWidgetSource } from "../custom-widget/source-resolver";
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
@@ -14,7 +13,7 @@ import {
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
@@ -122,6 +121,25 @@ const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
};
type IdentifiedRequest = CustomJsxRequest & { id: string };
type IdentifiedSource = CustomWidgetSource & { id: string };
const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => {
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
if (mode === "none" || authType === "none") return undefined;
const secrets = resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) }));
return {
type: authType,
secrets,
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
};
const withRequestLimit = async <T>(
ctx: RouterContext,
@@ -154,26 +172,21 @@ const executeRequest = async (
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
const source = findSource(resolved, request.source);
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
const connection = await resolveCustomWidgetSource(ctx, source, request, () =>
resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
);
const targetUrl = renderRequestTarget(connection.baseUrl, request, values);
const response = await withRequestLimit(ctx, resolved, request, () =>
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
return withRequestLimit(ctx, resolved, request, () =>
executeCustomWidgetRequest({
...connection,
baseUrl: source.baseUrl,
targetUrl,
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth: getAuth(resolved, source, request.auth),
networkScope: source.networkScope,
kind: request.kind,
cacheKey:
request.kind === "query" ? `${getCacheKey(resolved, request, values)}:${connection.cacheVersion}` : undefined,
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
cacheTtlSeconds: request.cacheSeconds,
}),
);
return response;
};
export const customApiRouter = createTRPCRouter({
@@ -186,7 +199,6 @@ export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const integrationVersions = await getCustomWidgetIntegrationCacheVersions(ctx, resolved.definition.sources);
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
@@ -227,7 +239,7 @@ export const customApiRouter = createTRPCRouter({
return {
type: "customJsx" as const,
template: resolved.definition.template,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationVersions.join(":")}`,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}`,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
-1
View File
@@ -46,5 +46,4 @@ 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)),
});
@@ -1,29 +0,0 @@
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,
};
}),
});
+1 -1
View File
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
return "http://127.0.0.1:3000";
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
}
export const trpcPath = "/api/trpc";
@@ -14,6 +14,7 @@ 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,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "../src/core/request-schema";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -280,7 +279,7 @@ export function getDeterministicEvaluationIssues(
message: `Use the verified source URL ${expectations.sourceBaseUrl}.`,
});
}
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
if (authType !== expectations.sourceAuth) {
issues.push({
path: ["sources", "default", "auth"],
@@ -468,7 +467,7 @@ const getJudgeRuntimeContext = (widget: HomarrCustomWidgetV2) => {
const sections = [getCustomWidgetSkillReference("runtime").content];
const hasMutation = Object.values(widget.requests).some((request) => request.kind === "action");
const hasProtectedSource = Object.values(widget.sources).some((source) => {
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return authType !== "none";
});
if (hasMutation || hasProtectedSource) sections.push(getCustomWidgetSkillReference("security").content);
@@ -72,8 +72,6 @@ ${leanShape}
Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest.
Supported existing integrations use {"type":"integration","integrationKind":"sonarr"} as a source, without baseUrl, networkScope, or auth. The installer selects their local integration. An MCP author can use integration_all to obtain a matching integrationId for preview/create. Integration URLs and credentials resolve server-side; exports remove integrationId. All integration calls require full integration access. Integration methods other than GET must be manual actions.
Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE is valid only for actions, requires full permission, and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option.
@@ -13,7 +13,7 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.10.0";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.9.0";
export const CUSTOM_WIDGET_SKILL_REFERENCE_NAMES = ["schema", "runtime", "security"] as const;
export type CustomWidgetSkillReferenceName = (typeof CUSTOM_WIDGET_SKILL_REFERENCE_NAMES)[number];
@@ -58,7 +58,7 @@ interface HomarrCustomWidgetV2 {
}
\`\`\`
\`sources.default\` is required. HTTP source properties: \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`:
The object key \`default\` is the required source ID; \`default\` is not a property on a source. Source properties are \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`:
\`\`\`json
{
@@ -85,17 +85,14 @@ interface HomarrCustomWidgetV2 {
}
\`\`\`
Integration sources use \`{"type":"integration","integrationKind":"sonarr"}\`. Bind \`integrationId\` from \`integration_all\`; exports omit it. Calls require full integration access. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions.
Auth is \`none\`, \`bearer\`, \`basic\`, \`{ "type": "apiKeyHeader", "name": "X-Api-Key" }\`, or \`{ "type": "apiKeyQuery", "name": "api_key" }\`. A request defaults to source \`default\`, kind \`query\`, method \`GET\`, trigger \`load\`, inherited auth, and view permission. Set \`trigger: "manual"\` for a parameterized query. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. Do not use \`load: false\`.
Auth: \`none\`, \`bearer\`, \`basic\`, \`{ "type": "apiKeyHeader", "name": "X-Api-Key" }\`, or \`{ "type": "apiKeyQuery", "name": "api_key" }\`. Requests default to source \`default\`, kind \`query\`, method \`GET\`, trigger \`load\`, inherited auth, and view permission. Parameterized queries need \`trigger: "manual"\`. Actions default to manual/modify; DELETE requires full permission and confirmation. Do not use \`load: false\`.
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately.
Paths use \`{option:name}\` and \`{param:name}\`; query/body references use \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Constants stay primitive (\`take: 10\`); \`$param\` is only for manual helpers, never load queries. Names and types are inferred.
Paths bind \`{option:name}\` and \`{param:name}\`; query/body references bind \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Use primitive constants (\`take: 10\`). \`$param\` is only for manual requests.
Options require \`label\`, \`control\`, and \`default\`. Optional fields: \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, \`group\`.
Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`.
`,
"references/runtime.md": `# Runtime
Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and temporary \`inputs.name\`. Status is \`{ loading, ok, status, statusText, error }\`. Render load queries directly from \`data\` and \`status\` with \`RefreshButton\`; never wrap them in \`SubFetch\`.
@@ -104,17 +104,6 @@ type Definition = z.infer<typeof customWidgetDefinitionSchema>;
function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
for (const [requestId, request] of Object.entries(definition.requests)) {
if (
definition.sources[request.source]?.type === "integration" &&
request.method !== "GET" &&
request.kind !== "action"
) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "kind"],
message: "Integration requests with a method other than GET must be manual actions",
});
}
if (!definition.sources[request.source]) {
ctx.addIssue({
code: "custom",
+4 -23
View File
@@ -1,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "./request-schema";
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customWidgetImportSchema } from "./custom-jsx-schema";
@@ -105,7 +104,7 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
return { success: true, widget: exportCustomWidgetDefinition(parsed.data), warnings: [] };
return { success: true, widget: parsed.data, warnings: [] };
}
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
@@ -158,35 +157,17 @@ export function getImportReview(value: unknown): ImportReview | null {
const widget = parsed.data;
return {
name: widget.name,
origins: Object.values(widget.sources).map((source) =>
source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin,
),
origins: Object.values(widget.sources).map((source) => new URL(source.baseUrl).origin),
authTypes: [
...new Set(
Object.values(widget.sources).map((source) =>
source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
typeof source.auth === "string" ? source.auth : source.auth.type,
),
),
],
networkScopes: [
...new Set(Object.values(widget.sources).flatMap((source) => (source.networkScope ? [source.networkScope] : []))),
],
networkScopes: [...new Set(Object.values(widget.sources).map((source) => source.networkScope))],
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
};
}
/** Deployment bindings never travel with an exported or published widget. */
export function exportCustomWidgetDefinition(widget: HomarrCustomWidgetV2): HomarrCustomWidgetV2 {
return {
...widget,
sources: Object.fromEntries(
Object.entries(widget.sources).map(([id, source]) => {
if (source.type !== "integration") return [id, source];
const { integrationId: _localBinding, ...portable } = source;
return [id, portable];
}),
),
};
}
@@ -41,10 +41,7 @@ const sourceUrlIssueMessages: Record<CustomWidgetSourceUrlIssue, string> = {
queryOrFragment: "API source URLs cannot contain a query string or fragment",
};
export const customWidgetHttpSourceSchema = z.strictObject({
type: z.literal("http").optional(),
integrationKind: z.never().optional(),
integrationId: z.never().optional(),
export const customWidgetSourceSchema = z.strictObject({
name: z.string().trim().min(1).max(128).optional(),
baseUrl: z
.string()
@@ -57,25 +54,7 @@ export const customWidgetHttpSourceSchema = z.strictObject({
networkScope: z.enum(customJsxNetworkScopes),
auth: authSchema.default("none"),
});
export const customWidgetIntegrationSourceSchema = z.strictObject({
type: z.literal("integration"),
name: z.string().trim().min(1).max(128).optional(),
integrationKind: customWidgetIdentifierSchema,
integrationId: z.string().min(1).max(100).optional(),
baseUrl: z.never().optional(),
networkScope: z.never().optional(),
auth: z.never().optional(),
});
export const customWidgetSourceSchema = z.union([customWidgetHttpSourceSchema, customWidgetIntegrationSourceSchema]);
export type CustomWidgetSource = z.infer<typeof customWidgetSourceSchema>;
export type CustomWidgetHttpSource = z.infer<typeof customWidgetHttpSourceSchema>;
export type CustomWidgetIntegrationSource = z.infer<typeof customWidgetIntegrationSourceSchema>;
export function getCustomWidgetSourceAuthType(source: { auth?: CustomWidgetHttpSource["auth"] } | undefined) {
if (!source?.auth) return "none" as const;
if (typeof source.auth === "string") return source.auth;
return source.auth.type;
}
export const customWidgetSourcesSchema = z
.record(customWidgetIdentifierSchema, customWidgetSourceSchema)
@@ -1,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "./request-schema";
import type { CustomWidgetSource } from "./request-schema";
import type { CustomWidgetSecretKind } from "./schema-types";
@@ -22,7 +21,7 @@ export function getCustomWidgetSecretRequirements(
sources: Record<string, CustomWidgetSource>,
): CustomWidgetSecretRequirement[] {
return Object.entries(sources).flatMap(([sourceId, source]) => {
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return getCustomWidgetRequiredSecretKinds(authType).map((kind) => ({
sourceId,
sourceName: source.name ?? sourceId,
@@ -1,5 +1,4 @@
import { getCustomWidgetSourceAuthType } from "./request-schema";
import type { CustomJsxNetworkScope, CustomWidgetSource } from "./request-schema";
import type { CustomWidgetSource } from "./request-schema";
import { getCustomWidgetSourceUrlIssue } from "./request-schema";
import type { CustomWidgetSecretKind } from "./schema-types";
import { getCustomWidgetSecretRequirements } from "./secret-requirements";
@@ -16,9 +15,7 @@ export interface CustomWidgetSourceSetup {
sourceName: string;
suggestedBaseUrl: string;
baseUrl: string;
networkScope: CustomJsxNetworkScope;
integrationKind?: string;
integrationId?: string;
networkScope: CustomWidgetSource["networkScope"];
authType: "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery";
credentialFields: Array<{
kind: CustomWidgetSecretKind;
@@ -43,18 +40,10 @@ export function isCustomWidgetSourceUrlPlaceholder(baseUrl: string) {
}
export function customWidgetSourceRequiresUrlConfirmation(source: CustomWidgetSource) {
if (source.type === "integration") return !source.integrationId;
return source.networkScope !== "public" || isCustomWidgetSourceUrlPlaceholder(source.baseUrl);
}
export function hasSameCustomWidgetSourceAuthentication(left: CustomWidgetSource, right: CustomWidgetSource) {
if (left.type === "integration" || right.type === "integration") {
return (
left.type === right.type &&
left.integrationKind === right.integrationKind &&
left.integrationId === right.integrationId
);
}
if (typeof left.auth === "string" || typeof right.auth === "string") return left.auth === right.auth;
return left.auth.type === right.auth.type && left.auth.name === right.auth.name;
}
@@ -65,15 +54,13 @@ export function getCustomWidgetSourceSetups(
): CustomWidgetSourceSetup[] {
const requirements = getCustomWidgetSecretRequirements(sources);
return Object.entries(sources).map(([sourceId, source]) => {
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return {
sourceId,
sourceName: source.name ?? sourceId,
suggestedBaseUrl: source.baseUrl ?? "",
baseUrl: source.baseUrl ?? "",
networkScope: source.networkScope ?? "private",
integrationKind: source.integrationKind,
integrationId: source.integrationId,
suggestedBaseUrl: source.baseUrl,
baseUrl: source.baseUrl,
networkScope: source.networkScope,
authType,
credentialFields: requirements
.filter((requirement) => requirement.sourceId === sourceId)
@@ -94,23 +81,12 @@ export function getCustomWidgetSourceSetups(
export function applyCustomWidgetSourceSetup(
sources: Record<string, CustomWidgetSource>,
setup: Record<string, { baseUrl?: string; networkScope?: CustomJsxNetworkScope; integrationId?: string }>,
setup: Record<string, Pick<CustomWidgetSource, "baseUrl" | "networkScope">>,
) {
return Object.fromEntries(
Object.entries(sources).map(([sourceId, source]) => {
const configured = setup[sourceId];
if (!configured) return [sourceId, source];
if (source.type === "integration") {
return [sourceId, { ...source, integrationId: configured.integrationId }];
}
return [
sourceId,
{
...source,
baseUrl: configured.baseUrl ?? source.baseUrl,
networkScope: configured.networkScope ?? source.networkScope,
},
];
return [sourceId, configured ? { ...source, ...configured } : source];
}),
);
}
@@ -1,6 +1,3 @@
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy";
type HeaderSetter = Pick<Headers, "set">;
export const AUTH_HANDLERS: Record<
@@ -46,54 +43,3 @@ export function applyAuth(
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
}
}
export async function performAuthenticatedRequest(
input: CustomWidgetHttpRequest,
signal: AbortSignal,
perform: (input: CustomWidgetHttpRequest, signal: AbortSignal) => Promise<CustomWidgetHttpResponse>,
): Promise<CustomWidgetHttpResponse> {
const target = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(target, input.pathPrefix);
if (input.resolveConnectionAsync) {
const connection = await withinDeadline(input.resolveConnectionAsync, signal);
const base = new URL(connection.baseUrl);
target.protocol = base.protocol;
target.host = base.host;
input = { ...input, ...connection, targetUrl: target };
}
let response = await perform(input, signal);
if (!isExpired(input, response)) return response;
response.ok = false;
response.status = 401;
response.statusText = "Integration session expired";
if (!input.auth?.refreshAsync) return response;
const auth = await withinDeadline(input.auth.refreshAsync, signal);
// An action may have reached the service. Refresh for its next explicit invocation.
if (input.kind === "action") return response;
input = { ...input, auth };
response = await perform(input, signal);
if (isExpired(input, response)) {
response.ok = false;
response.status = 401;
response.statusText = "Integration session expired";
}
return response;
}
function isExpired(input: CustomWidgetHttpRequest, response: CustomWidgetHttpResponse) {
return input.auth && (response.status === 401 || input.auth.isExpired?.(response));
}
async function withinDeadline<T>(callback: () => Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let abort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
});
try {
return await Promise.race([callback(), aborted]);
} finally {
if (abort) signal.removeEventListener("abort", abort);
}
}
@@ -39,7 +39,3 @@ export {
resolveCustomWidgetRequestValues,
} from "./request-manifest";
export type { CustomJsxRuntimeParams } from "./request-manifest";
export { redactResponseSecrets } from "./response";
export { assertCustomWidgetPathScope } from "./network-policy";
@@ -1,5 +1,4 @@
import { lookup } from "node:dns/promises";
import type { ConnectionOptions } from "node:tls";
import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net";
import { Agent } from "undici";
@@ -192,11 +191,7 @@ export function assertSafeStaticHeaders(headers: Record<string, string> | undefi
}
}
export function createPinnedAgent(
addresses: ResolvedAddress[],
timeoutMs: number,
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
) {
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
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;
@@ -209,42 +204,10 @@ export function createPinnedAgent(
else callback(null, selected.address, selected.family);
};
return new Agent({
connect: { ...tls, lookup: customLookup },
connect: { lookup: customLookup },
connectTimeout: timeoutMs,
headersTimeout: timeoutMs,
bodyTimeout: timeoutMs,
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
});
}
export function assertCustomWidgetPathScope(url: URL, pathPrefix: string) {
const prefix = decodePath(pathPrefix).replace(/\/+$/u, "");
const path = decodePath(url.pathname);
if (
path.includes("\\") ||
path.split("/").some((segment) => segment === "." || segment === "..") ||
(prefix && path !== prefix && !path.startsWith(`${prefix}/`))
) {
throw new CustomWidgetDomainError({
code: "FORBIDDEN",
message: "Request must remain within the selected integration URL",
});
}
}
function decodePath(value: string) {
let path = value;
for (let pass = 0; pass < 4; pass += 1) {
let decoded: string;
try {
decoded = decodeURIComponent(path);
} catch {
return path;
}
if (decoded === path) return path;
path = decoded;
}
if (/%[0-9a-f]{2}/iu.test(path))
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Request path is excessively encoded" });
return path;
}
@@ -1,4 +1,3 @@
import { hasSameCustomWidgetSourceAuthentication } from "../core";
import { customWidgetDefinitionSchema } from "../core";
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
@@ -37,9 +36,3 @@ export function validatePreviewTemplateRevision(current: PreviewTemplateContext,
}
return parsed.data.template;
}
export function canConfigurePreviewSource(current: CustomWidgetSource, source: CustomWidgetSource) {
if (current.type === "integration" && source.type === "integration")
return current.integrationKind === source.integrationKind;
return hasSameCustomWidgetSourceAuthentication(current, source);
}
@@ -1,4 +1,3 @@
import { canConfigurePreviewSource } from "./preview-session-template";
import { z } from "zod/v4";
import {
@@ -6,6 +5,7 @@ import {
customWidgetRequestsSchema,
customWidgetSecretKinds,
customWidgetSourcesSchema,
hasSameCustomWidgetSourceAuthentication,
} from "../core";
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
@@ -242,7 +242,7 @@ export class CustomWidgetPreviewSessionService {
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!canConfigurePreviewSource(currentSource, source)) {
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
@@ -253,7 +253,7 @@ export class CustomWidgetPreviewSessionService {
...session,
sources: {
...session.sources,
[sourceId]: source,
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
@@ -2,13 +2,11 @@ import { Buffer } from "node:buffer";
import { STATUS_CODES } from "node:http";
import { Headers, Response } from "undici";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { applyAuth, performAuthenticatedRequest } from "./auth";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
import { applyAuth } from "./auth";
import { CustomWidgetDomainError } from "./errors";
import {
assertSafeStaticHeaders,
assertCustomWidgetPathScope,
createPinnedAgent,
resolveAndValidateHost,
resolveSameOriginTarget,
@@ -16,7 +14,6 @@ import {
} from "./network-policy";
import { closeDispatcher } from "./request-dispatcher-lifecycle";
import { parseResponseBody } from "./response";
import { redactResponseSecrets } from "./response";
export {
assertSafeStaticHeaders,
@@ -40,6 +37,32 @@ export const MAX_REQUEST_DURATION_MS = 45_000;
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE";
export interface CustomWidgetAuthConfig {
type: string;
secrets: Array<{ kind: string; value: string }>;
headerName?: string | null;
}
export interface CustomWidgetHttpRequest {
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod;
body?: string;
staticHeaders?: Record<string, string>;
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
}
export interface CustomWidgetHttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let cacheEpoch = 0;
@@ -52,8 +75,7 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
try {
assertRequest(input);
return await performAuthenticatedRequest(input, controller.signal, performRequestWithinDeadline);
return await performRequestWithinDeadline(input, controller.signal);
} catch (error) {
if (controller.signal.aborted) {
throw new CustomWidgetDomainError({
@@ -85,17 +107,12 @@ async function performRequestWithinDeadline(
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
let currentMethod = input.method;
let currentBody = input.auth?.transformBody ? input.auth.transformBody(input.body) : input.body;
assertRequest({ ...input, body: currentBody });
let currentBody = input.body;
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
for (let redirects = 0; ; redirects += 1) {
input.auth?.transformUrl?.(currentUrl);
resolveSameOriginTarget(input.baseUrl, currentUrl);
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(currentUrl, input.pathPrefix);
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();
@@ -118,22 +135,15 @@ async function performRequestWithinDeadline(
statusText: STATUS_CODES[responseData.statusCode] ?? "",
headers: normalizeResponseHeaders(responseData.headers),
});
const parsed = {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
result = {
kind: "response",
response: {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
},
};
if (input.auth?.isExpired?.(parsed)) {
parsed.ok = false;
parsed.status = 401;
parsed.statusText = "Integration session expired";
}
parsed.data = redactResponseSecrets(parsed.data, [
...(input.redactSecrets ?? []),
...(input.auth?.secrets ?? []),
]);
result = { kind: "response", response: parsed };
} else {
await responseData.body.dump();
result = {
@@ -238,8 +248,6 @@ function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | u
if (input.auth) {
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
for (const [name, value] of Object.entries(input.auth.headers ?? {})) headers.set(name, value);
for (const [name, value] of Object.entries(input.auth.query ?? {})) url.searchParams.set(name, value);
}
return headers;
}
@@ -1,43 +0,0 @@
import type { ConnectionOptions } from "node:tls";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
export interface CustomWidgetAuthConfig {
baseUrl?: string;
type: string;
secrets: Array<{ kind: string; value: string }>;
headerName?: string | null;
headers?: Record<string, string>;
query?: Record<string, string>;
transformUrl?(url: URL): void;
transformBody?(body: string | undefined): string;
isExpired?(response: CustomWidgetHttpResponse): boolean;
refreshAsync?(): Promise<CustomWidgetAuthConfig>;
}
export interface CustomWidgetHttpRequest {
resolveConnectionAsync?(): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "auth" | "tls" | "pathPrefix" | "redactSecrets">
>;
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
pathPrefix?: string;
redactSecrets?: CustomWidgetAuthConfig["secrets"];
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod;
body?: string;
staticHeaders?: Record<string, string>;
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
}
export interface CustomWidgetHttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
@@ -1,4 +1,3 @@
import { Buffer } from "node:buffer";
import type { Response } from "undici";
import { CustomWidgetDomainError } from "./errors";
@@ -64,39 +63,3 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
const tooLarge = () =>
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown {
if (secrets.length === 0) return data;
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);
}
@@ -22,7 +22,7 @@ const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.age
describe("authoring resources", () => {
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
const skill = getCustomWidgetSkill();
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.10.0" });
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.9.0" });
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
@@ -1,10 +1,9 @@
import type { ReactNode } from "react";
import { Alert, Badge, Checkbox, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconCheck, IconKey, IconServer, IconX } from "@tabler/icons-react";
import type {
CustomWidgetSecretKind,
CustomJsxNetworkScope,
CustomWidgetSource,
CustomWidgetSourceSetup,
CustomWidgetSourceUrlIssue,
} from "../core";
@@ -12,8 +11,7 @@ import { getCustomWidgetSourceSetupIssue } from "../core";
export interface CustomWidgetSourceSetupValue {
baseUrl: string;
networkScope: CustomJsxNetworkScope;
integrationId?: string;
networkScope: CustomWidgetSource["networkScope"];
urlConfirmed: boolean;
secrets: Partial<Record<CustomWidgetSecretKind, string>>;
}
@@ -40,7 +38,6 @@ export interface CustomWidgetSourceSetupPanelProps {
values: Record<string, CustomWidgetSourceSetupValue>;
messages: CustomWidgetSourceSetupMessages;
onChange(sourceId: string, value: CustomWidgetSourceSetupValue): void;
renderIntegrationSource?(setup: CustomWidgetSourceSetup, value: CustomWidgetSourceSetupValue): ReactNode;
}
export function createCustomWidgetSourceSetupValues(
@@ -51,7 +48,6 @@ export function createCustomWidgetSourceSetupValues(
setup.sourceId,
{
baseUrl: setup.baseUrl,
integrationId: setup.integrationId,
networkScope: setup.networkScope,
urlConfirmed: !setup.requiresUrlConfirmation,
secrets: {} as Partial<Record<CustomWidgetSecretKind, string>>,
@@ -66,7 +62,6 @@ export function isCustomWidgetSourceSetupReady(
) {
return setups.every((setup) => {
const value = values[setup.sourceId];
if (setup.integrationKind) return Boolean(value?.integrationId);
return Boolean(value && !getCustomWidgetSourceSetupIssue(value) && value.urlConfirmed);
});
}
@@ -76,7 +71,6 @@ export function CustomWidgetSourceSetupPanel({
values,
messages,
onChange,
renderIntegrationSource,
}: CustomWidgetSourceSetupPanelProps) {
if (setups.length === 0) return null;
return (
@@ -90,14 +84,6 @@ export function CustomWidgetSourceSetupPanel({
{setups.map((setup) => {
const value = values[setup.sourceId] ?? createCustomWidgetSourceSetupValues([setup])[setup.sourceId];
if (!value) return null;
if (setup.integrationKind) {
return (
<Stack key={setup.sourceId} gap="sm">
<Text fw={600}>{setup.sourceName}</Text>
{renderIntegrationSource?.(setup, value)}
</Stack>
);
}
const issue = getCustomWidgetSourceSetupIssue(value);
const missingCredentials = setup.credentialFields.filter(
(field) => !field.configured && !value.secrets[field.kind]?.trim(),
@@ -157,7 +143,7 @@ export function CustomWidgetSourceSetupPanel({
networkScope &&
onChange(setup.sourceId, {
...value,
networkScope: networkScope as CustomJsxNetworkScope,
networkScope: networkScope as CustomWidgetSource["networkScope"],
})
}
/>
-1
View File
@@ -505,7 +505,6 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
-1
View File
@@ -503,7 +503,6 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
-1
View File
@@ -488,7 +488,6 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff. Show more