Compare commits

...
Author SHA1 Message Date
ajnart cf60c9e4cf fix: parse JSX in custom widget architecture guard 2026-09-12 19:48:37 +02:00
ajnart a29ec26c27 fix: resolve widget platform CI lint and formatting failures 2026-09-12 19:36:30 +02:00
ajnart 9ff1019a3d fix: simplify widget package publishing and navigation 2026-09-12 19:28:16 +02:00
ajnart 84058544a4 refactor: consolidate widget workbench modules 2026-09-12 16:06:18 +02:00
ajnart 29c94be821 fix: preserve widget drafts and connection groups 2026-09-12 15:50:36 +02:00
ajnart 48042a08ff fix: simplify custom widget package workbench 2026-09-12 15:33:51 +02:00
ajnart 9a52a035aa fix: restore cached Next.js build outputs 2026-09-12 15:31:54 +02:00
ajnart 505032bd21 chore(custom-widgets): fix formatting and dependency order 2026-09-12 14:24:58 +02:00
ajnart 19aa92ec25 fix(custom-widgets): preserve pnpm esbuild output tracing 2026-09-12 14:24:47 +02:00
ajnart de75c5f29d fix(custom-widgets): stabilize commands and resolve dismissed confirmations 2026-09-12 14:09:41 +02:00
ajnart 8f152f1929 feat(custom-widgets): add extensible widget platform 2026-09-12 13:54:05 +02:00
leafyelin 6cca757493 feat(plugins): add llama.cpp server integration and widget (#6765) 2026-09-10 20:06:13 +02:00
Thomas 1102ad8342 fix: protect demo users from deletion and password changes (#6819) 2026-09-10 19:52:47 +02:00
Thomas 7c00c18a42 docs(mcp): restore custom widget authoring guidance (#6817) 2026-09-10 19:24:55 +02:00
Thomas ea3dac752f fix(mcp): harden tool schemas and dispatch (#6815) 2026-09-10 19:19:47 +02:00
Thomas f994d665d2 ci: remove automatic test gates and trim test suites (#6816)
* ci: remove automatic test gates and trim test suites

* docs: remove CI audit report
2026-09-10 19:14:31 +02:00
Thomas 3358b6337f fix(mcp): use JSON-compatible invitation dates (#6814) 2026-09-10 17:53:38 +02:00
Thomas 61d3fd5f08 fix(board): restore container grid geometry (#6808) 2026-09-09 15:04:27 +02:00
Thomas 3aac106156 docs: correct v2 beta installation guidance (#6809) 2026-09-09 12:09:14 +02:00
Thomas 9d857377c1 fix: refine release v2 interactions (#6805) 2026-09-08 11:36:19 +02:00
Thomas 923905c529 fix: keep bookmark density consistent (#6804)
* fix: keep bookmark density consistent

* fix: prioritize bookmark titles in compact rows

* fix: preserve compact bookmark text fallback
2026-09-08 10:39:41 +02:00
398 changed files with 35803 additions and 3101 deletions

No files matched your search

+6 -6
View File
@@ -1,20 +1,20 @@
---
name: homarr-custom-widget
description: Author, validate, preview, test, install, or configure API-backed Homarr Custom JSX v2 widgets.
description: Author, validate, preview, test, install, or configure static or API-backed Homarr Custom JSX v2 widgets.
---
# Homarr Custom Widget
Author one widget or a coordinated set of widgets. Load only needed release-matched context. Run lifecycle tools alone; independent reads may run together. For a set, research once and finish each widget's validation, evidence, and persistence before the next.
Author one widget or a coordinated set. Load release-matched context on demand. Run lifecycle tools alone; independent reads may run together. Finish each widget's validation, evidence, and persistence before the next.
1. Read primary API documentation. Use web search when documentation is not supplied or may have changed.
2. Create credential-free definitions with keyed `sources`, `requests`, optional `options`, and safe JSX `template`.
1. For connected widgets, read primary API documentation; use web search when it is missing or may have changed.
2. Create credential-free definitions with keyed `sources`, `requests`, optional `options`, and safe JSX `template`. Static widgets use empty `sources` and `requests`; an API is optional.
3. While drafting, use `customWidget_validateTemplate` for focused JSX diagnostics without resending the manifest.
4. Send the definition once to `customWidget_previewCreate` and test its queries/actions. For a JSX-only fix, validate, call `customWidget_previewReviseTemplate` with its session, and retest; it inherits the manifest and resets evidence. Create a preview only for source/request/option changes.
5. Configure deployment-specific source URLs and credentials through Homarr; never repeat plaintext.
6. Persist each exact final tested preview with `customWidget_createFromPreview`. Do not resend a large definition through `customWidget_create` when a preview session is available.
Treat a supplied sample or successful preview response as the binding contract. Render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Humanize numeric enums with indexed literal label arrays, omit absent numeric values instead of inventing zero, and label timestamp timezones. Give recoverable load errors and empty states a clear refresh or retry path.
Use supplied samples or preview responses as the data contract. Render requested fields and all returned items; guard optional nested values. Humanize numeric enums with label arrays, omit absent numbers, and label timestamp timezones. Give load errors and empty states a refresh or retry path.
Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Render load queries from `data` and `status` with `RefreshButton`; reserve `SubFetch` for manual parameterized queries. Templates read `data`, `status`, `options`, and temporary `inputs`.
@@ -26,7 +26,7 @@ Plan capabilities. Use one `customWidget_findComponents` search per job. Batch s
Do not simplify a useful workflow merely because the template is interpreted. Freely compose any supported installed components with multiple sources, requests, options, `choicesFrom`, bound filters, charts, responsive detail areas, manual queries, and safe actions when they improve the user's job. Complexity must remain purposeful rather than decorative.
Polish with a divided list or responsive media grid. Make one metric/action asymmetrically primary; keep headers compact, identity/state clear, metadata quiet, and one badge. At base artwork fills its row and caps above xs; never combine full-width media and nowrap.
Use divided lists or responsive media grids, one primary metric/action, compact headers, quiet metadata, and one badge. Full-width artwork wraps at base and caps above xs.
Make initial states actionable and wrap variable labels/values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
@@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 {
}
```
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?`:
Static widgets use empty `sources` and `requests`. Each request must resolve to a defined source; omitting `source` uses `default` only when that ID exists. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`:
```json
{
@@ -1,5 +1,7 @@
# Security
Private access includes LAN, Docker DNS, and Tailscale CGNAT addresses. Loopback means the Homarr server or container, not the viewer's browser.
All requests use Homarr's protected server executor. Source origin, network scope, DNS, redirects, SSRF, rate limits, permissions, size limits, timeouts, and encrypted credential injection remain enforced.
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
+6
View File
@@ -93,3 +93,9 @@ HOMARR_WEBSITE_URL=https://homarr.dev
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
# Set to false for writable preview deployments so the demo user can modify the dashboard.
# DEMO_READ_ONLY=false
# Persistent trusted-widget build cache; activated artifacts are also retained in the database.
# Defaults to /appdata/custom-widgets in production and ./appdata/custom-widgets in development.
# CUSTOM_WIDGET_PACKAGE_PATH=/appdata/custom-widgets
# Maximum concurrently active trusted widget server artifacts per Homarr process.
# CUSTOM_WIDGET_MAX_SERVER_PROCESSES=16
+10 -57
View File
@@ -97,17 +97,16 @@ jobs:
- name: Validate Custom Widget bundle budget
run: |
pnpm --filter nextjs build
pnpm turbo run build --filter=@homarr/nextjs
pnpm check:custom-widget-bundle
- name: Run unit tests
run: pnpm test
container:
name: Container and E2E
name: Container build
# Same-repository previews already build amd64; only build a fallback when no digest is available.
needs: [fast-gate, preview-amd64]
if: |
!cancelled() && needs.fast-gate.result == 'success' &&
needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' &&
(
github.event_name == 'merge_group' ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -115,10 +114,9 @@ jobs:
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
)
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
@@ -126,23 +124,10 @@ jobs:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: "!env.ACT"
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/setup-buildx-action@v4
- name: Reuse amd64 preview image
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
env:
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
- name: Build container once
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/build-push-action@v7
@@ -152,9 +137,7 @@ jobs:
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
tags: homarr-ci
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
@@ -182,37 +165,7 @@ jobs:
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
env:
NODE_ENV: development
CI: "true"
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
--tag homarr-ci "${secret_args[@]}" .
preview-amd64:
name: Build amd64 preview image
@@ -320,10 +273,10 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [container, preview-amd64, preview-arm64]
needs: [fast-gate, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.fast-gate.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
+29 -12
View File
@@ -2,8 +2,36 @@ name: Workshop
on:
pull_request:
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
push:
branches: [dev, v2, release/v2, main]
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
permissions:
contents: read
@@ -15,21 +43,10 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24.18.0
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @homarr/workshop typecheck
- run: pnpm --filter @homarr/docs typecheck
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
- run: pnpm test:workshop
- run: pnpm test:workshop-image
publish-amd64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
+1
View File
@@ -69,6 +69,7 @@ e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
apps/nextjs/public/__widget-editor/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

@@ -23,7 +23,7 @@ Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is wha
:::warning Back up before upgrading
Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2) and create a
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.
@@ -65,7 +65,7 @@ We have called this the **"ultimate Homarr update"**. Not because work stops, bu
We threw out the old drag-and-drop system and rebuilt it on [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr previews the result, checks collisions and only saves a valid layout. Bad moves roll back instead of wrecking the board. In short: less fighting with your own dashboard.
![A Calendar tile repositioned between widgets on the rebuilt Homarr 2.0 board grid](./img/drag-and-drop-still.webp)
![A Calendar tile moving across the rebuilt Homarr 2.0 board grid](./img/drag-and-drop.gif)
### Goodbye Groups and sections, bonjour Containers
@@ -161,7 +161,7 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
## Links
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
- Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2).
- Follow the [v2 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).
+143 -1
View File
@@ -3,7 +3,8 @@
The Homarr image includes a recovery CLI for operations that cannot be completed in the web interface. It is not an
application API; use the [API](/docs/management/api) or [MCP endpoint](/docs/management/mcp) for automation.
Back up the database and stop Homarr before running a command that changes data.
Back up the database and stop Homarr before running a recovery command that changes database records directly.
Widget project commands and authenticated preview requests can run while Homarr is online.
Failed commands print an error and return a non-zero exit code, so shell scripts can stop safely.
## Run the recovery CLI
@@ -43,6 +44,147 @@ homarr <command>
See the pages in this section for available commands.
## Build and share custom widget packages
The `widgets` commands accept a project directory, a v3 package JSON file, or an offline archive. Scaffolding,
validation, compilation and packing do not need a running Homarr database.
Create a project to edit in your own IDE:
```sh
homarr widgets init my-clock --example clock --id example.my-clock --name "My clock"
homarr widgets validate my-clock
homarr widgets pack my-clock
```
`init` (also `scaffold`) creates a new directory with `homarr-widget.json` and normal TSX, TypeScript, CSS and asset
files. It refuses to overwrite an existing directory. Choose `clock`, `timer`, `beszel`, `checklist`, `downloads`,
`sonarr`, `rack`, `backup`, `household`, `overview`, `media`, `sqlite` or `mqtt` with `--example`; leaving out `--id` generates a unique
local package identity. Edit the manifest and files, then repeat validation and packing. The manifest carries entrypoints,
handlers, connection requirements, options and exact dependency versions; it does not embed the files map in this layout.
Directory loading skips hidden files, symlinks, `node_modules`, `dist`, `coverage`, generated archives and root-level
`package.json`, `package-lock.json` and `tsconfig.json`. Keep all widget source under supported TS/JS, JSON, CSS, SVG,
text, Markdown, image or font files. Build output defaults to `dist/widget.artifact.json`; packing defaults to
`dist/widget.homarr-widget.json`. Remove or rename an existing output before rebuilding, or pass a new `--output`.
The same commands also accept the single-file format:
```sh
homarr widgets validate widget.json
homarr widgets build widget.json --output widget.artifact.json
homarr widgets pack widget.json --output widget.homarr-widget.json
homarr widgets validate widget.homarr-widget.json
```
`validate` checks the package structure or an archive's source and artifact checksums. It does not execute widget code.
`build` compiles JSX/TSX, local modules, CSS and assets into browser and optional server output. `pack` includes the
source, compiled output and dependency lock in one JSON archive for offline installation. Output files must not already exist.
Packages with third-party dependencies need `--trust` to install their exact declared versions:
```sh
homarr widgets pack widget.json --trust
```
Dependency installations are cached in `.homarr-widgets` beside the input file. Installation scripts are disabled;
packages requiring native build steps are not supported by these commands. Compilation does not start the widget's
server handlers. Installed trusted widgets execute with Homarr's privileges; source and artifact checksums establish
integrity, not publisher authenticity. Local connection bindings, credentials and widget state are separate from the archive.
### Preview a local project in Homarr
Set `HOMARR_API_KEY` to an admin API key from your own Homarr instance, then run:
```sh
homarr widgets preview my-clock --url http://homarr.local:7575 --trust
```
The command creates a disabled installation on first use, compiles the current local source on that Homarr instance,
and prints a link to its exact preview session. Open the link while signed in as the API key owner. Repeating the command
replaces the previous preview; it does not activate or overwrite the installation's saved draft. Use `--save-draft` to
also save your local source. Use `--installation-id ID` to target an existing installation. The local
`.homarr-widget.local.json` file remembers installation and preview IDs and is excluded from scaffolded Git projects.
Pass `--bindings bindings.json` for a JSON object mapping declared connection names to existing Homarr connection IDs,
and `--options options.json` for preview option values. Credentials remain in Homarr. `--live-actions` explicitly allows
real mutations during this preview; queries and subscriptions can already execute trusted server code. `--trust` is
required on every invocation. Set `HOMARR_URL` instead of `--url`, or select a different secret variable with
`--api-key-env VARIABLE_NAME`. Preview compilation may install declared dependencies after this explicit trust choice.
Use `--scenario NAME` to select synthetic query data declared in the package's `previewScenarios`. This skips server
startup and configured service requests; unprovided query fixtures fail explicitly and subscriptions are unavailable.
SDK actions stay simulated, so `--scenario` cannot be combined with `--live-actions`. Trusted browser code and dependency
compilation keep their normal privileges. See [synthetic preview scenarios](../../management/custom-widgets/trusted-packages.mdx#synthetic-preview-scenarios).
### Publish the exact offline archive
Set `HOMARR_WORKSHOP_TOKEN` to an authenticated Workshop token, then publish the archive you reviewed:
```sh
homarr widgets publish my-clock/dist/widget.homarr-widget.json
homarr widgets publish next-release.homarr-widget.json --submission-id LISTING_ID --expected-revision 1
```
Use `--url` (or `WORKSHOP_API_URL`) for your own Workshop service, `--token-env` for another token variable,
`--changelog` for release notes, and `--forked-from RELEASE_ID` to attribute a new fork. Existing listings require their
current revision and a new package version. Forks need their own package identity. Published releases are immutable;
publication sends the archive without rebuilding it. You can also import the archive into Homarr and publish through
the package workspace.
### Run a package on another machine
Run an already packed archive on another machine with the optional widget runner:
```sh
export HOMARR_WIDGET_RUNNER_TOKEN="$(openssl rand -hex 32)"
homarr widgets serve widget.homarr-widget.json --trust --host 127.0.0.1 --port 8787
```
`serve` executes the installed package with that machine's operating system privileges. It never downloads packages
or accepts source uploads. Set a custom token variable with `--token-env VARIABLE_NAME`. The token must have at least
32 characters. Interrupting the command stops its worker and subscriptions. See [remote widget runners](../../management/custom-widgets/remote-runners.mdx)
for connection setup, local files, SQL, MQTT, and browser-service adapters.
### Share several widgets as a collection
A collection contains independent compiled widget packages and a shared connection blueprint. Write a collection
manifest with paths to already packed archives:
```json
{
"manifest": { "id": "example.home", "version": "1.0.0", "name": "Home dashboard" },
"connections": { "monitor": { "label": "Monitoring server", "kind": "integration" } },
"widgets": [
{ "key": "clock", "archive": "clock/dist/widget.homarr-widget.json" },
{
"key": "servers",
"archive": "beszel/dist/widget.homarr-widget.json",
"connections": { "beszel": "monitor" }
}
]
}
```
Pack and verify it without compiling or executing the member packages:
```sh
homarr widgets collection pack collection.json --output home.homarr-collection.json
homarr widgets collection validate home.homarr-collection.json
homarr widgets collection import home.homarr-collection.json --url http://homarr.local:7575
```
`import` uses `HOMARR_API_KEY`, or the variable selected with `--api-key-env`. Pass `--bindings bindings.json` to map
each collection slot to an existing local Homarr connection once; all members that reference that slot receive the
binding. Omitted connections can be configured later. Imports create every member as a disabled draft in one database
transaction and print their review links. Trust and activation are separate, explicit steps.
Collections support 1–32 members and up to 128 MiB. Package identities, compiled artifacts, dependency locks and
documented Workshop/fork attribution travel with the archive. The collection and each nested artifact have integrity
checksums. Local connection URLs, IDs and credentials, board placements, stored widget data and local configuration
are excluded. Authored `options` on a member are portable defaults: include only values you intend to share. Package
source is included verbatim, so embedded literals are still part of the archive.
## Build a local branch harness
The repository's host-side CLI can build the current checkout and run it as a named Docker harness:
@@ -82,3 +82,25 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
- `pnpm build` — production monorepo build.
- `docker build -t homarr .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
## CI and focused validation
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
Choose the smallest relevant suite when changing behavior:
| Command | Scope | Requirements |
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording.
@@ -140,3 +140,14 @@ The advanced deployments environment variables should only be used if you know w
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts.
## Trusted widget packages
| Variable | Purpose | Default |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `CUSTOM_WIDGET_PACKAGE_PATH` | Persistent build and runtime artifact cache. Activated browser/server artifacts and their source are also retained in the database. | `/appdata/custom-widgets` in production; `./appdata/custom-widgets` in development |
| `CUSTOM_WIDGET_MAX_SERVER_PROCESSES` | Maximum active trusted widget server artifacts per Homarr process, from 1 to 128. Source-free packages do not start a server process. | `16` |
Each replica materializes an authorized installed artifact from the database into its own cache. Dashboard requests do
not install npm dependencies. Package dependencies are resolved and bundled when an owner previews or activates a new
source draft; importing a complete compiled archive supports offline activation. See [trusted widget packages](/docs/management/custom-widgets/trusted-packages).
+22 -8
View File
@@ -1,5 +1,5 @@
---
title: Upgrade from v1 to v2
title: Try the v2 beta
sidebar_position: 2
tags:
- Getting started
@@ -8,8 +8,20 @@ tags:
- Backup
---
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
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
@@ -18,21 +30,23 @@ before starting v2. See [Backup and restore](/docs/management/backup) for detail
:::
## Upgrade
## Try the beta with existing v1 data
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
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. After validation, keep the version pinned or return to your normal stable release policy.
5. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
For Docker Compose, the change is limited to the image tag before the usual update:
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:v2.0.0
image: ghcr.io/homarr-labs/homarr-test:v2
environment:
WORKSHOP_API_URL: https://v2.preview.homarr.dev
```
```sh
@@ -0,0 +1,55 @@
---
title: "llama.cpp"
description: "Connect Homarr to a llama.cpp llama-server to monitor your local LLM: health, loaded model, generation speed and request status."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { llamacppIntegration } from ".";
import { llamacppWidget } from "@site/docs/widgets/llama-cpp";
<IntegrationHeader integration={llamacppIntegration} categories={["System Monitoring"]} />
The llama.cpp integration lets Homarr connect to a [llama-server](https://github.com/ggml-org/llama.cpp) instance so you can see the health of your local LLM, which model is loaded, and how fast it is generating — all from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: llamacppWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your `llama-server`, for example `http://192.168.1.50:8080`. Homarr calls the `/health`, `/v1/models`, `/metrics` and `/slots` endpoints under that URL automatically.
:::
:::tip Metrics
Generation speed and token counters are read from the Prometheus endpoint at `/metrics`. Start `llama-server` with the `--metrics` flag so these values are available:
```shell
llama-server --model /path/to/model.gguf --metrics
```
Without `--metrics` the widget still shows health, model information, and active-request speed from `/slots`; metrics-backed counters and aggregate speed stay empty.
:::
:::info Context and per-request speed
Context (KV cache) usage and the in-flight request data are read from the `/slots` endpoint, which is available on every recent llama-server build and requires no extra flags. The per-request generation speed shown while a request is running is derived by tracking the request's decoded token count across polls, so it reflects the average speed of the request that is currently being generated.
:::
### Secrets
llama-server does not require authentication by default. The integration only needs the URL of your server.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const llamacppIntegration: IntegrationDefinition = {
name: "llama.cpp",
description:
"Connect to a local llama.cpp llama-server to show model, throughput and request status on your dashboard.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
path: "../../integrations/llama-cpp",
};
@@ -32,3 +32,10 @@ import { calendarWidget } from "@site/docs/widgets/calendar";
},
]}
/>
### Trusted widget packages
The [Sonarr operations reference package](/docs/management/custom-widgets/trusted-packages) can combine two Sonarr
integrations, display upcoming and missing episodes and queue progress, and submit a confirmed search for a missing
episode. Search actions use the existing integration API key on Homarr's server and require interaction access.
A successful command submission means Sonarr queued the search, not that it found or downloaded a release.
+44
View File
@@ -27,6 +27,12 @@ session cookie.
API keys do not expire automatically and act as the user that created them. Every request uses that user's group and
resource permissions.
## Invitation dates
`POST /api/invites` and the tRPC `invite.createInvite` procedure accept `expirationDate` as an ISO 8601 timestamp with a
timezone, for example `2026-12-01T18:00:00Z`. Homarr validates the timestamp and stores it as a date. tRPC callers should
send the ISO string rather than a JavaScript `Date` object.
## Health probes
The liveness endpoint does not require authentication:
@@ -92,3 +98,41 @@ and focused references under `/api/custom-widgets/`.
Use [Custom Widget agent authoring](../custom-widgets/agent-authoring) for the lifecycle and
[MCP](../mcp) when the client supports Model Context Protocol.
## Trusted widget package lifecycle
The `customWidget.package` tRPC namespace separates authoring from activation. Administrators can save incomplete
source with `saveDraft`, inspect `draftChanges`, run `preview`, and call `activate` after acknowledging trusted code.
`getRaw` and `repair` on `customWidget` recover existing v2 definitions without rendering their code.
The package API also manages named local connections, per-placement guest grants, retained collectors, immutable
archive import/export, forks, Workshop releases, staged updates, and rollback. `get` and `list` return management data
without evaluating submitted package modules. Connection secret values are accepted separately by `saveConnection`
and are not returned by connection listing or package export.
For an existing connection, `secretUpdates` patches only supplied credential keys while retaining the others;
`secrets` replaces the complete credential map. Supply only one. Credential patches require the saved connection
kind and authentication method to match the submitted configuration.
`previewStorageGet` and `previewStorageSet` use the owning administrator's preview session and the same temporary
store as its server handlers. They accept `previewId`, `scope`, and `key`; writes also accept `value`.
Expired, discarded, and other users' sessions cannot read or write preview storage.
Connection configuration accepts `kind: "http" | "integration" | "service"`. A service connection supplies a
`serviceType` and a JSON `settings` object without requiring a URL; encrypted `secrets` remain a separate input.
Package requirements match both `kind` and `serviceType`. Server handlers access these settings through
`getWidgetConnection` in `@homarr/widget-sdk/server`. Service diagnostics distinguish file readability, TCP
reachability and settings-only checks; preview runs the adapter's actual database or protocol operation.
Installed widgets use `widget.customApi.packageQuery`, `packageAction`, `packageSubscription`, `packageStorageGet`,
and `packageStorageSet`. Each invocation is bound to an existing board item and a manifest-declared handler; the host
checks the current board and integration permissions. A public board does not grant action access automatically.
Guest grants name a handler and exact allowed inputs, and are invalidated by changes to server behavior, dependencies,
connection identity, or resource-selecting options.
Compiled surfaces are served through `/api/custom-widgets/artifacts/{itemId}/{digest}/{surface}` after board access
checks. The optional `?asset=css` selects the corresponding stylesheet. Preview surfaces use
`/api/custom-widgets/previews/{previewId}/{surface}` and require the administrator who created the preview.
These routes do not expose server source or connection credentials.
The full package format, SDK examples, trust boundary, and activation behavior are described in
[trusted widget packages](../custom-widgets/trusted-packages).
+14
View File
@@ -38,3 +38,17 @@ A backup can be restored on another Homarr instance. When its `SECRET_ENCRYPTION
integration secrets for the target instance. Version metadata is used to apply compatible migrations.
The same restore flow is available during first-run onboarding, before the target database contains user data.
## Custom widget packages
Database backups include installed v2 definitions and trusted v3 source, compiled artifacts, current and previous
activation metadata, local connections, placement bindings, guest grants, and managed widget storage/history.
Restoring an encrypted backup re-encrypts widget credentials with the destination instance's key alongside native
integration credentials. Compiled artifacts can be materialized again from the restored database without Workshop.
Keep `SECRET_ENCRYPTION_KEY` with database backups made using MySQL or PostgreSQL administration tools. The built-in
ZIP export/restore remains specific to SQLite. A package export is portable distribution material and deliberately
excludes local connections, credentials, stored values, and collected history; it is not a complete instance backup.
Files created directly by trusted Node code, external databases, remote-runner state, and changes already made to
other applications are outside Homarr's managed widget storage. Include those resources in your own backup process.
@@ -36,6 +36,11 @@ current Base layout.
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
border color, and CSS classes.
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
will remain when released.
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
previewed positions without a movement animation and stay there when you release the pointer.
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
scrolls and store their contents separately for each layout.
@@ -5,15 +5,27 @@ JSON is available when direct editing is faster.
## Workflow
1. Configure each API source, network scope, authentication method, and credential.
2. Add named queries and actions. Values can reference widget options or invocation parameters.
1. Start with a static widget, or add API sources, network access, authentication, and credentials when needed.
2. Add named queries and actions for connected services. Values can reference widget options or invocation parameters.
3. Define widget options and their defaults.
4. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
5. Run **Test and preview**, inspect the response data and redacted request journal, then save.
Changing the definition, options, or credentials invalidates the previous preview. Preview actions are simulated unless
Changing the definition, options, or credentials ends the previous API preview session. The last response remains visible
as a labelled snapshot while you edit JSX. Run **Test and preview** again to refresh data and enable API interactions.
Preview actions are simulated unless
**Run actions against the configured API** is enabled for that preview session.
Use the workbench **Undo** and **Redo** buttons or `Ctrl/Cmd+Z` and `Ctrl/Cmd+Shift+Z` for changes across JSX, sources,
requests, options, and metadata. History is bounded to 100 entries and about two million characters. **Reset to saved**
restores the last saved definition. Credentials are excluded from this history and reset clears unsaved credential values.
Undo and Redo retain pending credentials only when the source origin, network scope, and authentication destination
are unchanged. Otherwise, enter the credentials again for the restored source.
Homarr keeps one recovery draft per signed-in user in this browser, replacing the previous draft as you edit. On returning
to that widget, choose **Restore draft** or **Discard draft**. Saved credentials stay on the server; unsaved credentials must
be entered again. Browser storage is optional, and drafts larger than one million characters are not stored for recovery.
Widget options support text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, URL,
duration, time zone, and JSON controls. Dynamic choices can come from a load query.
@@ -6,8 +6,14 @@ Templates are JSX expressions interpreted inside a widget-local safety and rende
- `status.requestId` — `{ loading, ok, status, statusText, error }`
- `options.name` — saved widget configuration
- `inputs.name` — temporary values from declarative `bind`
- `host` — the widget's board context, dimensions, scale, and compact or advanced display mode
The v2 manifest requires a `default` source and supports up to eight sources, 64 requests, and 64 options. Source IDs are
Existing callbacks may still use `host` as their parameter name, such as `data.hosts.map(host => host.name)`.
Inside that callback, `host` refers to the current item; outside it, `host` refers to the widget context.
The v2 manifest supports empty `sources` and `requests` for widgets that do not need an API. Connected widgets support
up to eight sources, 64 requests, and 64 options. Each request must reference a defined source; omitting `source` uses
`default` only when a source with that ID exists. Source IDs are
case-insensitively unique. Each request allows up to 32 static headers and 32 invalidation targets; a widget can contain
up to 24 secrets, each up to 8,192 characters, and templates are limited to 50,000 characters. Validation reports the
exact field when a limit is exceeded.
@@ -15,7 +15,10 @@ Custom Widgets may change during the v2 release cycle. Share feedback in the
:::
A **Custom Widget** is a validated `homarr-custom-widget-v2` definition containing API sources, queries and actions,
Custom Widgets supports [trusted widget packages](./trusted-packages) with full TSX, CSS, modules, optional Node
handlers, and advanced views, alongside the restricted v2 authoring format. Both can work without an API.
A **v2 Custom Widget** is a validated `homarr-custom-widget-v2` definition containing optional API sources, queries and actions,
optional settings, and a safe JSX template.
Homarr executes requests on the server and renders the template in a restricted runtime. Source credentials are
@@ -37,6 +40,8 @@ boards; actions require authentication and the permission declared by the reques
## Authoring
- [Build a trusted widget package](./trusted-packages), configure local connections, and manage its Workshop releases.
- [Create a Custom Widget](./authoring) in the workbench or from a copied AI prompt.
- [Use the safe JSX runtime](./custom-jsx) for data bindings, temporary inputs, and components.
- [Browse the component reference](./component-reference).
@@ -0,0 +1,104 @@
---
title: Remote widget runners
sidebar_position: 8
---
A trusted widget server normally runs beside Homarr. The optional runner executes one installed widget package on
another machine. Use it when the data lives on a NAS filesystem, a separate Docker network, or a host with a database
or browser service that Homarr cannot reach. The runner has the privileges of the account starting it; its child
process provides cancellation and restart, not security isolation.
## Install a runner
Build an archive using `homarr widgets pack widget.json --trust`, copy the archive to the target machine, and run:
```sh
export HOMARR_WIDGET_RUNNER_TOKEN="$(openssl rand -hex 32)"
homarr widgets serve widget.homarr-widget.json --trust --host 127.0.0.1 --port 8787
```
The runner uses the compiled artifact and bundled JavaScript dependencies. It does not contact an npm registry or
accept package uploads. Restart it with a different archive to update its installed version. Keep the token in the
runner environment and Homarr's encrypted connection store. Use a private interface or an HTTPS reverse proxy when
Homarr runs on another machine. `127.0.0.1` identifies the machine or container running the runner.
In Homarr, create an HTTP connection with the runner's base URL and bearer token. Bind it to the package's named
`runner` connection. Only declared query, action, and subscription handlers are remotely callable. Migration
handlers remain local lifecycle operations. Homarr still authorizes the dashboard placement and its named handler
before calling the runner; the runner token represents the trusted Homarr caller.
## Call a remote handler
The local package's `server.ts` can forward to a handler installed on the runner:
```ts
import { callWidgetRunner, defineWidgetServer, subscribeWidgetRunner } from "@homarr/widget-sdk/server";
export default defineWidgetServer({
status: (input, context) => callWidgetRunner(context, { connection: "runner", handler: "status", input }),
activity: async function* (input, context) {
yield* subscribeWidgetRunner(context, { connection: "runner", handler: "activity", input });
},
});
```
Declare `status` as a query and `activity` as a subscription in both manifests. The remote package accesses its own
environment and filesystem. It has no automatic backchannel to Homarr's storage, native integrations, or connection
credentials. Put those operations in the local forwarding handler when needed.
The pull subscription transport waits up to ten seconds per read, buffers at most 32 values or 8 MiB, and closes
subscriptions that are not read for sixty seconds. Returning from an async generator completes the stream. Use
`context.signal` to release connections and stop work on cancellation.
## Apps without an HTTP API
An API request is optional. A widget may use only JSX, local browser state, a timer, or static information. Trusted
server handlers can read files, speak other protocols, or query a database using the runner's location and account.
For an application that writes a JSON status file, a remote handler can read that file:
```ts
import { readFile } from "node:fs/promises";
import { defineWidgetServer } from "@homarr/widget-sdk/server";
export default defineWidgetServer({
status: async (_input, context) => {
const path = process.env.APP_STATUS_FILE;
if (!path) throw new Error("Set APP_STATUS_FILE on the runner");
return JSON.parse(await readFile(path, { encoding: "utf8", signal: context.signal }));
},
});
```
For an application's SQLite database, Node 24 provides a built-in driver:
```ts
import { DatabaseSync } from "node:sqlite";
import { defineWidgetServer } from "@homarr/widget-sdk/server";
export default defineWidgetServer({
recentJobs: () => {
const path = process.env.APP_DATABASE;
if (!path) throw new Error("Set APP_DATABASE on the runner");
const database = new DatabaseSync(path, { readOnly: true });
try {
return database.prepare("SELECT name, status FROM jobs ORDER BY id DESC LIMIT 20").all();
} finally {
database.close();
}
},
});
```
Adapt the query to the application's actual schema. For PostgreSQL, MySQL, or MQTT, declare an exact version of a
JavaScript client dependency, bundle it when packing, and use the runner environment for service credentials. MQTT
subscriptions fit async generator handlers; close the broker client when the handler's signal aborts. Persistent
history belongs in Homarr's collection and history APIs or the application's database.
A browser-only application can be adapted through an existing browser automation service reachable from the runner.
Call that service from a server handler and return structured data to the tile. Browser binaries and native addons
are not shipped inside portable widget archives. Directly bundling a library that requires native addon loading is
rejected; use an independently installed service for that runtime dependency.
These paths enable adapters for many self-hosted applications. The application must still expose something the
runner can read or control, such as HTTP, a socket, a file, a database, or a browser session.
@@ -1,15 +1,21 @@
# Configure requests and security
Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets
Widgets can use empty `sources` and `requests` objects when they do not need an API. Connected widgets may use a source
named `default` or explicitly name the source on every request. 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. 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.
Use `private` for LAN addresses, Docker service names, and Tailscale's `100.64.0.0/10` range. Use `loopback` for
`127.0.0.1` or `::1`. Requests run from Homarr: in Docker, loopback addresses point to the Homarr container, while a
service name such as `http://sonarr:8989` must resolve on its Docker network. Public sources cannot reach private or
loopback addresses. Cloud metadata, link-local, multicast, and other reserved targets remain blocked.
## 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:
Requests are keyed by ID. With a source named `default`, a load query can be as small as:
```json
{ "status": { "path": "/api/status" } }
@@ -0,0 +1,426 @@
---
title: Trusted widget packages
sidebar_position: 2
tags: [Custom Widgets, JSX, Workshop]
---
# Trusted widget packages
Trusted packages let administrators write complete React/TSX modules, import CSS and dependencies, and optionally run
Node server handlers. A package can provide a dashboard tile, an advanced widget view, and a custom configuration
view. Static content, clocks, interactive local state, and other source-free widgets need no API connection.
Open **Management → Custom Widgets → Packages**. The source-free world clock is the default starter. Thirteen reference starters cover clocks, timers, checklists,
downloads, Sonarr, racks, backups, household controls, service overview, media, Beszel, SQLite, and MQTT. Each new widget receives
its own local package identity. The Beszel
reference package demonstrates a connected widget using Homarr's shared SDK and integration data.
## Trust and execution
Packages are trusted installed code. Browser modules run within Homarr; Node handlers have Homarr's server
privileges. Review the complete source and dependency list before checking the trust acknowledgement. Both
**Run preview** and **Activate saved draft** execute package code. An action simulation is not a security sandbox:
trusted code can make direct network requests and external changes outside the SDK.
Workshop browsing displays screenshots, source text, dependencies, and immutable release metadata. It does not run
package code. Importing a package creates a disabled draft. Connection credentials are entered separately on your
Homarr installation.
A supplied archive can include compiled browser and server code. Its digest verifies that those bytes are intact;
it does not prove they were built from the displayed source. Inspect **Compiled artifact** and the dependency lock
as well as the source before activating a supplied build. Rebuilding from reviewed source produces a new local
artifact; dependency installation happens during that build, never while rendering a dashboard.
## Package, installation, and placement
| Concept | Contains |
| ------------ | ------------------------------------------------------------------------------------------------------------- |
| Package | Portable manifest, source files, exact dependency versions, named connection requirements, option definitions |
| Installation | Saved source draft, active compiled release, previous activation, local connection bindings, enabled state |
| Placement | A tile on a board, its option values, connection overrides, board permissions, widget state |
One installation can appear on several boards. Activating it updates all its placements. A placement's saved option
values survive updates; changing package defaults does not overwrite those saved values.
## Customize a native widget
Administrators can choose **Customize as a widget package** from supported native widget menus. Homarr prepares a
reference package and reuses its existing integration connections. The original native widget remains on the board.
Save the new package, preview and activate it, then add its placement to the board. The package has its own identity
and can be edited independently of the native widget.
## Author a package
The workspace has a file explorer, TSX/CSS/JSON editor, a connection panel, activation and board controls, and a
preview canvas with adjustable width and height. Use `widget.json` to edit package metadata. This is a virtual view
of the package envelope excluding `files`; each source file is edited separately in the explorer.
Use **Add**, **Rename**, and **Remove** to organize source files. When renaming a file, update its imports and any
manifest entrypoints that reference the old path. JSX imports and ordinary React hooks are supported by the trusted
package compiler; the restricted v2 interpreter does not process these files.
The package envelope uses `$schema: "homarr-widget-package-v3"` and contains:
| Field | Purpose |
| -------------- | ------------------------------------------------------------------------------------ |
| `manifest` | Package identity, release version, SDK version, entrypoints, handlers, and metadata |
| `files` | Relative file paths mapped to source text |
| `dependencies` | Additional npm dependencies pinned to exact versions |
| `connections` | Named HTTP, Homarr integration, or service requirements; local bindings are separate |
| `options` | Controls, labels, defaults, and constraints for placement settings |
`manifest.entrypoints.tile` is required. `advanced`, `configuration`, and `server` are optional. Entrypoints must
refer to files in the package. A package with named server handlers needs a server entrypoint. The `manifest.id`
identifies the package across releases; `manifest.version` uses a complete semantic version such as `1.0.0`.
Homarr provides React, Mantine, and `@homarr/widget-sdk` as shared host modules. Referenced Tabler icons are bundled
from Homarr's installed version, which is recorded in the artifact lock. The reference packages show how to use the SDK for host dimensions, theme and locale, options, state, commands, data queries, and advanced
views. Put additional dependencies in the package's dependency map rather than copying Homarr's internal source tree.
Production images include the widget compiler's JavaScript, native esbuild executable, and bundled icon sources.
When customizing Homarr's standalone build, retain these runtime assets and scope tracing includes to their files;
whole-package recursive globs can fail on pnpm dependency directory symlinks.
Use `WidgetScope` to compose components with independent state and command IDs. Commands registered through
`useWidgetCommands` remain registered across unrelated host updates and are removed when their component unmounts.
`useWidgetServices().confirm(...)` resolves to `true` on confirmation and `false` on cancellation or dismissal,
including Escape, a backdrop click, or the close button.
## Convert an existing Custom JSX widget
Choose **Convert to a widget package** from a valid Custom JSX widget's list row or editor. Conversion uses its saved
definition and creates a separate disabled package. The original definition and its board placements remain in
place. Local connections and encrypted credentials are copied on the server, keeping them out of portable source.
The initial package retains a compatible JSX surface and options. Converted dynamic options remain available through
the authored configuration surface. Extend the package with TSX modules, CSS, or an advanced
view in the workspace. Review the generated files, preview, and explicitly activate the package before adding it
to a board. Conversion itself does not execute or replace the original widget. After activation, **Replace an
existing Custom JSX tile** lets you choose one original placement and explicitly replace it. Position, size, refresh
interval and configured values are retained; the original definition and other placements stay available. Use
**Add to board** when you want a separate tile instead.
## Assistant authoring
Choose **Ask Assistant** in the workspace to describe a widget or request changes. Shortcuts help improve the
current widget, add an advanced view, or fix current errors. This opens Homarr's existing Assistant; it uses the
visible unsaved draft, public SDK guidance, reference packages, and source validation diagnostics.
The Assistant reads source on demand. Local connection settings, bindings, credential fields, preview option
values, and runtime response data are excluded. Recognized credential literals are redacted from source context;
keep secrets in local connections. Files with redacted credentials must be edited directly before the Assistant can
replace them. Files omitted because a response reached its context budget must be read separately before proposing changes.
Individual files over 180,000 characters and manifests over 60,000 characters require direct editing.
Proposals display each changed file before and after in the Assistant. **Apply to draft** applies the complete
proposal as one Undo step. If the draft changes while a proposal is waiting, the Assistant must read it again and
propose against the new revision. Applying a proposal does not save, trust, preview, activate, or publish it.
Preview evidence identifies only the exact current candidate that compiled and received a preview session.
Compilation does not prove successful browser rendering or live actions. Source, option, and binding changes
invalidate that evidence; use the preview canvas to verify the final result before activation.
## Save, preview, and activate
Drag the divider between source and preview to resize the workspace. The divider also supports arrow keys,
Home/End, and double-click to reset. The preference is saved in your browser. On smaller screens, switch between
Code and Preview without unmounting the working preview.
The preview offers compact, wide, phone, and tablet viewport presets, independent width/height controls, and a
25–200% display scale. Scaling changes both the visual surface and the SDK's visible dimensions. Light and dark
preview modes stay local to the preview; they do not change your Homarr theme. These presentation controls do not
rebuild code, clear query data, or require another preview session.
The source editor understands the package's TypeScript and TSX files together. Press **Ctrl+Space** for typed
completions, hover a symbol for its type and documentation, or press **F12** to open its definition. Definitions
inside the installed SDK or its dependencies open as read-only excerpts. **F2** renames a symbol across package
files; the complete rename is one Undo operation. **Format** uses TypeScript formatting for TS/TSX files.
Semantic diagnostics appear in the editor and its expandable issue list. They use declarations generated from this
Homarr installation's actual SDK, React, Mantine, and Node dependencies. Include `.d.ts` files when another package
needs declarations that are not installed with Homarr. These editor diagnostics do not block saving a draft or
replace the preview compiler's runtime compatibility checks. Analysis runs in a local browser worker, without
executing your source or downloading code or types from a CDN.
For Homarr development, `pnpm dev` and the Next.js build regenerate the worker and installed declarations. After
editing SDK source while the dev server is already running, run `pnpm --filter @homarr/custom-widgets generate:editor`
and reopen the workspace. Generated assets are included in the standalone image's public directory.
1. **Save draft** persists the current source without building or executing it. Incomplete TSX and metadata JSON can
be saved for later repair. Saving a draft leaves the active dashboard release running.
2. Review the code and dependencies, acknowledge trust, and choose **Run preview**. The preview builds the current
editor contents and runs them in a temporary session. Select tile, advanced, or configuration view when provided.
**Preview options** controls the configuration used for this session.
3. Save your final changes, review the activation's placement count, then choose **Activate saved draft**. The server
validates and builds the saved source before switching the installation. Activation enables the installation.
4. Choose a board and configure the new placement under **Activation & boards → Add to board**.
Changing source, options, local preview bindings, or the live-action mode invalidates execution evidence for the previous candidate.
The last successful view remains visible with a stale label and paused SDK activity while you edit or fix a failed
build. Its session is released after a replacement loads successfully or the workspace closes. Re-run preview to
inspect the changed inputs. Pausing SDK activity does not sandbox arbitrary effects inside trusted code. Preview widget storage is temporary:
frontend storage hooks and server handlers share the same preview-session values, isolated from dashboard storage
and other preview sessions. Completing an action refreshes queries and managed storage in that preview.
SDK named actions are simulated by default. Enable **Allow live SDK actions for this preview session** and run
preview again to operate the real service through SDK actions. The session label shows its mode. Changing source,
bindings, or connection settings turns this option off. Direct behavior inside trusted code remains the owner's responsibility.
## Synthetic preview scenarios
Authors can include `previewScenarios` in package source to review success, loading and partial failures with
synthetic data. A scenario maps declared query handler names to JSON results or errors:
```json
{
"previewScenarios": {
"partialFailure": {
"label": "Storage unavailable",
"queries": {
"memory": { "data": { "usedPercent": 38 } },
"storage": {
"error": { "message": "Synthetic storage collector timeout", "code": "TIMEOUT" },
"delayMs": 700
}
}
}
}
}
```
Select a scenario when creating a preview. Its ID and the exact compiled source are stored in that preview session;
changing the scenario requires a new preview. Required local connections can remain unbound. Fixture queries return
the authored JSON without starting the package's server module or calling a configured service. A query without a
fixture fails explicitly; it never falls back to live data. Each handler receives the same fixture regardless of its
input. Declared input validation still applies.
SDK actions are simulated in fixture mode, and enabling live actions together with a scenario is rejected.
Subscriptions are unavailable in this bounded query-fixture mode. The SDK capability helper remains available so
action controls can display their simulated state. Production dashboard queries always execute the active package's
normal handlers; a preview scenario never changes an installation or its placements.
[Download the complete example](/custom-widgets/preview-scenarios.json) for two independent local metrics with ready,
delayed and partial-failure scenarios. It needs no API or local connection. Import the source, review trust, and
preview its named scenarios. Its ordinary handlers read local memory and filesystem metrics when used without a scenario.
Scenarios support up to 16 names, 64 query fixtures each, 2 MiB of total fixture JSON, and delays of up to 30 seconds.
Errors can use `BAD_REQUEST`, `NOT_FOUND`, `TIMEOUT` or `INTERNAL_SERVER_ERROR`; the latter is the default. Request
cancellation and discarding the session cancel pending fixture delays.
Fixtures are explicitly authored package source and are included in source exports, offline archives and Workshop
releases. Homarr does not capture live responses into scenarios. Use invented values and review fixture contents
before sharing. This is SDK simulation, not a security sandbox: trusted browser code can still make direct requests,
and compiling uncached dependencies can require registry access. Review source and acknowledge trust as usual.
## Local connections
The **Unified downloads** starter accepts a primary and optional second download integration. Each source has its
own queue, loading state, error and retry control, so a failed client leaves the other queue usable. Advanced view
adds filtering, sorting, per-client and per-item controls, and pause/resume for all or selected clients. Batch
results identify successful and failed clients individually. Per-item actions resolve the current item from the
selected client, and confirmed removal keeps downloaded files. The composed overview reuses the same modules.
Controls consult the current viewer's capabilities. Guests need an explicit grant for an exact input such as
`{"connection":"secondary","operation":"pause"}` on `control`; batch grants additionally list every target in
`connections`. No guest action is enabled merely by installing the starter.
Choose **Service** for MQTT, SQLite, SSH, local files or another protocol. Service connections require a
`serviceType` matching the package requirement, arbitrary nonsecret JSON settings, and optional encrypted
credentials. They do not require an HTTP URL. For example, declare
`{kind: "service", serviceType: "sqlite", label: "Database"}` in a package and bind a local connection with
settings such as `{"path":"/data/app.sqlite"}`. MQTT commonly uses `{"url":"mqtt://mosquitto:1883","topics":["sensors/#"]}`;
put its username and password in the encrypted credentials field. Leave that field blank to keep existing
credentials, or select **Replace saved credentials** to clear them explicitly.
Server handlers call `getWidgetConnection(context, {connection: "database", serviceType: "sqlite"})` from
`@homarr/widget-sdk/server` to obtain the local settings and secrets for a Node library. These credentials are
never returned by the browser connection API. Authors must keep this object server-side and return only the
data their widget needs. File paths resolve inside Homarr's container/process; mount the required files there
and give the Homarr process access. Use a remote runner when the adapter must execute on another machine.
Diagnostics report what they actually verify: SQLite/file connections check regular-file readability, and MQTT
connections check TCP reachability. Neither check claims database-query validity or MQTT authentication. Run
preview to exercise the real query or authenticated subscription. Other service types report configured settings
and require their adapter's preview for a protocol check. The **SQLite and local status** and **MQTT live topics**
starters include complete adapters; MQTT pins its reviewed `mqtt` dependency, while SQLite uses Node's built-in
module. Their package READMEs document connection settings, limits, cancellation and deployment details.
Declare named requirements in the package and bind them to reusable local connections in **Connections**. A
connection can point to an existing Homarr integration or an HTTP endpoint. HTTP setup supports a separate server
and browser URL, Basic authentication, bearer tokens, secret headers or query parameters, cookies, TLS verification, and a timeout.
Server requests originate from Homarr's server or container. Examples include `http://sonarr:8989`,
`http://192.168.1.20:8080`, and a Tailscale address. `127.0.0.1` addresses the network namespace running Homarr; it does
not address the dashboard viewer's browser or a different container. Enter full URLs including the protocol. For a bare address such as `sonarr:7573`, the connection form offers an
explicit **Use http://sonarr:7573** suggestion. Choosing it adds the protocol while preserving the supplied host,
port, and path; use HTTPS when required by the service.
**Diagnose** shows the reached stage, resolved addresses, HTTP status and timing, and top-level JSON field names
without response values. The primary result distinguishes name resolution, network, TLS, HTTP and authentication
failures; expand **Technical details** for the full diagnostic. **Save installation bindings** changes the defaults inherited by every placement.
Individual placements can override connections in their board configuration. **Edit connection** updates a reusable
connection for every widget that uses it. Empty credential fields retain stored credentials; changing authentication
requires entering the credentials for the new method.
For Basic authentication, you can update the username or password independently: leaving the other field blank
preserves its saved value. Updating a bearer token, cookie, username, or password also preserves a saved TLS private key.
Connection credentials are encrypted in Homarr's database. Connection forms and their credential fields are not
included in the package editor's Undo/Redo or browser recovery. Keep credentials out of source files: source files
are portable and can be exported or published.
## Recovery and operational rollback
Disabling an enabled guest action does not depend on the validity of its edited input JSON. You can revoke access
immediately from the action's Disable control while repairing those inputs.
Activation also coordinates with widget settings, board saves, additions, and board duplication. If a package
changes while one of these saves is waiting, Homarr asks you to reload before saving again so an older
configuration cannot overwrite its migration. Changing a placement's connection overrides invalidates guest
action approvals tied to the previous bindings.
**Undo/Redo** covers the whole source document: metadata, files, and installation name. History is bounded to 100
entries and 2 MB in the current editor session. **Reset to saved** restores the saved draft. Homarr keeps one browser
recovery draft per signed-in owner, up to 1 MB, and offers to restore it when opening the matching package.
Recognized credential literals are redacted from recovery source; use connection fields for secrets.
Package release history is separate from editor Undo/Redo. **Roll back previous activation** restores the previous
compiled release and its activation snapshot of local bindings, placement options, and widget data. You must
acknowledge discarding newer widget data. Calls already made to an external service cannot be reversed by rollback.
Your source draft remains available for further work.
**Disable installation** stops that installation across all placements. It is independent of saving a source draft.
## Import, export, fork, and update
**Import package** accepts source JSON or a `homarr-widget-archive-v3` archive. Archives contain source plus a compiled
artifact; compatible matching artifacts can be activated without rebuilding dependencies. Imports create disabled
drafts for review. **Export source draft** exports current editor contents, including incomplete work.
**Export active archive** exports the activated release and its compiled artifact. Local connection credentials and
board placements are not part of these exports.
**Stage archive update** replaces the installation's saved draft after explicit acknowledgement and keeps its active
release running. **Review saved draft changes** shows changed files before and after, dependency changes, connection
requirements, and configuration changes. **Fork as a new package** copies saved source to a new disabled installation
with a new package identity; it does not change the original installation's board placements.
## Portable widget collections
A `homarr-widget-collection-v1` archive groups several independent packages with named shared connection slots.
Import binds a slot once and fans it out to every member that needs it. All imported packages start as disabled
drafts; importing does not install dependencies or execute widget code. Each package keeps its own identity,
activation, updates, settings and dashboard placements.
The complete collection is validated before creating installations. Invalid member code checksums, mismatched
connection types or invalid portable option defaults reject the import with no new installations. Installation
records are committed together; immutable artifact cache entries from a database failure may remain until cleanup.
Export uses the selected installations' activated archives. Shared local bindings become named requirements, while
their connection IDs, URLs and credentials stay local. Export also excludes placement settings and widget storage.
Only explicit authored defaults belong in the portable blueprint. Workshop and fork attribution is retained, and
normal package updates retain the collection's membership and connection mapping. Review code literals before
sharing, since package source is exported verbatim.
From the installed package library, choose **Collections** to export selected active packages or resume an imported
collection. Import its JSON archive using **Import**. The setup page asks for shared connections once, lets you
choose widgets and their options, and activates the selected packages after you acknowledge trust. **Activate and
add missing widgets** places them on the selected board. Each member reports its own result, and retrying skips
placements already present on that board. You can stop after the current member finishes.
See the [collection CLI commands](../../advanced/command-line/index.mdx#share-several-widgets-as-a-collection) for
packing, validation and importing through an admin API key. Collections support up to 32 widgets and 128 MiB.
## Workshop lifecycle
In Workshop, select a release, inspect source files and dependencies, and optionally bind its named connections.
**Import for review** opens the package workspace with a disabled local installation. Preview and activate it after
reviewing trust, then add it to a board.
The **Packages** library shows each installation’s version, author, and **View on Workshop** link. Choose
**Check for updates**, then **Review update** to load a release into the editor. If your saved draft has local
changes, confirm its replacement first. The active release continues running until you activate the reviewed draft.
Use **Duplicate** or export local changes you want to keep before replacing them.
Choose **Publish to Workshop** from the package’s action menu or its editor. Packages and Custom JSX widgets use the
same publishing form: sign in, add a title, description, and optional screenshots, then publish. The signed-in Workshop
account receives attribution. Homarr updates your existing listing or creates an attributed copy of another author’s
package automatically. **Release details** contains the version and optional release notes; updates suggest the next
patch version. Review source, dependencies, and fixtures before publishing. Local bindings and credentials remain
on the Homarr installation.
The management sidebar can collapse to icons using its toggle. It collapses automatically in the package editor to
leave more room for code and preview; expand it whenever you need the full labels. On mobile, use the header menu.
## Reference packages with local services
**Sonarr operations** supports two native Sonarr connections, upcoming and missing episodes, queue progress, source
filtering, and confirmed episode-search actions. Compact view shows four rows; the advanced view shows all loaded
rows with text filtering. Each server contributes up to 100 missing or queued episodes. An unavailable second
server does not hide the primary server's results. Searching requires native integration interaction access and
submits a Sonarr command; a queued command does not prove an eligible release was found or downloaded.
**Filesystem backup monitor** reads backup metadata and an optional JSON status report mounted into Homarr. Its
README contains the success/failure/running report format, timestamps, duration, size and retained run history.
The optional **Run backup** action uses a fixed executable and argument array deliberately configured in
`command.ts`. It runs through Node `execFile` without a shell, requires full placement permission, and never accepts
command text from the browser. Preview simulates it. Mount the executable into Homarr, then review and activate its
configuration. Backup activity and status reports do not verify restore integrity.
**Media and camera wall** combines native media sessions with optional authenticated camera snapshots and an Immich
image gallery. Camera paths share a bound HTTP connection; its credentials stay on the server. Immich uses a secret
`x-api-key` header with `asset.read` and `asset.view`. The package fetches images server-side and returns bounded
image data to the widget. It supports up to eight camera snapshots and twelve Immich thumbnails. Clicking an image
opens the advanced view with shared selection. Camera snapshots poll; this reference does not stream RTSP or other
continuous video, and individual images must remain under 675 KiB.
## Optional history collection
A placement’s **History collection** shows its latest retained sample and time. Expand the sampled-point table for
recent values or **Technical details** for the stored record.
Expand a placement’s **History collection** to select a named query handler, input JSON, value path, unit, sampling
interval, retention period, and maximum sample count. Collection is disabled by default. Enabling it runs the query
on Homarr’s server even when the dashboard is closed. Pause it without deleting samples, or explicitly discard
the collection and its history. The collection panel shows recent samples and request errors; package code can
consume retained history through the widget SDK.
## Guest actions
A package can declare a named action as available for guest grants. Under a placement's **Guest actions**, the owner
enables that specific handler for an explicit JSON array of permitted inputs. Other inputs remain denied. Changing
server code, dependencies, or bound connection configuration invalidates the grant and requires owner renewal.
Board access and the package's declared permissions still govern normal authenticated usage.
## Repair a v2 widget
The existing restricted v2 workbench remains available from the installed widgets tab. Invalid v2 definitions expose
**Repair widget**, which shows validation issues and editable source without changing the widget's dashboard ID.
Save repaired source or export a repair draft. Credentials are retained only when the original source and
authentication binding remain unchanged; configure credentials again when those bindings change.
## Build cache maintenance
Administrators can call `customWidget.package.cleanupArtifacts` through the authenticated API. Start with
`{ "dryRun": true }` to inspect candidate artifact IDs and local directories, then call it with `dryRun: false` to
remove those that are still unused. Cleanup preserves active and previous releases, every valid saved source draft
(including disabled offline imports), and artifacts leased by recent imports, builds or previews. Unreferenced data
must be at least seven days old. Preview sessions themselves expire after one hour.
Each call removes at most 100 database artifacts and 20 local directories by default. Adjust `maximumArtifacts`
(up to 500) and `maximumDirectories` (up to 100), or repeat when the result reports `limited: true`. Directory cleanup
applies to the current Homarr replica's runtime and dependency cache. Other replicas can clean their own local cache
through the same procedure. Cleanup coordinates reference changes across replicas and never invokes package code.
This is explicit maintenance; no automatic build-cache cleanup job is enabled.
For an external editor workflow with normal files, local previews and immutable release publishing, see the
[widget CLI commands](../../advanced/command-line/index.mdx#build-and-share-custom-widget-packages).
Each server worker runs at most eight handlers concurrently and queues at most 64 more for up to 30 seconds.
Extra requests receive a capacity error; one failing source does not block successful results from other requests.
## Webhook lifecycle and advanced links
Each placement can issue a separate token for a named action with an input schema. Homarr displays the POST URL and
bearer token once. Tokens stay outside source drafts, recovery and exports. Code or local binding changes invalidate
existing tokens; create a new one for the updated placement. Explicitly revoked tokens remain revoked after rollback.
The webhook accepts JSON matching the action schema, up to 1 MiB, and runs with its owner's current authorization.
A board URL can link directly to an installed widget's advanced view using `#widget=<placementId>`. The viewer still
needs access to the board and widget. This opens the dashboard widget surface rather than a standalone app route.
+37 -34
View File
@@ -8,18 +8,17 @@ tags:
# Model Context Protocol (MCP)
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
current Homarr API to tools for compatible AI clients.
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
Requests are stateless: each request authenticates independently, without an MCP session ID.
![The AI / MCP management tab](./img/mcp.png)
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
## Connect a client
## Configure a client
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
MCP configuration:
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
replacing the URL and API key:
```json
{
@@ -34,7 +33,10 @@ MCP configuration:
}
```
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
For clients that only support STDIO, use `mcp-remote`:
```json
{
@@ -47,26 +49,30 @@ Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
}
```
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
the catalog by namespace, tool name, or description; matching categories expand automatically.
### OAuth
## Authentication and permissions
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
Homarr publishes discovery metadata under `/.well-known/`.
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
for clients that support dynamic authorization.
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
such as `https://homarr.example.com`, without a path.
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
server and protected resource through Homarr's `/.well-known/` routes.
## Permissions and tools
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
Inspect the **AI / MCP** tab or your client's tool list for available actions.
Use HTTPS when the endpoint crosses an untrusted network.
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
## Verify the endpoint
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
The 2025 compatibility path accepts a compact `tools/list` request:
## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
```sh
curl -X POST \
@@ -77,17 +83,14 @@ curl -X POST \
https://homarr.example.com/api/mcp
```
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
A successful discovery or initialization response alone does not verify that tools can be listed.
## Tools and resources
## Troubleshooting
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
of an unsupported date schema.
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
an alias.
+12
View File
@@ -20,3 +20,15 @@ Each task exposes:
![The scheduled Tasks management page](../img/tasks.png)
Disabling a task stops scheduled runs but does not prevent a manual run.
## Widget history collectors
Trusted widget installations can opt into background sampling from their **Activation & boards** workspace tab.
Each collector names a declared query, input, optional response field, interval, and retention limit. Collection is
separate from viewer refresh; Redis admits one sampling run per interval across serving replicas. New collectors are
disabled until the owner enables them. The owner must retain administrator access for collection to continue.
The production application schedules enabled collectors while it is running, including when no dashboard is open.
Each series is bounded by its configured retention/sample count and a 1 MiB managed record limit. Pausing a collector
stops requests. Removing it explicitly removes its retained series. Application history APIs remain preferable when
available because Homarr cannot collect observations while it is offline.
@@ -18,6 +18,9 @@ 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 and password changes are disabled for everyone, including administrators,
even when `DEMO_READ_ONLY=false` allows other changes. This applies to the UI and API.
![The Users management page](../img/users.png)
## Groups
+6 -4
View File
@@ -15,10 +15,12 @@ import { bookmarksWidget } from ".";
The Bookmarks widget keeps direct URLs and Homarr Apps in one ordered list. URLs do not require an App record; Homarr
uses the site's favicon when available. Choose adaptive, compact-grid, or other layouts from the widget configuration.
Adaptive, vertical, and grid layouts keep larger cards when the configured bookmarks fit, then reduce card height and
spacing to show as many bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with
narrower cards in one-row widgets. Horizontal, compact-grid, and icon-only layouts keep their gaps tight. Compact grid
also preserves its configured columns when the dashboard is scaled.
Vertical uses consistent compact rows and keeps bookmark names visible whenever the widget is wide enough, regardless
of how many bookmarks are configured. Adaptive and grid layouts reduce card height and spacing to show as many
bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with narrower cards in
one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep their gaps tight. Compact grid also
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.
### Screenshots
+1 -1
View File
@@ -17,7 +17,7 @@ export const bookmarksWidget: WidgetDefinition = {
{
name: "Layout",
description:
"How bookmarks use the available widget space. Layouts stay compact and reduce card size when needed.",
"How bookmarks use the available widget space. Vertical keeps compact rows and stable title visibility.",
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
defaultValue: "Adaptive",
},
@@ -0,0 +1,43 @@
---
title: "llama.cpp"
description: "Displays the health, loaded model and generation speed of a local llama.cpp llama-server."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { llamacppWidget } from ".";
import { llamacppIntegration } from "@site/docs/integrations/llama-cpp";
<WidgetHeader widget={llamacppWidget} categories={["System Monitoring"]} />
This widget shows the live status of your local [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server`: whether it is online, which model is loaded (with quantization, context size, file size and parameter count), the generation speed in tokens per second, and how many requests are being processed. When the server is started with `--metrics`, it also reports total token counts and the prompt cache hit rate.
### Generation speed
The widget picks the most meaningful speed for the current state (hover the speed for an explanation of which one is shown):
- **While a request is being generated** — the average speed of that request since it started, derived from the token count reported by `/slots`.
- **While the server is busy but the per-request data is unavailable** — the live speed from the `/metrics` rate gauge.
- **While the server is idle** — the average generation speed since the server started (cumulative tokens ÷ seconds), so the value stays meaningful instead of dropping to zero.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: llamacppIntegration,
note: "Displays health, model details, generation speed and request status",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={llamacppWidget.configuration} />
+37
View File
@@ -0,0 +1,37 @@
import { WidgetDefinition } from "@site/src/types";
import { IconCpu } from "@tabler/icons-react";
export const llamacppWidget: WidgetDefinition = {
icon: IconCpu,
name: "llama.cpp",
description: "Shows the health, loaded model and generation speed of a local llama.cpp llama-server.",
path: "../../widgets/llama-cpp",
configuration: {
items: [
{
name: "Show title",
description: "Displays the llama.cpp label and status badge at the top of the widget",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show model info",
description: "Displays the loaded model name, quantization, context size and file size",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show cache hit rate",
description: "Displays the prompt cache hit rate as a percentage bar",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show context usage",
description: "Displays the context (KV cache) token usage as a progress bar",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+7
View File
@@ -37,6 +37,13 @@ Custom Widget credentials are configured in your Homarr instance and are not par
Publish a saved Custom Widget from its action menu under **Management → Custom Widgets**. Custom CSS and manual
submissions can be published from the Workshop website.
Trusted v3 packages use the same publishing form, with attribution from your signed-in Workshop account.
Homarr selects a new listing, an update to your listing, or an attributed copy automatically. Version and optional
release notes are under **Release details**. Find version indicators, **Check for updates**, and **View on Workshop**
in the **Packages** library.
Trusted v3 packages publish immutable releases. When editing a release and its screenshots together, Homarr validates
the screenshot count before publishing, so an invalid screenshot edit does not consume the proposed package version.
Sign in to the Workshop and open **Yours** to update or delete submissions and review reports. Signed-in users can vote,
comment, and report content; Workshop moderators can remove content. Workshop moderation is separate from administration
@@ -305,7 +305,9 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const widgetDefinition = useMemo<HomarrCustomWidgetV2 | null>(() => {
if (submission?.type !== "customWidget") return null;
const result = validateSubmissionContent("customWidget", submission.content);
return result.success && typeof result.data === "object" ? result.data : null;
if (!result.success || typeof result.data !== "object" || result.data.$schema !== "homarr-custom-widget-v2")
return null;
return result.data;
}, [submission]);
const handleVote = async (value: 1 | -1) => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { CUSTOM_WIDGET_SCHEMA, type HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import {
MAX_WORKSHOP_CSS_LENGTH,
type WorkshopSubmissionType,
@@ -12,7 +13,7 @@ export const CSS_SCHEMA_VERSION = WORKSHOP_CSS_SCHEMA;
export const MAX_CSS_LENGTH = MAX_WORKSHOP_CSS_LENGTH;
export type WorkshopValidationResult =
| { success: true; data: HomarrCustomWidgetV2 | string }
| { success: true; data: HomarrCustomWidgetV2 | CustomWidgetPackage | string }
| { success: false; error: string };
export const schemaVersionByType = {
+3 -1
View File
@@ -2572,7 +2572,9 @@
"type": "object",
"properties": {
"expirationDate": {
"type": "string"
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
}
},
"required": [
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "homarr-widget-package-v3",
"manifest": {
"id": "homarr.example.preview-scenarios",
"version": "1.0.0",
"sdkVersion": "1",
"name": "Preview scenario example",
"description": "Two independent local metrics with synthetic success, loading and partial-failure scenarios.",
"entrypoints": {
"tile": "widget.tsx",
"advanced": "widget.tsx",
"server": "server.ts"
},
"handlers": {
"memory": {
"kind": "query"
},
"storage": {
"kind": "query"
}
}
},
"files": {
"widget.tsx": "import { Alert, Card, Group, Progress, Stack, Text } from \"@mantine/core\";\nimport { useWidgetQuery } from \"@homarr/widget-sdk\";\ninterface MetricData { usedPercent: number; }\ninterface MetricProps { title: string; query: ReturnType<typeof useWidgetQuery<MetricData>>; }\nfunction Metric({ title, query }: MetricProps) {\n return <Card withBorder><Stack gap=\"xs\"><Text fw={600}>{title}</Text>\n {query.isPending && <Text c=\"dimmed\">Loading…</Text>}\n {query.error && <Alert color=\"orange\">{query.error.message}</Alert>}\n {query.data && <><Group justify=\"space-between\"><Text>Used</Text><Text>{query.data.usedPercent}%</Text></Group><Progress value={query.data.usedPercent} /></>}\n </Stack></Card>;\n}\nexport default function LocalMetrics() {\n const memory = useWidgetQuery<MetricData>(\"memory\", {}, {refetchInterval: 10000});\n const storage = useWidgetQuery<MetricData>(\"storage\", {}, {refetchInterval: 10000});\n return <Stack p=\"md\"><Metric title=\"Memory\" query={memory} /><Metric title=\"Storage\" query={storage} /></Stack>;\n}\n",
"server.ts": "import { freemem, totalmem } from \"node:os\";\nimport { statfs } from \"node:fs/promises\";\nimport { defineWidgetServer } from \"@homarr/widget-sdk/server\";\nexport default defineWidgetServer({\n memory: () => ({usedPercent: Math.round(100 * (1 - freemem() / totalmem()))}),\n storage: async () => {const value = await statfs(process.cwd()); return {usedPercent: Math.round(100 * (1 - value.bavail / value.blocks))};},\n});\n"
},
"dependencies": {},
"connections": {},
"options": {},
"previewScenarios": {
"ready": {
"label": "Both metrics available",
"queries": {
"memory": {
"data": {
"usedPercent": 38
}
},
"storage": {
"data": {
"usedPercent": 72
}
}
}
},
"loading": {
"label": "Slow storage collector",
"description": "Memory appears first; storage stays loading for three seconds.",
"queries": {
"memory": {
"data": {
"usedPercent": 38
},
"delayMs": 100
},
"storage": {
"data": {
"usedPercent": 72
},
"delayMs": 3000
}
}
},
"partialFailure": {
"label": "Storage unavailable",
"description": "Memory still renders when the storage collector fails.",
"queries": {
"memory": {
"data": {
"usedPercent": 38
}
},
"storage": {
"error": {
"message": "Synthetic storage collector timeout",
"code": "TIMEOUT"
},
"delayMs": 700
}
}
}
}
}
+12 -1
View File
@@ -43,7 +43,18 @@ const nextConfig: NextConfig = {
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3", "esbuild"],
outputFileTracingRoot: path.resolve(import.meta.dirname, "../.."),
outputFileTracingIncludes: {
"/*": [
// esbuild is automatically traced as an external, preserving its platform binary links.
// Recursive package globs instead trace pnpm dependency directory symlinks as files.
"../../node_modules/@tabler/icons-react/package.json",
"../../node_modules/@tabler/icons-react/dist/**/*",
"../../packages/widget-sdk/package.json",
"../../packages/widget-sdk/src/**/*",
],
},
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
preloadEntriesOnStart: true,
+3 -2
View File
@@ -4,9 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"build": "pnpm --filter @homarr/custom-widgets generate:editor && HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"dev": "pnpm --filter @homarr/custom-widgets generate:editor && pnpm with-env next dev",
"format": "oxfmt .",
"lint": "oxlint",
"start": "pnpm with-env next start",
@@ -58,6 +58,7 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widget-sdk": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:",
@@ -7,6 +7,7 @@ import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
@@ -107,7 +108,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
useEffect(() => {
return () => {
@@ -1 +1,15 @@
export { CodeEditor } from "~/components/custom-widgets/code-editor";
"use client";
import type { ComponentProps } from "react";
import { CodeEditor as SharedCodeEditor } from "~/components/custom-widgets/code-editor";
export function CodeEditor(props: ComponentProps<typeof SharedCodeEditor>) {
const localHistory =
props.id === "raw-widget-manifest" || props.id.endsWith("-request-body-editor") || props.id.startsWith("option-");
return (
<div data-custom-widget-local-history={localHistory || undefined}>
<SharedCodeEditor {...props} hideHistoryActions={!localHistory} />
</div>
);
}
@@ -1,43 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
describe("Copy AI prompt", () => {
it("uses one self-contained prompt without an embedded-skill branch", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(source).toContain("buildCustomWidgetAiPrompt");
expect(source).not.toContain("embedded-authoring-prompt");
expect(source).not.toContain("Menu");
});
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
const card = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
"utf8",
);
const styles = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
const actions = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(card).toContain('<Accordion variant="contained">');
expect(card).not.toContain("defaultValue");
expect(actions).toContain("<ActionIcon");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
});
@@ -27,7 +27,7 @@ import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { downloadJson } from "~/components/custom-widgets/download";
import { downloadJson } from "@homarr/common";
const iconProps = { size: 16, stroke: 1.5 };
@@ -0,0 +1,104 @@
import { redactCustomWidgetCredentialLiterals } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
export type CustomWidgetDocumentContent = Omit<CustomWidgetFormValues, "secrets">;
export interface CustomWidgetDocumentHistoryState {
canUndo: boolean;
canRedo: boolean;
}
const MAX_HISTORY_ENTRIES = 100;
const MAX_HISTORY_CHARACTERS = 2_000_000;
const COALESCE_DELAY = 600;
const typingFields = new Set(["name", "description", "iconUrl", "template"]);
export function getCustomWidgetDocumentContent(values: CustomWidgetFormValues): CustomWidgetDocumentContent {
const { secrets: _secrets, ...content } = values;
return content;
}
export function getCustomWidgetRecoveryContent(values: CustomWidgetFormValues): CustomWidgetDocumentContent {
const content = getCustomWidgetDocumentContent(values);
for (const key of Object.keys(content) as Array<keyof CustomWidgetDocumentContent>) {
let value = content[key];
for (const secret of values.secrets) {
if (secret.value) value = value.replaceAll(secret.value, "[REDACTED]");
}
content[key] = redactCustomWidgetCredentialLiterals(value.replaceAll(/\b(https?:\/\/)[^\s/"'<>]+@/giu, "$1"));
}
return content;
}
export function createCustomWidgetDocumentHistory(initialValues: CustomWidgetFormValues) {
const initial = getCustomWidgetDocumentContent(initialValues);
let entries = [{ content: initial, size: JSON.stringify(initial).length }];
let position = 0;
let lastChangeAt = 0;
let lastTypingField: string | undefined;
let sameTurn = false;
let state: CustomWidgetDocumentHistoryState = { canUndo: false, canRedo: false };
const refreshState = () => {
state = { canUndo: position > 0, canRedo: position < entries.length - 1 };
};
const stopCoalescing = () => {
sameTurn = false;
lastChangeAt = 0;
lastTypingField = undefined;
};
return {
getState: () => state,
record(values: CustomWidgetFormValues) {
const next = getCustomWidgetDocumentContent(values);
const current = entries[position]?.content;
const changed = (Object.keys(next) as Array<keyof CustomWidgetDocumentContent>).filter(
(key) => next[key] !== current?.[key],
);
if (changed.length === 0) return;
const now = Date.now();
const field = changed[0];
const isTyping = changed.length === 1 && field !== undefined && typingFields.has(field);
const coalesce =
position > 0 &&
position === entries.length - 1 &&
(sameTurn || (isTyping && field === lastTypingField && now - lastChangeAt < COALESCE_DELAY));
entries = entries.slice(0, position + 1);
const entry = { content: next, size: JSON.stringify(next).length };
if (coalesce) entries[position] = entry;
else {
entries.push(entry);
position += 1;
}
let size = entries.reduce((total, item) => total + item.size, 0);
while (entries.length > 1 && (entries.length > MAX_HISTORY_ENTRIES || size > MAX_HISTORY_CHARACTERS)) {
const removed = entries.shift();
if (removed) size -= removed.size;
position -= 1;
}
lastChangeAt = now;
lastTypingField = undefined;
if (isTyping) lastTypingField = field;
sameTurn = true;
queueMicrotask(() => {
sameTurn = false;
});
refreshState();
},
undo() {
if (position === 0) return null;
position -= 1;
stopCoalescing();
refreshState();
return entries[position]?.content ?? null;
},
redo() {
if (position === entries.length - 1) return null;
position += 1;
stopCoalescing();
refreshState();
return entries[position]?.content ?? null;
},
stopCoalescing,
};
}
@@ -0,0 +1,196 @@
"use client";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Alert, Button, Group, Stack, Text } from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp, IconRestore } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { isRecord } from "@homarr/common";
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { getCustomWidgetRecoveryContent } from "./_custom-widget-document-history";
import type { CustomWidgetDocumentContent } from "./_custom-widget-document-history";
import { useCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RecoveryDraft {
definitionId: string | null;
content: CustomWidgetDocumentContent;
}
const MAX_RECOVERY_CHARACTERS = 1_000_000;
export function CustomWidgetDraftControls({
form,
definitionId,
disabled,
}: {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
disabled: boolean;
}) {
const t = useI18n("customWidget.workbench.history");
const store = useCustomWidgetFormDocumentStore();
const history = useSyncExternalStore(store.subscribe, store.getHistory, store.getHistory);
const dirty = useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
const { data: session } = useSession();
const userId = session?.user.id;
const [recovery, setRecovery] = useState<RecoveryDraft | null>(null);
const storageKey = userId ? `homarr:custom-widget-recovery:${userId}` : null;
const restoreHistory = useCallback(
(direction: "undo" | "redo") => {
if (disabled) return;
const restored = store[direction]();
if (restored) form.setValues(restored);
},
[disabled, form, store],
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== "z") return;
const target = event.target;
if (!(target instanceof Element) || !target.closest("[data-custom-widget-workbench]")) return;
if (target.closest("[data-custom-widget-secret], [data-custom-widget-local-history], #preview")) return;
event.preventDefault();
event.stopPropagation();
if (event.shiftKey) restoreHistory("redo");
else restoreHistory("undo");
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [restoreHistory]);
useEffect(() => {
if (!storageKey) return;
try {
const saved = localStorage.getItem(storageKey);
if (!saved || saved.length > MAX_RECOVERY_CHARACTERS) return;
const parsed = JSON.parse(saved) as RecoveryDraft;
if (!parsed || parsed.definitionId !== (definitionId ?? null)) return;
if (!isRecord(parsed.content)) return;
const keys = customWidgetFormSchema.keyof().options.filter((key) => key !== "secrets");
if (!keys.every((key) => typeof parsed.content[key] === "string")) return;
setRecovery({
definitionId: parsed.definitionId,
content: getCustomWidgetRecoveryContent({ ...parsed.content, secrets: [] }),
});
} catch {
// Browser storage is optional; editing and saving continue without it.
}
}, [definitionId, storageKey]);
useEffect(() => {
if (!storageKey || recovery) return;
let timeout: ReturnType<typeof setTimeout> | undefined;
const persist = () => {
clearTimeout(timeout);
if (!store.getDirty()) {
try {
const saved = localStorage.getItem(storageKey);
if (saved && (JSON.parse(saved) as RecoveryDraft).definitionId === (definitionId ?? null)) {
localStorage.removeItem(storageKey);
}
} catch {
/* Storage is optional. */
}
return;
}
timeout = setTimeout(() => {
try {
const draft = JSON.stringify({
definitionId: definitionId ?? null,
content: getCustomWidgetRecoveryContent(store.getValues()),
} satisfies RecoveryDraft);
if (draft.length <= MAX_RECOVERY_CHARACTERS) localStorage.setItem(storageKey, draft);
} catch {
// Quota limits and blocked storage must not interrupt the workbench.
}
}, 500);
};
const unsubscribe = store.subscribe(persist);
if (store.getDirty()) persist();
return () => {
clearTimeout(timeout);
unsubscribe();
};
}, [definitionId, recovery, storageKey, store]);
const dismissRecovery = () => {
if (storageKey) {
try {
localStorage.removeItem(storageKey);
} catch {
/* Storage is optional. */
}
}
setRecovery(null);
};
return (
<Stack gap="sm" mb="md">
{recovery && (
<Alert title={t("recoveryTitle")}>
<Stack gap="xs">
<Text size="sm">{t("recoveryDescription")}</Text>
<Group gap="xs">
<Button
size="xs"
type="button"
disabled={disabled}
onClick={() => {
form.setValues({ ...store.getValues(), ...recovery.content, secrets: [] });
dismissRecovery();
}}
>
{t("restore")}
</Button>
<Button size="xs" type="button" variant="subtle" onClick={dismissRecovery}>
{t("discard")}
</Button>
</Group>
</Stack>
</Alert>
)}
<Group gap="xs">
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconArrowBackUp size={16} />}
disabled={disabled || !history.canUndo}
onClick={() => restoreHistory("undo")}
aria-keyshortcuts="Control+Z Meta+Z"
>
{t("undo")}
</Button>
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconArrowForwardUp size={16} />}
disabled={disabled || !history.canRedo}
onClick={() => restoreHistory("redo")}
aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z"
>
{t("redo")}
</Button>
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconRestore size={16} />}
disabled={disabled || !dirty}
onClick={() => form.setValues(store.resetToSaved())}
>
{t("reset")}
</Button>
<Text size="xs" c="dimmed">
{t("credentialsExcluded")}
</Text>
</Group>
</Stack>
);
}
@@ -201,7 +201,7 @@ interface CustomWidgetPreviewSectionProps {
onSizeChange(size: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange: Dispatch<SetStateAction<Record<string, unknown>>>;
onLiveActionsChange(enabled: boolean): void;
onLiveActionsChange(enabled: boolean, sessionId: string): void;
}
export function CustomWidgetPreviewSection(props: CustomWidgetPreviewSectionProps) {
@@ -13,10 +13,17 @@ import {
import type { ReactNode } from "react";
import type { UseFormReturnType } from "@mantine/form";
import {
customWidgetSourcesSchema,
getCustomWidgetRequiredSecretKinds,
hasSameCustomWidgetSourceBinding,
} from "@homarr/custom-widgets/core";
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
import { createCustomWidgetDocumentHistory, getCustomWidgetDocumentContent } from "./_custom-widget-document-history";
import type { CustomWidgetDocumentContent, CustomWidgetDocumentHistoryState } from "./_custom-widget-document-history";
export interface CustomWidgetFormDocumentStore {
getValues(): CustomWidgetFormValues;
@@ -24,6 +31,10 @@ export interface CustomWidgetFormDocumentStore {
getDirty(): boolean;
subscribe(listener: () => void): () => void;
markSaved(values: CustomWidgetFormValues): void;
getHistory(): CustomWidgetDocumentHistoryState;
undo(): CustomWidgetFormValues | null;
redo(): CustomWidgetFormValues | null;
resetToSaved(): CustomWidgetFormValues;
}
const documentValueKeys = customWidgetFormSchema.keyof().options;
@@ -42,19 +53,58 @@ export function createCustomWidgetFormDocumentStore(
let values = initialValues;
let persistedValues = initialValues;
let dirty = false;
const history = createCustomWidgetDocumentHistory(initialValues);
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
const restore = (content: CustomWidgetDocumentContent | null): CustomWidgetFormValues | null => {
if (!content) return null;
let secrets: CustomWidgetFormValues["secrets"] = [];
try {
const sources = customWidgetSourcesSchema.parse(JSON.parse(content.sources));
const currentSources = customWidgetSourcesSchema.parse(JSON.parse(values.sources));
secrets = values.secrets.filter((secret) => {
const source = sources[secret.sourceId];
const currentSource = currentSources[secret.sourceId];
if (!source || !currentSource || !hasSameCustomWidgetSourceBinding(source, currentSource)) return false;
const auth = typeof source.auth === "string" ? source.auth : source.auth.type;
return getCustomWidgetRequiredSecretKinds(auth).some((kind) => kind === secret.kind);
});
} catch {
// An unfinished source document cannot safely retain credential bindings.
}
const restored = { ...content, secrets };
values = restored;
dirty = !areDocumentValuesEqual(restored, persistedValues);
notify();
return restored;
};
return {
getValues: () => values,
setValues: (nextValues) => {
if (Object.is(values, nextValues)) return;
history.record(nextValues);
values = nextValues;
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
notify();
},
getDirty: () => dirty,
getHistory: history.getState,
undo: () => restore(history.undo()),
redo: () => restore(history.redo()),
resetToSaved: () => {
history.stopCoalescing();
const restored = {
...getCustomWidgetDocumentContent(persistedValues),
secrets: persistedValues.secrets.map((secret) => ({ ...secret, value: "" })),
};
history.record(restored);
values = restored;
dirty = !areDocumentValuesEqual(restored, persistedValues);
notify();
return restored;
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -62,6 +112,7 @@ export function createCustomWidgetFormDocumentStore(
markSaved: (savedValues) => {
const wasDirty = dirty;
persistedValues = savedValues;
history.stopCoalescing();
dirty = !areDocumentValuesEqual(values, persistedValues);
if (wasDirty !== dirty) notify();
},
@@ -11,6 +11,7 @@ import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
import { CustomWidgetDraftControls } from "./_custom-widget-draft-controls";
import {
CustomWidgetOptionsSnapshotSync,
CustomWidgetSaveActions,
@@ -111,19 +112,24 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
[setMobilePane],
);
const handleLiveActionsChange = useCallback(
(enabled: boolean) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
})),
(enabled: boolean, sessionId: string) =>
setPreview((current) => {
if (current.session?.id !== sessionId) return current;
return { ...current, session: { ...current.session, liveActions: enabled } };
}),
[setPreview],
);
return (
<CustomWidgetFormDocumentProvider store={documentStore}>
<CustomWidgetFormAnalysisProvider>
<form onSubmit={props.onSubmit} className={classes.form}>
<form onSubmit={props.onSubmit} className={classes.form} data-custom-widget-workbench>
<CustomWidgetUnsavedChangesGuard />
<CustomWidgetDraftControls
form={form}
definitionId={props.definitionId}
disabled={props.savePending || props.previewPending}
/>
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
<SegmentedControl
className={classes.paneSwitcher}
@@ -1,36 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
describe("legacy custom widget migration UI", () => {
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
expect(list).toContain("widget.migrationRequired");
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("navigator.clipboard.readText()");
expect(actions).toContain('t("action.pasteMigration")');
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
// The import pipeline is shared by the dialog and the Workshop install page.
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
expect(importer).toMatch(
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
);
expect(importer).toContain("utils.customWidget.list.invalidate()");
expect(importer).toContain("utils.customWidget.available.invalidate()");
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
expect(dialog).toContain("useCustomWidgetImport({");
expect(dialog).toContain("legacyId,");
});
});
@@ -89,7 +89,7 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
}
actions={
<Group gap="xs" wrap="nowrap">
{widget.valid && (
{!widget.migrationRequired && (
<Button
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
@@ -97,7 +97,17 @@ function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{tCommon("action.edit")}
{widget.valid ? tCommon("action.edit") : t("workbench.repair.title")}
</Button>
)}
{widget.valid && (
<Button
component={Link}
href={`/manage/custom-widgets/packages/convert/${widget.id}`}
variant="light"
size="sm"
>
{t("package.conversion.shortTitle")}
</Button>
)}
<CustomWidgetRowActions
@@ -47,7 +47,7 @@ export function createPreviewDisplayData({
})),
previewSessionId: preview.session?.id,
previewLiveActions: preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
queriesDisabled: fixture !== "live" || !preview.session,
isEditMode: fixture !== "live",
};
}
@@ -1,6 +1,5 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
@@ -76,16 +75,6 @@ afterEach(async () => {
});
describe("Custom Widget preview panel", () => {
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
"utf8",
);
expect(source).not.toContain("forceColorScheme");
expect(source).not.toContain("onThemeChange");
});
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
@@ -26,6 +26,7 @@ export interface PreviewState {
status: Record<string, unknown>;
session: { id: string; expiresAt: number; liveActions: boolean } | null;
outcome: PreviewOutcome;
stale?: boolean;
}
interface PreviewPanelProps {
@@ -36,7 +37,7 @@ interface PreviewPanelProps {
onSizeChange(value: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
onLiveActionsChange(enabled: boolean, sessionId: string): void;
}
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
@@ -131,6 +132,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
]}
/>
</Group>
{props.preview.stale && <Alert color="yellow">{t("staleSnapshot")}</Alert>}
{candidate && (
<PreviewResult
outcome={props.preview.outcome}
@@ -203,11 +205,12 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
disabled={!props.preview.session || liveActionsMutation.isPending}
onChange={(event) => {
if (!props.preview.session) return;
const sessionId = props.preview.session.id;
const enabled = event.currentTarget.checked;
liveActionsMutation.mutate(
{ sessionId: props.preview.session.id, enabled },
{
onSuccess: () => props.onLiveActionsChange(enabled),
onSuccess: () => props.onLiveActionsChange(enabled, sessionId),
onError: (error) => showErrorNotification({ title: t("liveActions"), message: error.message }),
},
);
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Stack, Text, Title } from "@mantine/core";
import { parse } from "superjson";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "~/components/custom-widgets/code-editor";
function readStored(value: string): unknown {
try {
return parse(value);
} catch {
return value;
}
}
export function CustomWidgetRepair({ definition }: { definition: RouterOutputs["customWidget"]["getRaw"] }) {
const t = useI18n("customWidget.workbench.repair");
const [source, setSource] = useState(() =>
JSON.stringify(
{
$schema: "homarr-custom-widget-v2",
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: readStored(definition.sources),
requests: readStored(definition.requests),
options: readStored(definition.options),
template: definition.template,
},
null,
2,
),
);
const [error, setError] = useState("");
const repair = clientApi.customWidget.repair.useMutation();
const save = async () => {
try {
const parsed: unknown = JSON.parse(source);
const widget = customWidgetDefinitionSchema.parse(parsed);
const result = await repair.mutateAsync({ id: definition.id, widget });
window.location.assign(result.managementPath);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
};
const download = () => {
const url = URL.createObjectURL(new Blob([source], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${definition.id}-repair.json`;
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Stack>
<Title>
{t("title")}: {definition.name}
</Title>
<Alert color="yellow">
<Text>{t("description")}</Text>
{definition.issues.map((issue, index) => (
<Text size="sm" key={index}>
{issue.path}: {issue.message}
</Text>
))}
</Alert>
{error && <Alert color="red">{error}</Alert>}
<CodeEditor
id="repair-source"
label={t("source")}
language="json"
value={source}
onChange={setSource}
height="60vh"
/>
<Group>
<Button loading={repair.isPending} onClick={() => void save()}>
{t("save")}
</Button>
<Button variant="default" onClick={download}>
{t("export")}
</Button>
</Group>
</Stack>
);
}
@@ -24,7 +24,7 @@ export function CustomWidgetRequestsEditor({
const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources);
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
const sourceIds = isRecord(sources) ? Object.keys(sources) : [];
const options = parseJson(form.values.options);
const optionNames = isRecord(options) ? Object.keys(options) : [];
@@ -41,13 +41,20 @@ export function CustomWidgetRequestsEditor({
commit(next);
};
const add = () => {
const source = sourceIds[0];
if (!source) return;
let suffix = entries.length + 1;
while (entries.some(([id]) => id === `request-${suffix}`)) suffix += 1;
commit([...entries, [`request-${suffix}`, { path: "/api/data" }]]);
commit([...entries, [`request-${suffix}`, { source, path: "/api/data" }]]);
};
return (
<Stack gap="sm">
{sourceIds.length === 0 && (
<Text size="sm" c="dimmed">
{t("sourceRequired")}
</Text>
)}
{entries.map(([id, rawRequest], index) => {
const request = isRecord(rawRequest) ? rawRequest : {};
const kind = request.kind === "action" ? "action" : "query";
@@ -212,7 +219,13 @@ export function CustomWidgetRequestsEditor({
</Fieldset>
);
})}
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
<Button
type="button"
variant="light"
leftSection={<IconPlus size={16} />}
onClick={add}
disabled={sourceIds.length === 0 || entries.length >= 64}
>
{t("addRequest")}
</Button>
{form.errors.requests && (
@@ -0,0 +1,30 @@
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
export function applyCustomWidgetSavedState(
form: CustomWidgetWorkbenchForm,
store: CustomWidgetFormDocumentStore,
submitted: CustomWidgetFormValues,
) {
const savedValues = {
...submitted,
secrets: submitted.secrets.map((secret) => {
if (!secret.value.trim()) return secret;
return { ...secret, value: "", hasValue: true };
}),
};
const currentValues = store.getValues();
form.setInitialValues(savedValues);
form.setValues({
...currentValues,
secrets: currentValues.secrets.map((secret) => {
const saved = submitted.secrets.find((entry) => entry.sourceId === secret.sourceId && entry.kind === secret.kind);
if (!saved?.value.trim() || saved.value !== secret.value) return secret;
return { ...secret, value: "", hasValue: true };
}),
});
form.resetDirty();
store.markSaved(savedValues);
}
@@ -3,14 +3,14 @@ import { isRecord } from "@homarr/common";
export function getDependentRequestIds(requestsJson: string, sourceId: string) {
const requests = parseRequestRecord(requestsJson);
return Object.entries(requests).flatMap(([requestId, request]) =>
isRecord(request) && request.source === sourceId ? [requestId] : [],
isRecord(request) && (request.source ?? "default") === sourceId ? [requestId] : [],
);
}
export function removeDependentRequests(requestsJson: string, sourceId: string) {
return Object.fromEntries(
Object.entries(parseRequestRecord(requestsJson)).filter(
([, request]) => !isRecord(request) || request.source !== sourceId,
([, request]) => !isRecord(request) || (request.source ?? "default") !== sourceId,
),
);
}
@@ -57,13 +57,13 @@ export function CustomWidgetSourceField({
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<Fieldset legend={index === 0 ? t("primary") : source.name}>
<Fieldset legend={source.name ?? source.id}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("id")}
value={source.id}
disabled={source.id === "default" || Boolean(definitionId)}
disabled={Boolean(definitionId)}
error={form.errors.sources}
onCommit={(value) => onUpdate(index, { id: value })}
/>
@@ -118,6 +118,7 @@ export function CustomWidgetSourceField({
return (
<Group key={kind} align="end" wrap="nowrap">
<Input
data-custom-widget-secret
style={{ flex: 1 }}
label={tSecret(kind)}
value={secret?.value ?? ""}
@@ -139,17 +140,15 @@ export function CustomWidgetSourceField({
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => onRemove(index)}
>
{t("remove")}
</Button>
)}
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => onRemove(index)}
>
{t("remove")}
</Button>
</Stack>
</Fieldset>
);
@@ -1,6 +1,6 @@
"use client";
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
import { Button, List, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -55,7 +55,9 @@ export function CustomWidgetSourcesEditor({
? Object.fromEntries(
Object.entries(parsedRequests).map(([id, request]) => [
id,
isRecord(request) && request.source === previousId ? { ...request, source: nextId } : request,
isRecord(request) && (request.source ?? "default") === previousId
? { ...request, source: nextId }
: request,
]),
)
: {};
@@ -100,7 +102,7 @@ export function CustomWidgetSourcesEditor({
{
name: t("newName", { count: sources.length + 1 }),
baseUrl: "https://example.com",
networkScope: sources.length === 0 ? "public" : "private",
networkScope: "private",
auth: "none",
},
],
@@ -164,6 +166,11 @@ export function CustomWidgetSourcesEditor({
return (
<Stack gap="sm">
{sources.length === 0 && (
<Text size="sm" c="dimmed">
{t("empty")}
</Text>
)}
{sources.map((source, index) => {
return (
<CustomWidgetSourceField
@@ -181,16 +188,15 @@ export function CustomWidgetSourcesEditor({
/>
);
})}
<Accordion variant="contained">
<Accordion.Item value="advanced">
<Accordion.Control>{t("advanced")}</Accordion.Control>
<Accordion.Panel>
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={addSource}>
{t("add")}
</Button>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Button
type="button"
variant="light"
leftSection={<IconPlus size={16} />}
onClick={addSource}
disabled={sources.length >= 8}
>
{t("add")}
</Button>
{form.errors.sources && (
<Text c="red" size="xs">
{form.errors.sources}
@@ -1,12 +1,13 @@
"use client";
import { Tabs } from "@mantine/core";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { IconApi, IconBuildingStore, IconPackage } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
const tabs = {
packages: "/manage/custom-widgets/packages",
installed: "/manage/custom-widgets",
workshop: "/manage/custom-widgets/workshop",
} as const;
@@ -18,6 +19,13 @@ export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
return (
<Tabs value={tabs[active]}>
<Tabs.List aria-label={t("ariaLabel")}>
<Tabs.Tab
value={tabs.packages}
leftSection={<IconPackage size={16} stroke={1.5} />}
renderRoot={(props) => <Link href={tabs.packages} {...props} />}
>
{t("packages")}
</Tabs.Tab>
<Tabs.Tab
value={tabs.installed}
leftSection={<IconApi size={16} stroke={1.5} />}
@@ -24,6 +24,7 @@ import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { clearSaveIssues, reportSaveIssues } from "./_custom-widget-save-issue-utils";
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { applyCustomWidgetSavedState } from "./_custom-widget-save-state";
interface FormActionsInput {
mode: "create" | "edit";
@@ -44,14 +45,22 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const discardPreviewMutation = clientApi.customWidget.previewDiscard.useMutation();
const discardPreview = discardPreviewMutation.mutate;
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false);
const setPreview = input.setPreview;
const previewGeneration = useRef(0);
const activePreviewGeneration = useRef<number | null>(null);
const activeSessionId = useRef<string | null>(null);
const discardCurrentSession = useCallback(() => {
const sessionId = activeSessionId.current;
activeSessionId.current = null;
if (sessionId) discardPreview({ sessionId });
}, [discardPreview]);
const invalidatePreview = useCallback(() => {
previewGeneration.current += 1;
if (activePreviewGeneration.current !== null) return;
discardCurrentSession();
setPreview((preview) => {
if (
preview.outcome === "idle" &&
@@ -60,9 +69,9 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
Object.keys(preview.status).length === 0
)
return preview;
return { data: {}, status: {}, session: null, outcome: "idle" };
return { ...preview, session: null, outcome: "idle", stale: true };
});
}, [setPreview]);
}, [discardCurrentSession, setPreview]);
useLayoutEffect(() => {
invalidatePreview();
}, [input.optionsSnapshot, invalidatePreview]);
@@ -71,8 +80,9 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
() => () => {
previewGeneration.current += 1;
activePreviewGeneration.current = null;
discardCurrentSession();
},
[],
[discardCurrentSession],
);
let actionLabel = tCommon("action.save");
if (input.mode === "create") actionLabel = tCommon("action.create");
@@ -100,9 +110,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
title: tCommon("action.create"),
message: t("notification.created", { name: values.name }),
});
input.form.setInitialValues(values);
input.form.resetDirty();
input.documentStore.markSaved(values);
applyCustomWidgetSavedState(input.form, input.documentStore, values);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
window.location.assign(result.managementPath);
} else if (input.definitionId) {
@@ -116,9 +124,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
utils.customWidget.get.invalidate({ id: input.definitionId }),
utils.widget.customApi.getData.invalidate(),
]);
input.form.setInitialValues(values);
input.form.resetDirty();
input.documentStore.markSaved(values);
applyCustomWidgetSavedState(input.form, input.documentStore, values);
showSuccessNotification({
title: tCommon("action.save"),
message: t("notification.updated", { name: values.name }),
@@ -157,11 +163,12 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}
const secretsAtStart = getChangedSecrets(valuesAtStart);
const generation = previewGeneration.current + 1;
discardCurrentSession();
previewGeneration.current = generation;
activePreviewGeneration.current = generation;
const isCurrent = () => previewGeneration.current === generation;
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
input.setPreview((current) => ({ ...current, session: null, outcome: "loading" }));
setPreviewPending(true);
try {
const created = await previewMutation.mutateAsync({
@@ -170,7 +177,11 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
options: optionsAtStart,
secrets: secretsAtStart,
});
if (!isCurrent()) return;
if (!isCurrent()) {
discardPreview({ sessionId: created.previewSession.id });
return;
}
activeSessionId.current = created.previewSession.id;
const snapshot = await loadPreviewQueries(candidateAtStart.data, created.previewSession.id);
if (!isCurrent()) return;
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
@@ -178,6 +189,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
...snapshot,
session: created.previewSession,
outcome: failed > 0 ? "error" : "success",
stale: false,
});
if (failed > 0) {
showWarningNotification({
@@ -198,13 +210,14 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}
} catch {
if (!isCurrent()) return;
discardCurrentSession();
input.setPreview((current) => ({ ...current, outcome: "error" }));
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
} finally {
if (activePreviewGeneration.current === generation) {
activePreviewGeneration.current = null;
setPreviewPending(false);
if (!isCurrent()) setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
if (!isCurrent()) setPreview((current) => ({ ...current, session: null, outcome: "idle", stale: true }));
}
}
};
@@ -1,5 +1,7 @@
import { redirect } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core";
import { Button, Container, Group, Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -8,6 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { CustomWidgetBetaBanner } from "../../_beta-banner";
import { CustomWidgetForm } from "../../_custom-widget-form";
import { CustomWidgetRepair } from "../../_custom-widget-repair";
import { FormErrorBoundary } from "../../_form-error-boundary";
interface EditCustomWidgetPageProps {
@@ -20,14 +23,27 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
redirect("/manage/custom-widgets");
}
const params = await props.params;
const raw = await api.customWidget.getRaw({ id: params.id }).catch(catchTrpcNotFound);
if (raw.issues.length > 0)
return (
<Container fluid>
<CustomWidgetRepair definition={raw} />
</Container>
);
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
const t = await getI18n("customWidget.package.conversion");
return (
<>
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, definition.name]])} nonInteractable={["edit"]} />
<Container fluid>
<Stack>
<Title>{definition.name}</Title>
<Group justify="space-between">
<Title>{definition.name}</Title>
<Button component={Link} href={`/manage/custom-widgets/packages/convert/${params.id}`} variant="light">
{t("title")}
</Button>
</Group>
<CustomWidgetBetaBanner />
<FormErrorBoundary>
<CustomWidgetForm
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { Container, Stack } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { PackageWorkspace } from "../_package-workspace";
export default async function WidgetPackagePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ boardId?: string }>;
}) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const { id } = await params;
const { boardId } = await searchParams;
const installation = await api.customWidget.package.get({ id }).catch(catchTrpcNotFound);
return (
<Container fluid>
<DynamicBreadcrumb dynamicMappings={new Map([[id, installation.name]])} />
<Stack>
<PackageWorkspace installation={installation} userId={session.user.id} initialBoardId={boardId} />
</Stack>
</Container>
);
}
@@ -0,0 +1,293 @@
"use client";
import { Alert, Button, Divider, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useState, useEffect, useRef } from "react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
import { createId } from "@homarr/common";
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
import { packageDraftToolContracts } from "~/components/assistant/assistant-package-draft-contracts";
import type {
PackageDraftReview,
ProposePackageChangesArgs,
} from "~/components/assistant/assistant-package-draft-contracts";
import {
notifyPackageDraftChanged,
registerAssistantPackageDraft,
} from "~/components/assistant/assistant-package-draft-registry";
import { packageAssistantSdkContext } from "@homarr/custom-widgets/authoring-prompt";
import { recoveryDocument } from "@homarr/custom-widgets/workbench/package";
import type { PackageDocument } from "@homarr/custom-widgets/workbench/package";
import type { usePackageWorkspace } from "./_use-package-workspace";
export function PackageAssistant() {
const t = useI18n("customWidget.package.assistant");
const assistant = useOptionalHomarrAssistant();
const [request, setRequest] = useState("");
const [opened, setOpened] = useState(false);
const [sendFailed, setSendFailed] = useState(false);
const send = (intent: string) => {
if (!assistant?.enabled || assistant.isRunning) return;
setSendFailed(false);
assistant.open();
if (
assistant.sendPrompt(
`${intent}\nWork on the currently visible trusted widget package v3 draft. First call read_widget_package_draft, then propose_widget_package_changes against its exact revision. Read relevant source examples through that tool when helpful. Keep credentials in local connections. Propose reviewable changes; do not save, activate, install or claim preview success.`,
)
) {
setOpened(false);
setRequest("");
} else setSendFailed(true);
};
return (
<Popover opened={opened} onChange={setOpened} width={320} withinPortal position="bottom-start">
<Popover.Target>
<Button
size="xs"
variant="light"
leftSection={<IconSparkles size={14} />}
onClick={() => setOpened((value) => !value)}
aria-expanded={opened}
>
{t("open")}
</Button>
</Popover.Target>
<Popover.Dropdown>
{!assistant?.enabled && (
<Stack gap="sm">
<Text size="sm">{assistant?.unavailableDescription || t("unavailable")}</Text>
<Button component={Link} href="/manage/assistant" variant="light" size="xs">
{t("setup")}
</Button>
<Text size="xs" c="dimmed">
{t("manual")}
</Text>
</Stack>
)}
{assistant?.enabled && (
<>
{assistant.isRunning && <Alert mb="xs">{t("busy")}</Alert>}
{sendFailed && (
<Alert color="yellow" mb="xs">
{t("sendFailed")}
</Alert>
)}
<Text size="xs" c="dimmed" mb="xs">
{t("context")}
</Text>
<Textarea
mx="xs"
mb="xs"
label={t("request")}
placeholder={t("requestPlaceholder")}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
minRows={3}
/>
<Group px="xs" pb="xs">
<Button size="xs" disabled={!request.trim() || assistant.isRunning} onClick={() => send(request)}>
{t("send")}
</Button>
</Group>
<Divider my="xs" />
<Stack gap={4}>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("createPrompt"))}
>
{t("create")}
</Button>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("improvePrompt"))}
>
{t("improve")}
</Button>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("advancedPrompt"))}
>
{t("advanced")}
</Button>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("fixPrompt"))}
>
{t("fix")}
</Button>
</Stack>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
type Workspace = ReturnType<typeof usePackageWorkspace>;
const maximumContextCharacters = 180_000;
export function usePackageAssistant(workspace: Workspace) {
const identity = useRef({ document: workspace.document, revision: createId() });
if (identity.current.document !== workspace.document)
identity.current = { document: workspace.document, revision: createId() };
const revision = identity.current.revision;
const latest = useRef({ workspace, revision });
latest.current = { workspace, revision };
const readState = useRef({ revision: "", paths: new Set<string>(), metadata: false });
useEffect(
() =>
registerAssistantPackageDraft({
read(input) {
const { workspace: state, revision: currentRevision } = latest.current;
const sanitized = recoveryDocument(state.document);
if (readState.current.revision !== currentRevision)
readState.current = { revision: currentRevision, paths: new Set(), metadata: false };
const metadataOmitted = sanitized.metadata.length > 60_000;
if (!metadataOmitted) readState.current.metadata = true;
const files: Record<string, string> = {};
const omittedFiles: string[] = [];
let remaining = maximumContextCharacters;
for (const path of input.paths ?? Object.keys(sanitized.files)) {
const value = sanitized.files[path];
if (value === undefined || value.length > remaining) {
omittedFiles.push(path);
continue;
}
files[path] = value;
remaining -= value.length;
readState.current.paths.add(path);
}
const redactedFiles = Object.keys(sanitized.files).filter(
(path) => sanitized.files[path] !== state.document.files[path],
);
let previewEvidence: unknown = { status: "not-previewed-current-candidate" };
if (state.preview && !state.stale)
previewEvidence = {
status: "compiled-and-preview-session-created",
revision: currentRevision,
artifactDigest: state.preview.displayData.artifactDigest,
browserRenderingVerified: false,
liveActions: state.preview.liveActions,
};
return {
installationId: state.installationId,
saved: !state.editor.dirty,
revision: currentRevision,
name: sanitized.name,
metadata: metadataOmitted ? undefined : sanitized.metadata,
metadataOmitted,
selectedFile: state.selected,
filePaths: Object.keys(sanitized.files),
files,
omittedFiles,
oversizedFiles: Object.entries(sanitized.files)
.filter(([, value]) => value.length > maximumContextCharacters)
.map(([path]) => path),
contextLimits: {
fileCharacters: maximumContextCharacters,
metadataCharacters: 60_000,
oversizedFilesRequireManualEdit: true,
},
redactedFiles,
metadataRedacted: sanitized.metadata !== state.document.metadata,
diagnostics: recoveryDocument({ name: "", metadata: state.error, files: {} }).metadata.slice(0, 4000),
schemaValid: state.parsed.success,
validationIssues: state.parsed.success
? []
: state.parsed.error.issues.map(({ path, message }) => ({ path, message })),
sdk: packageAssistantSdkContext,
reference: input.reference ? widgetReferencePackages[input.reference] : undefined,
previewEvidence,
excluded: [
"local connection bindings",
"connection settings and credentials",
"preview option values",
"runtime response data",
],
};
},
review(input) {
return prepare(input, latest.current, readState.current).review;
},
apply(input) {
const prepared = prepare(input, latest.current, readState.current);
if (!prepared.review.available || !prepared.next) return { applied: false, error: prepared.review.error };
latest.current.workspace.resetExecution();
readState.current = { revision: "", paths: new Set(), metadata: false };
latest.current.workspace.edit(prepared.next);
return { applied: true };
},
}),
[],
);
useEffect(() => {
notifyPackageDraftChanged();
}, [revision, workspace.busy, workspace.preview, workspace.error]);
}
function prepare(
input: ProposePackageChangesArgs,
{ workspace, revision }: { workspace: Workspace; revision: string },
read: { revision: string; paths: Set<string>; metadata: boolean },
): { review: PackageDraftReview; next?: PackageDocument } {
const parsed = packageDraftToolContracts.propose_widget_package_changes.parameters.safeParse(input);
if (!parsed.success) return unavailable("The proposed patch is invalid.");
if (revision !== input.revision || read.revision !== revision)
return unavailable("The draft changed. Ask the Assistant to read the current draft and propose again.");
if (workspace.busy) return unavailable("Wait for the current save or preview to finish.");
const current = workspace.document;
if (input.metadata !== undefined && !read.metadata)
return unavailable(
"The manifest is too large for Assistant context. Edit it directly before proposing a replacement.",
);
const sanitized = recoveryDocument(current);
if (input.metadata !== undefined && sanitized.metadata !== current.metadata)
return unavailable(
"The manifest contains redacted credentials. Move them to a local connection before replacing this file.",
);
if (input.name !== undefined && sanitized.name !== current.name)
return unavailable("The name contains redacted credentials. Edit it directly before proposing a replacement.");
const next = {
name: input.name ?? current.name,
metadata: input.metadata ?? current.metadata,
files: { ...current.files },
};
const changes: PackageDraftReview["changes"] = [];
if (next.name !== current.name) changes.push({ path: "Installation name", before: current.name, after: next.name });
if (next.metadata !== current.metadata)
changes.push({ path: "widget.json", before: current.metadata, after: next.metadata });
for (const [path, value] of Object.entries(input.files)) {
if (Object.hasOwn(current.files, path) && !read.paths.has(path))
return unavailable(`Read '${path}' before proposing changes to it.`);
if (sanitized.files[path] !== current.files[path])
return unavailable(
`'${path}' contains redacted credentials. Move them to local connections before replacing this file.`,
);
if (value === null) delete next.files[path];
else next.files[path] = value;
if (value !== current.files[path]) changes.push({ path, before: current.files[path] ?? "", after: value ?? "" });
}
if (
Object.keys(next.files).length > 256 ||
Object.values(next.files).reduce((size, file) => size + file.length, 0) > 20_000_000
)
return unavailable("The proposed draft exceeds package size limits.");
return { next, review: { available: true, changes } };
}
const unavailable = (error: string) => ({ review: { available: false, error, changes: [] } });
@@ -0,0 +1,267 @@
"use client";
import { extractErrorMessage } from "@homarr/common";
import { useState } from "react";
import {
Alert,
Button,
Checkbox,
Group,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Accordion,
Table,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
export function PackageCollectors({ itemId, queries }: { itemId: string; queries: string[] }) {
const t = useI18n("customWidget.package");
const collectors = clientApi.customWidget.package.collectors.useQuery({ itemId });
const save = clientApi.customWidget.package.saveCollector.useMutation();
const [name, setName] = useState("");
const [handler, setHandler] = useState<string | null>(queries[0] ?? null);
const [input, setInput] = useState("{}");
const [valuePath, setValuePath] = useState("");
const [unit, setUnit] = useState("");
const [intervalSeconds, setIntervalSeconds] = useState(60);
const [retentionDays, setRetentionDays] = useState(7);
const [maximumPoints, setMaximumPoints] = useState(2000);
const [enabled, setEnabled] = useState(false);
const [error, setError] = useState("");
const submit = () => {
if (!handler) return;
try {
const params: unknown = JSON.parse(input);
save.mutate(
{
itemId,
name,
handler,
input: params,
valuePath,
unit,
intervalSeconds,
retentionDays,
maximumPoints,
enabled,
},
{
onSuccess: () => {
setName("");
setEnabled(false);
void collectors.refetch();
},
onError: (cause) => setError(cause.message),
},
);
} catch (cause) {
setError(extractErrorMessage(cause));
}
};
return (
<Stack>
<Text size="sm">{t("collectionDescription")}</Text>
{(collectors.data ?? []).map((collector) => (
<CollectorRow key={collector.id} collector={collector} onChange={() => void collectors.refetch()} />
))}
{queries.length > 0 && (
<Paper withBorder p="sm">
<Stack gap="xs">
<TextInput
label={t("collectorName")}
value={name}
onChange={(event) => setName(event.currentTarget.value)}
/>
<Select label={t("queryHandler")} data={queries} value={handler} onChange={setHandler} />
<Textarea label={t("queryInput")} value={input} onChange={(event) => setInput(event.currentTarget.value)} />
<TextInput
label={t("valuePath")}
value={valuePath}
onChange={(event) => setValuePath(event.currentTarget.value)}
/>
<TextInput label={t("unit")} value={unit} onChange={(event) => setUnit(event.currentTarget.value)} />
<Group grow>
<NumberInput
label={t("collectionInterval")}
value={intervalSeconds}
min={15}
max={86400}
onChange={(value) => {
if (typeof value === "number") setIntervalSeconds(value);
}}
/>
<NumberInput
label={t("retentionDays")}
value={retentionDays}
min={1}
max={365}
onChange={(value) => {
if (typeof value === "number") setRetentionDays(value);
}}
/>
<NumberInput
label={t("maximumPoints")}
value={maximumPoints}
min={10}
max={10000}
onChange={(value) => {
if (typeof value === "number") setMaximumPoints(value);
}}
/>
</Group>
<Checkbox
label={t("enableCollection")}
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
/>
<Button loading={save.isPending} disabled={!name.trim() || !handler} onClick={submit}>
{t("saveCollector")}
</Button>
</Stack>
</Paper>
)}
{error && <Alert color="red">{error}</Alert>}
{collectors.error && <Alert color="red">{collectors.error.message}</Alert>}
</Stack>
);
}
function CollectorRow({
collector,
onChange,
}: {
collector: RouterOutputs["customWidget"]["package"]["collectors"][number];
onChange(): void;
}) {
const t = useI18n("customWidget.package");
const save = clientApi.customWidget.package.saveCollector.useMutation();
const remove = clientApi.customWidget.package.removeCollector.useMutation();
const history = clientApi.widget.customApi.packageHistory.useQuery({
itemId: collector.itemId,
collectorId: collector.id,
maximumPoints: 20,
});
const [discard, setDiscard] = useState(false);
return (
<Paper withBorder p="sm">
<Stack gap="xs">
<Text fw={500}>{collector.name}</Text>
<Text size="xs">
{collector.handler} · {collector.intervalSeconds}s · {collector.unit}
</Text>
<Text size="xs" ff="monospace">
{collector.id}
</Text>
<Group>
<Button
variant="light"
size="xs"
loading={save.isPending}
onClick={() => save.mutate({ ...collector, enabled: !collector.enabled }, { onSuccess: onChange })}
>
{collector.enabled ? t("pauseCollection") : t("enableCollection")}
</Button>
<Button variant="subtle" size="xs" onClick={() => void history.refetch()}>
{t("refresh")}
</Button>
</Group>
{history.data && <PackageHistorySummary data={history.data} />}
<Checkbox
checked={discard}
onChange={(event) => setDiscard(event.currentTarget.checked)}
label={t("discardCollectedHistory")}
/>
<Button
variant="subtle"
color="red"
disabled={!discard}
loading={remove.isPending}
onClick={() =>
remove.mutate({ itemId: collector.itemId, id: collector.id, discardHistory: true }, { onSuccess: onChange })
}
>
{t("removeCollector")}
</Button>
{save.error && <Alert color="red">{save.error.message}</Alert>}
{remove.error && <Alert color="red">{remove.error.message}</Alert>}
{history.error && <Alert color="red">{history.error.message}</Alert>}
</Stack>
</Paper>
);
}
type History = RouterOutputs["widget"]["customApi"]["packageHistory"];
function PackageHistorySummary({ data }: { data: History }) {
const t = useI18n("customWidget.package.historySummary");
const latest = data.samples.at(-1);
return (
<Stack gap="xs">
{latest && (
<Group justify="space-between">
<Text fw={600}>
{formatCollectedValue(latest.value)} {data.unit}
</Text>
<Text size="xs" c="dimmed">
{t("latest", { time: new Date(latest.timestamp).toLocaleString() })}
</Text>
</Group>
)}
{!latest && (
<Text size="sm" c="dimmed">
{t("empty")}
</Text>
)}
{"lastError" in data && data.lastError && <Alert color="yellow">{data.lastError}</Alert>}
<Accordion multiple>
<Accordion.Item value="samples">
<Accordion.Control>{t("samples", { count: data.samples.length })}</Accordion.Control>
<Accordion.Panel>
<div style={{ maxHeight: 220, overflow: "auto" }}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("time")}</Table.Th>
<Table.Th>{t("value")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.samples.toReversed().map((sample, index) => (
<Table.Tr key={`${sample.timestamp}-${index}`}>
<Table.Td>{new Date(sample.timestamp).toLocaleString()}</Table.Td>
<Table.Td>
{formatCollectedValue(sample.value)} {data.unit}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="technical">
<Accordion.Control>{t("details")}</Accordion.Control>
<Accordion.Panel>
<Text component="pre" size="xs" style={{ whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
{JSON.stringify(data, null, 2)}
</Text>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
function formatCollectedValue(value: unknown): string {
if (typeof value === "number") return value.toLocaleString();
if (typeof value === "string") return value.slice(0, 240);
return (JSON.stringify(value) ?? "—").slice(0, 240);
}
@@ -0,0 +1,298 @@
"use client";
import { extractErrorMessage, isRecord, getConnectionUrlSuggestion } from "@homarr/common";
import { useState } from "react";
import {
Alert,
Button,
Checkbox,
NumberInput,
Paper,
PasswordInput,
Select,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetJson } from "@homarr/widget-sdk/shared";
type Connection = RouterOutputs["customWidget"]["package"]["connections"][number];
type Authentication = "none" | "basic" | "bearer" | "headers" | "cookie" | "query";
const authKinds = ["none", "basic", "bearer", "headers", "cookie", "query"];
export function ConnectionEditor({ onSaved, connection }: { onSaved(): void; connection?: Connection }) {
const t = useI18n("customWidget.package");
const integrations = clientApi.integration.all.useQuery();
const save = clientApi.customWidget.package.saveConnection.useMutation();
const configuration: Record<string, unknown> = connection?.configuration ?? {};
const tls: Record<string, unknown> = isRecord(configuration.tls) ? configuration.tls : {};
let initialAuth: Authentication = "none";
if (typeof configuration.auth === "string" && authKinds.includes(configuration.auth))
initialAuth = configuration.auth as Authentication;
let initialKind: "http" | "integration" | "service" = "http";
if (configuration.kind === "integration") initialKind = "integration";
if (configuration.kind === "service") initialKind = "service";
const [name, setName] = useState(connection?.name ?? "");
const [kind, setKind] = useState<"http" | "integration" | "service">(initialKind);
const [integrationId, setIntegrationId] = useState<string | null>(connection?.integrationId ?? null);
const [baseUrl, setBaseUrl] = useState(typeof configuration.baseUrl === "string" ? configuration.baseUrl : "");
const [browserUrl, setBrowserUrl] = useState(
typeof configuration.browserUrl === "string" ? configuration.browserUrl : "",
);
const [auth, setAuth] = useState<Authentication>(initialAuth);
const [secret, setSecret] = useState("");
const [username, setUsername] = useState("");
const [headers, setHeaders] = useState("{}");
const [verifyTls, setVerifyTls] = useState(tls.rejectUnauthorized !== false);
const [timeoutMs, setTimeoutMs] = useState(
typeof configuration.timeoutMs === "number" ? configuration.timeoutMs : 30_000,
);
const [serviceType, setServiceType] = useState(
typeof configuration.serviceType === "string" ? configuration.serviceType : "custom",
);
const [serviceSettings, setServiceSettings] = useState(JSON.stringify(configuration.settings ?? {}, null, 2));
const [serviceSecrets, setServiceSecrets] = useState("");
const [replaceServiceSecrets, setReplaceServiceSecrets] = useState(false);
const [error, setError] = useState("");
const serverUrlSuggestion = getConnectionUrlSuggestion(baseUrl);
const browserUrlSuggestion = getConnectionUrlSuggestion(browserUrl);
const submit = async () => {
try {
let secrets: Record<string, string> = {};
if (auth === "basic") secrets = { username, password: secret };
if (auth === "bearer") secrets = { token: secret };
if (auth === "cookie") secrets = { cookie: secret };
if (auth === "headers" || auth === "query") {
const parsed: unknown = JSON.parse(headers);
if (!isRecord(parsed) || Object.values(parsed).some((value) => typeof value !== "string"))
throw new Error(t("stringMap"));
secrets = parsed as Record<string, string>;
}
let settings: Record<string, WidgetJson> | undefined;
if (kind === "service") {
const parsed: unknown = JSON.parse(serviceSettings);
if (!isRecord(parsed)) throw new Error(t("serviceSettingsObject"));
settings = parsed as Record<string, WidgetJson>;
const credentials: unknown = JSON.parse(serviceSecrets || "{}");
if (!isRecord(credentials) || Object.values(credentials).some((value) => typeof value !== "string"))
throw new Error(t("stringMap"));
secrets = credentials as Record<string, string>;
}
let replaceSecrets = !connection || initialAuth !== auth || secret !== "" || username !== "" || headers !== "{}";
if (kind === "service")
replaceSecrets = !connection || initialKind !== kind || serviceSecrets !== "" || replaceServiceSecrets;
let secretUpdates: Record<string, string> | undefined;
if (
connection &&
kind === "http" &&
initialKind === kind &&
initialAuth === auth &&
["basic", "bearer", "cookie"].includes(auth) &&
replaceSecrets
) {
secretUpdates = Object.fromEntries(Object.entries(secrets).filter(([, value]) => value !== ""));
replaceSecrets = false;
}
await save.mutateAsync({
id: connection?.id,
name,
integrationId,
configuration: {
kind,
serviceType: kind === "service" ? serviceType : undefined,
settings,
baseUrl: kind === "http" ? baseUrl || undefined : undefined,
browserUrl: browserUrl || undefined,
auth,
headers: isRecord(configuration.headers)
? Object.fromEntries(
Object.entries(configuration.headers).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
: {},
tls: {
rejectUnauthorized: verifyTls,
ca: typeof tls.ca === "string" ? tls.ca : undefined,
cert: typeof tls.cert === "string" ? tls.cert : undefined,
},
maxResponseBytes:
typeof configuration.maxResponseBytes === "number" ? configuration.maxResponseBytes : undefined,
timeoutMs,
},
secrets: replaceSecrets ? secrets : undefined,
secretUpdates,
});
setSecret("");
setHeaders("{}");
onSaved();
} catch (cause) {
setError(extractErrorMessage(cause));
}
};
return (
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{connection ? t("editConnection") : t("newConnection")}</Text>
{connection && <Alert color="yellow">{t("editConnectionImpact")}</Alert>}
<TextInput
label={t("connectionName")}
required
value={name}
onChange={(event) => setName(event.currentTarget.value)}
/>
<Select
label={t("connectionKind")}
value={kind}
data={[
{ value: "http", label: "HTTP" },
{ value: "integration", label: t("integration") },
{ value: "service", label: t("service") },
]}
onChange={(value) => {
if (value === "http" || value === "integration" || value === "service") setKind(value);
}}
/>
{kind === "integration" && (
<Select
searchable
label={t("integration")}
data={(integrations.data ?? []).map((integration) => ({ value: integration.id, label: integration.name }))}
value={integrationId}
onChange={setIntegrationId}
/>
)}
{kind === "service" && (
<>
<TextInput
label={t("serviceType")}
description={t("serviceTypeHelp")}
value={serviceType}
onChange={(event) => setServiceType(event.currentTarget.value)}
required
/>
<Textarea
label={t("serviceSettings")}
description={t("serviceSettingsHelp")}
value={serviceSettings}
onChange={(event) => setServiceSettings(event.currentTarget.value)}
autosize
minRows={4}
maxRows={12}
spellCheck={false}
/>
<Textarea
label={t("serviceSecrets")}
description={t("serviceSecretsHelp")}
value={serviceSecrets}
onChange={(event) => setServiceSecrets(event.currentTarget.value)}
autosize
minRows={3}
maxRows={8}
autoComplete="off"
spellCheck={false}
/>
{connection && (
<Checkbox
label={t("replaceServiceSecrets")}
checked={replaceServiceSecrets}
onChange={(event) => setReplaceServiceSecrets(event.currentTarget.checked)}
/>
)}
</>
)}
{kind === "http" && (
<>
<TextInput
label={t("serverUrl")}
description={t("localNetwork")}
placeholder={t("serverUrlExample")}
required
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
/>
{serverUrlSuggestion && (
<Button
size="compact-xs"
variant="subtle"
w="fit-content"
onClick={() => setBaseUrl(serverUrlSuggestion)}
>
{t("useUrlSuggestion", { url: serverUrlSuggestion })}
</Button>
)}
<TextInput
label={t("browserUrl")}
value={browserUrl}
onChange={(event) => setBrowserUrl(event.currentTarget.value)}
/>
{browserUrlSuggestion && (
<Button
size="compact-xs"
variant="subtle"
w="fit-content"
onClick={() => setBrowserUrl(browserUrlSuggestion)}
>
{t("useUrlSuggestion", { url: browserUrlSuggestion })}
</Button>
)}
<Select
label={t("authentication")}
value={auth}
data={["none", "basic", "bearer", "headers", "cookie", "query"]}
onChange={(value) => {
if (value && ["none", "basic", "bearer", "headers", "cookie", "query"].includes(value))
setAuth(value as typeof auth);
}}
/>
{auth === "basic" && (
<TextInput
autoComplete="off"
label={t("username")}
value={username}
onChange={(event) => setUsername(event.currentTarget.value)}
/>
)}
{["basic", "bearer", "cookie"].includes(auth) && (
<PasswordInput
autoComplete="new-password"
label={t("credential")}
value={secret}
onChange={(event) => setSecret(event.currentTarget.value)}
/>
)}
{(auth === "headers" || auth === "query") && (
<Textarea
autoComplete="off"
label={auth === "query" ? t("secretQuery") : t("secretHeaders")}
placeholder={t("headersExample")}
value={headers}
onChange={(event) => setHeaders(event.currentTarget.value)}
/>
)}
<Checkbox
label={t("verifyTls")}
checked={verifyTls}
onChange={(event) => setVerifyTls(event.currentTarget.checked)}
/>
<NumberInput
label={t("timeout")}
value={timeoutMs}
min={100}
max={300000}
onChange={(value) => {
if (typeof value === "number") setTimeoutMs(value);
}}
/>
</>
)}
{error && <Alert color="red">{error}</Alert>}
<Button loading={save.isPending} disabled={!name.trim()} onClick={() => void submit()}>
{t("saveConnection")}
</Button>
</Stack>
</Paper>
);
}
@@ -0,0 +1,337 @@
"use client";
import { extractErrorMessage } from "@homarr/common";
import { ConnectionEditor } from "./_package-connection-editor";
import type { RouterOutputs } from "@homarr/api";
import { useState, useEffect, useRef } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Alert, Button, Group, Paper, Stack, Text, MultiSelect, Select, Accordion, Badge } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
export function PackageConnections({
requirements,
bindings,
onChange,
onPendingChange,
onSave,
saving,
onConnectionSaved,
}: {
requirements: CustomWidgetPackage["connections"];
bindings: Record<string, string>;
onChange: Dispatch<SetStateAction<Record<string, string>>>;
onPendingChange(pending: boolean): void;
onSave?(): void;
saving: boolean;
onConnectionSaved?(): void;
}) {
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const connections = clientApi.customWidget.package.connections.useQuery();
const integrations = clientApi.integration.all.useQuery();
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [diagnostic, setDiagnostic] = useState<RouterOutputs["customWidget"]["package"]["diagnoseConnection"] | null>(
null,
);
const [diagnosticError, setDiagnosticError] = useState("");
const diagnose = clientApi.customWidget.package.diagnoseConnection.useMutation();
return (
<Stack>
<Text size="sm" c="dimmed">
{t("connectionsDescription")}
</Text>
{Object.keys(requirements).length === 0 && <Alert>{t("noConnections")}</Alert>}
{Object.entries(requirements).map(([name, requirement]) => (
<Paper key={name} withBorder p="sm">
<Stack gap="xs">
<PackageBindingPicker
name={name}
requirement={requirement}
connections={connections.data ?? []}
integrations={integrations.data ?? []}
bindings={bindings}
onChange={onChange}
onPendingChange={onPendingChange}
disabled={saving}
/>
{getWidgetConnectionBindingNames(name, requirement, bindings).map((bindingName) => (
<Group key={bindingName} gap="xs">
<Text size="sm" flex={1}>
{connections.data?.find((entry) => entry.id === bindings[bindingName])?.name ?? bindingName}
</Text>
<Button
variant="default"
size="xs"
loading={diagnose.isPending}
onClick={() => {
const id = bindings[bindingName];
if (!id) return;
setDiagnostic(null);
setDiagnosticError("");
diagnose.mutate(
{ id },
{
onSuccess: setDiagnostic,
onError: (error) => setDiagnosticError(error.message),
},
);
}}
>
{t("diagnose")}
</Button>
<Button
variant="subtle"
size="xs"
onClick={() => {
setCreating(false);
setEditingId(bindings[bindingName] ?? null);
}}
>
{t("editConnection")}
</Button>
</Group>
))}
</Stack>
</Paper>
))}
{diagnostic && <PackageConnectionDiagnostic result={diagnostic} />}
{diagnosticError && <Alert color="red">{diagnosticError}</Alert>}
{connections.error && <Alert color="red">{connections.error.message}</Alert>}
{integrations.error && <Alert color="red">{integrations.error.message}</Alert>}
<Group>
{Object.values(requirements).some((requirement) => requirement.kind === "integration") && (
<Button
component={Link}
href="/manage/integrations/new"
target="_blank"
rel="noopener noreferrer"
variant="light"
>
{t("createIntegration")}
</Button>
)}
<Button
variant="subtle"
onClick={() => {
void connections.refetch();
void integrations.refetch();
}}
>
{t("refresh")}
</Button>
<Button
variant="default"
onClick={() => {
setEditingId(null);
setCreating(!creating);
}}
>
{t("newConnection")}
</Button>
{onSave && (
<Button loading={saving} onClick={onSave}>
{t("saveBindings")}
</Button>
)}
</Group>
{(creating || editingId) && (
<ConnectionEditor
key={editingId ?? "new"}
connection={connections.data?.find((entry) => entry.id === editingId)}
onSaved={() => {
setCreating(false);
setEditingId(null);
void connections.refetch();
void utils.customWidget.package.guestGrants.invalidate();
void utils.customWidget.package.webhooks.invalidate();
onConnectionSaved?.();
}}
/>
)}
</Stack>
);
}
function PackageBindingPicker({
name,
requirement,
connections,
integrations,
bindings,
onChange,
onPendingChange,
disabled,
}: {
name: string;
requirement: CustomWidgetPackage["connections"][string];
connections: RouterOutputs["customWidget"]["package"]["connections"];
integrations: RouterOutputs["integration"]["all"];
bindings: Record<string, string>;
onChange: Dispatch<SetStateAction<Record<string, string>>>;
onPendingChange(pending: boolean): void;
disabled: boolean;
}) {
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const saveConnection = clientApi.customWidget.package.saveConnection.useMutation();
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const preparing = useRef(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const ids = [
...new Set(
getWidgetConnectionBindingNames(name, requirement, bindings)
.map((key) => bindings[key])
.filter((id) => typeof id === "string"),
),
];
const compatible = connections.filter((connection) =>
isWidgetConnectionCompatible(requirement, connection.configuration),
);
const choices = compatible.map((connection) => ({ value: connection.id, label: connection.name }));
if (requirement.kind === "integration") {
for (const integration of integrations) {
if (requirement.integrationKind && integration.kind !== requirement.integrationKind) continue;
if (compatible.some((connection) => connection.integrationId === integration.id)) continue;
choices.push({ value: `integration:${integration.id}`, label: integration.name });
}
}
// Keep unavailable bindings visible so a removed integration can be diagnosed or replaced.
for (const id of ids) {
if (!choices.some((choice) => choice.value === id))
choices.push({
value: id,
label: connections.find((connection) => connection.id === id)?.name ?? t("unavailableConnection"),
});
}
const change = async (values: string[]) => {
if (preparing.current || disabled) return;
preparing.current = true;
setPending(true);
onPendingChange(true);
setError("");
try {
const nextIds: string[] = [];
for (const value of values) {
if (!value.startsWith("integration:")) {
nextIds.push(value);
continue;
}
const integration = integrations.find((entry) => `integration:${entry.id}` === value);
if (!integration) throw new Error(t("unavailableConnection"));
const result = await saveConnection.mutateAsync({
name: integration.name,
integrationId: integration.id,
configuration: { kind: "integration" },
});
nextIds.push(result.id);
}
if (!mounted.current) return;
onChange((current) => {
const next = { ...current };
for (const key of getWidgetConnectionBindingNames(name, requirement, next)) delete next[key];
for (const [index, id] of nextIds.entries()) {
let key = name;
if (index > 0) key = `${name}:${id}`;
next[key] = id;
}
return next;
});
} catch (cause) {
if (mounted.current) setError(extractErrorMessage(cause));
} finally {
// Refresh the list independently; a failed refetch must not discard a selection.
if (values.some((value) => value.startsWith("integration:")))
void utils.customWidget.package.connections.invalidate();
preparing.current = false;
if (mounted.current) setPending(false);
onPendingChange(false);
}
};
const common = {
label: requirement.label,
description: requirement.description,
placeholder: t("chooseConnection"),
required: !requirement.optional,
searchable: true,
clearable: true,
disabled: pending || disabled,
data: choices,
nothingFoundMessage: t("noCompatibleConnections"),
};
return (
<Stack gap="xs" flex={1}>
{requirement.multiple ? (
<MultiSelect {...common} value={ids} onChange={(values) => void change(values)} />
) : (
<Select {...common} value={ids[0] ?? null} onChange={(value) => void change(value ? [value] : [])} />
)}
{error && <Alert color="red">{error}</Alert>}
</Stack>
);
}
type Diagnostic = RouterOutputs["customWidget"]["package"]["diagnoseConnection"];
function PackageConnectionDiagnostic({ result }: { result: Diagnostic }) {
const t = useI18n("customWidget.package.diagnostic");
let color = "yellow";
if (["connected", "integration", "fileReadable", "transportReachable"].includes(result.status)) color = "green";
if (["dnsError", "unavailable", "httpError", "serviceUnavailable"].includes(result.status)) color = "red";
return (
<Stack gap="xs">
<Alert color={color} title={t(`status.${result.status}`)}>
<Stack gap="xs">
<Text size="sm">{t(`help.${result.status}`)}</Text>
{"stage" in result && (
<Text size="xs">
{t("phase", { phase: t(`stage.${result.stage as "dns" | "connection" | "tls" | "response"}`) })}
</Text>
)}
{"hostname" in result && (
<Text size="sm" ff="monospace">
{result.hostname}
</Text>
)}
{result.addresses.length > 0 && (
<Group gap="xs">
{result.addresses.map(({ address }) => (
<Badge key={address} variant="outline" tt="none">
{address}
</Badge>
))}
</Group>
)}
{"httpStatus" in result && typeof result.httpStatus === "number" && typeof result.durationMs === "number" && (
<Text size="xs">{t("response", { status: result.httpStatus, milliseconds: result.durationMs })}</Text>
)}
{"sampleFields" in result && Array.isArray(result.sampleFields) && result.sampleFields.length > 0 && (
<Text size="xs">{t("fields", { fields: result.sampleFields.join(", ") })}</Text>
)}
</Stack>
</Alert>
<Accordion>
<Accordion.Item value="details">
<Accordion.Control>{t("details")}</Accordion.Control>
<Accordion.Panel>
<Text component="pre" size="xs" style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{JSON.stringify(result, null, 2)}
</Text>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Alert, Button, Group, Stack, Text, TextInput, Title, Skeleton } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { widgetReferencePackages } from "@homarr/widget-sdk/examples";
import { PackageWorkspace } from "./_package-workspace";
export function PackageConvert({ definitionId, name }: { definitionId: string; name: string }) {
const t = useI18n("customWidget.package.conversion");
const router = useRouter();
const convert = clientApi.customWidget.package.convertV2.useMutation();
const [nextName, setNextName] = useState(name);
return (
<Stack>
<Title order={2}>{t("title")}</Title>
<Text>{t("description")}</Text>
<Text size="sm" c="dimmed">
{t("savedSource")}
</Text>
<TextInput
label={t("name")}
value={nextName}
maxLength={128}
onChange={(event) => setNextName(event.currentTarget.value)}
/>
{convert.error && <Alert color="red">{convert.error.message}</Alert>}
<Group>
<Button
disabled={!nextName.trim()}
loading={convert.isPending}
onClick={() =>
convert.mutate(
{ definitionId, name: nextName },
{ onSuccess: (result) => router.push(result.managementPath) },
)
}
>
{t("create")}
</Button>
<Button component={Link} href={`/manage/custom-widgets/edit/${definitionId}`} variant="subtle">
{t("back")}
</Button>
</Group>
</Stack>
);
}
export function NativePackageWorkspace({
userId,
sourceItemId,
starter,
}: {
userId: string;
sourceItemId: string;
starter: keyof typeof widgetReferencePackages;
}) {
const t = useI18n("customWidget.package");
const preparation = clientApi.customWidget.package.prepareNative.useMutation();
const prepare = preparation.mutate;
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
prepare({ sourceItemId, starter });
}, [prepare, sourceItemId, starter]);
if (preparation.data)
return (
<PackageWorkspace
userId={userId}
initialSource={preparation.data.source}
initialName={preparation.data.name}
initialBindings={preparation.data.bindings}
initialBoardId={preparation.data.sourceBoardId}
/>
);
if (preparation.error)
return (
<Stack>
<Alert color="red">{preparation.error.message}</Alert>
<Button onClick={() => prepare({ sourceItemId, starter })}>{t("retryPreparation")}</Button>
</Stack>
);
return (
<Stack>
<Skeleton height={50} />
<Skeleton height={400} />
</Stack>
);
}
@@ -0,0 +1,222 @@
"use client";
import { extractErrorMessage } from "@homarr/common";
import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Alert, Button, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import {
PackageLanguageClient,
createPackageLanguageExtensions,
} from "@homarr/custom-widgets/workbench/package-language";
import type {
PackageLanguageDiagnostic,
PackageLanguageLocation,
} from "@homarr/custom-widgets/workbench/package-language";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "~/components/custom-widgets/code-editor";
import type { usePackageWorkspace } from "./_use-package-workspace";
export default function PackageLanguageEditor({ state }: { state: ReturnType<typeof usePackageWorkspace> }) {
const t = useI18n("customWidget.package.languageService");
const current = useRef(state);
current.current = state;
const [client, setClient] = useState<PackageLanguageClient>();
const [diagnostics, setDiagnostics] = useState<PackageLanguageDiagnostic[]>([]);
const [error, setError] = useState("");
const [checked, setChecked] = useState(false);
const [definition, setDefinition] = useState<PackageLanguageLocation>();
const [reveal, setReveal] = useState<PackageLanguageLocation>();
const [rename, setRename] = useState<{ path: string; position: number; name: string; revision: number }>();
const [operationPending, setOperationPending] = useState(false);
useEffect(() => {
try {
const service = new PackageLanguageClient();
setClient(service);
return () => service.dispose();
} catch (cause) {
setError(extractErrorMessage(cause));
}
}, []);
useEffect(() => {
client?.update(state.document.files);
}, [client, state.document.files]);
useEffect(() => {
setDefinition(undefined);
setRename(undefined);
setDiagnostics([]);
setChecked(false);
setError("");
}, [state.selected]);
const navigate = (location: PackageLanguageLocation) => {
setReveal(location);
if (location.declaration) setDefinition(location);
else {
setDefinition(undefined);
current.current.setSelected(location.path);
}
};
const extensions = useMemo(() => {
if (!client || state.language !== "tsx") return undefined;
const path = state.selected;
return createPackageLanguageExtensions(client, path, {
navigate,
diagnostics: (next) => {
if (current.current.selected !== path) return;
setDiagnostics(next);
setChecked(true);
setError("");
},
error: (message) => {
if (current.current.selected === path) setError(message);
},
rename: (position, name) => setRename({ path, position, name, revision: client.getRevision() }),
});
}, [client, state.language, state.selected]);
const reportError = (cause: unknown) => {
if (cause instanceof Error && cause.name === "AbortError") return;
setError(extractErrorMessage(cause));
};
const format = async () => {
if (!client) return;
const path = state.selected;
const submittedFiles = state.document.files;
client.update(state.document.files);
setOperationPending(true);
try {
const source = await client.request("format", { path });
const latest = current.current;
if (latest.selected !== path || latest.document.files !== submittedFiles) return;
latest.edit({ ...latest.document, files: { ...latest.document.files, [path]: source } });
} catch (cause) {
reportError(cause);
} finally {
setOperationPending(false);
}
};
const renameSymbol = async () => {
if (!client || !rename) return;
if (client.getRevision() !== rename.revision) {
setError(t("changed"));
return;
}
const submittedFiles = current.current.document.files;
setOperationPending(true);
try {
const files = await client.request("rename", rename);
const latest = current.current;
if (latest.document.files !== submittedFiles) {
setError(t("changed"));
return;
}
latest.edit({ ...latest.document, files: { ...latest.document.files, ...files } });
setRename(undefined);
} catch (cause) {
reportError(cause);
} finally {
setOperationPending(false);
}
};
let status = t("loading");
if (checked) status = t("ready");
if (diagnostics.length) status = t("problems", { count: diagnostics.length });
if (error) status = t("unavailable");
let visiblePath = state.selected;
let value = state.editorValue;
let language = state.language;
if (definition?.declaration) {
visiblePath = definition.path;
value = definition.declaration.source;
language = "tsx";
}
let range: { from: number; to: number } | undefined;
if (reveal?.path === visiblePath) range = reveal;
return (
<Stack gap="xs" miw={0}>
{rename && (
<Group align="end" gap="xs">
<TextInput
label={t("rename")}
value={rename.name}
onChange={(event) => setRename({ ...rename, name: event.currentTarget.value })}
/>
<Button size="sm" loading={operationPending} disabled={state.busy} onClick={() => void renameSymbol()}>
{t("applyRename")}
</Button>
<Button size="sm" variant="subtle" onClick={() => setRename(undefined)}>
{t("cancel")}
</Button>
</Group>
)}
{definition?.declaration && (
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{t("declaration", { line: definition.declaration.line })}
</Text>
<Button size="compact-xs" variant="subtle" onClick={() => setDefinition(undefined)}>
{t("backToSource")}
</Button>
</Group>
)}
<CodeEditor
key={visiblePath}
id="package-source"
label={visiblePath}
language={language}
value={value}
hideHistoryActions
readOnly={state.busy || operationPending || Boolean(definition?.declaration)}
height="max(280px, calc(100dvh - 310px))"
extensions={definition?.declaration ? undefined : extensions}
revealRange={range}
onFormat={state.language === "tsx" && client ? () => void format() : undefined}
status={
state.language === "tsx" &&
!definition?.declaration && (
<Tooltip label={t("help")} multiline maw={380}>
<Text size="xs" c="dimmed">
{status}
</Text>
</Tooltip>
)
}
onChange={(next) => {
if (state.selected === "/widget.json") state.edit({ ...state.document, metadata: next }, state.selected);
else
state.edit(
{ ...state.document, files: { ...state.document.files, [state.selected]: next } },
state.selected,
);
}}
/>
{error && (
<Alert color="yellow" title={t("unavailable")}>
{error}
</Alert>
)}
{!definition && diagnostics.length > 0 && (
<Accordion variant="default">
<Accordion.Item value="types">
<Accordion.Control>{t("problems", { count: diagnostics.length })}</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
{diagnostics.slice(0, 20).map((diagnostic, index) => (
<Button
key={index}
variant="subtle"
size="compact-xs"
justify="start"
styles={{ label: { whiteSpace: "normal", textAlign: "start" } }}
onClick={() => navigate(diagnostic)}
>
{`TS${diagnostic.code}: ${diagnostic.message}`}
</Button>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
);
}
@@ -0,0 +1,419 @@
"use client";
import { extractErrorMessage, isRecord } from "@homarr/common";
import { useRef, useState } from "react";
import {
Alert,
Avatar,
Badge,
Button,
Group,
Stack,
Text,
ActionIcon,
Menu,
Modal,
Checkbox,
Anchor,
} from "@mantine/core";
import {
IconRefresh,
IconExternalLink,
IconPackage,
IconPencil,
IconUpload,
IconBuildingStore,
IconCopy,
IconDots,
IconDownload,
IconLayoutDashboard,
IconPlayerPause,
IconPlayerPlay,
IconPlugConnected,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { customWidgetArchiveSchema, WIDGET_COLLECTION_MAX_BYTES } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results";
import { showErrorNotification } from "@homarr/notifications";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { downloadPackage } from "@homarr/custom-widgets/workbench/package";
export function PackageList({ initialData }: { initialData: RouterOutputs["customWidget"]["package"]["list"] }) {
const t = useI18n("customWidget.package");
const session = useWorkshopSession();
const installations = clientApi.customWidget.package.list.useQuery(undefined, { initialData });
const entries = installations.data ?? [];
return (
<Stack>
<Group justify="space-between" align="flex-start">
<Text c="dimmed" maw={700}>
{t("libraryDescription")}
</Text>
<WorkshopAccountButton session={session} />
</Group>
{entries.length === 0 && (
<NoResults
icon={IconPackage}
title={t("empty")}
action={{ href: "/manage/custom-widgets/packages/new", label: t("create") }}
/>
)}
<ManageCollectionList ariaLabel={t("title")}>
{entries.map((entry) => (
<PackageRow key={entry.id} entry={entry} workshopUserId={session.user?.id} />
))}
</ManageCollectionList>
</Stack>
);
}
type PackageEntry = RouterOutputs["customWidget"]["package"]["list"][number];
function PackageRow({ entry, workshopUserId }: { entry: PackageEntry; workshopUserId?: string }) {
const t = useI18n("customWidget.package");
const tCommon = useI18n("common");
const update = clientApi.customWidget.package.checkUpdate.useQuery(
{ id: entry.id },
{ enabled: false, retry: false },
);
const stage = clientApi.customWidget.package.stageWorkshopUpdate.useMutation();
const [opened, setOpened] = useState(false);
const [replace, setReplace] = useState(false);
let status = t("draftOnly");
let color = "gray";
if (entry.activeArtifactId) {
status = t("disabled");
if (entry.enabled) {
status = t("active");
color = "green";
}
}
const version = entry.activeVersion ?? entry.draftVersion;
const updateData = update.data;
const latest = updateData?.latest;
const ownsListing = Boolean(workshopUserId && entry.workshop?.author === workshopUserId);
return (
<>
<ManageCollectionItem
leading={
<Avatar size={40} radius="sm" color="pink">
<IconPackage size={20} stroke={1.5} />
</Avatar>
}
title={<Text fw={600}>{entry.name}</Text>}
badges={
<Group gap="xs">
<Badge color={color} variant="light">
{status}
</Badge>
{version && (
<Badge color="gray" variant="outline">
v{version}
</Badge>
)}
{entry.activeVersion && entry.draftVersion !== entry.activeVersion && (
<Badge variant="light" color="blue">
{t("draftVersion", { version: entry.draftVersion ?? "?" })}
</Badge>
)}
</Group>
}
description={
<Stack gap={4}>
{entry.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{entry.description}
</Text>
)}
<Group gap="xs">
<Text size="xs" c="dimmed">
{t("placements", { count: entry.placementCount })}
</Text>
{entry.author && (
<Text size="xs" c="dimmed">
{t("byAuthor", { name: entry.author })}
</Text>
)}
{ownsListing && (
<Text size="xs" c="dimmed">
{t("yourWorkshopListing")}
</Text>
)}
{entry.workshopUrl && (
<Anchor href={entry.workshopUrl} target="_blank" rel="noopener noreferrer" size="xs">
{t("openWorkshop")} <IconExternalLink size={12} />
</Anchor>
)}
</Group>
{entry.workshop && (
<Button
variant="subtle"
size="xs"
w="fit-content"
maw="100%"
leftSection={<IconRefresh size={14} />}
loading={update.isFetching}
onClick={() => {
if (updateData?.available) {
setOpened(true);
return;
}
void update.refetch().then((result) => {
if (result.data?.available) setOpened(true);
});
}}
>
{updateData?.available && latest
? t("updateAvailable", { version: latest.version })
: t("checkUpdates")}
</Button>
)}
{update.error && (
<Text size="xs" c="red">
{update.error.message}
</Text>
)}
{updateData && !updateData.available && !update.error && (
<Text size="xs" c="dimmed">
{t("upToDate")}
</Text>
)}
</Stack>
}
actions={
<Group gap="xs" wrap="wrap">
<Button
visibleFrom="sm"
component={Link}
href={`/manage/custom-widgets/packages/${entry.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{tCommon("action.edit")}
</Button>
<ActionIcon
hiddenFrom="sm"
component={Link}
href={`/manage/custom-widgets/packages/${entry.id}`}
variant="default"
aria-label={tCommon("action.edit")}
size="lg"
>
<IconPencil size={16} />
</ActionIcon>
<PackageRowActions entry={entry} />
</Group>
}
actionsAlignment="center"
/>
<Modal opened={opened} onClose={() => setOpened(false)} title={t("reviewUpdate")} centered>
<Stack>
<Text fw={600}>
{entry.name} · {entry.workshop?.version} → {latest?.version}
</Text>
<Text size="sm" c="dimmed">
{t("updateReviewDescription")}
</Text>
{latest?.changelog && (
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{latest.changelog}
</Text>
)}
{updateData?.hasLocalChanges && (
<Checkbox
checked={replace}
onChange={(event) => setReplace(event.currentTarget.checked)}
label={t("replaceModifiedDraft")}
/>
)}
{stage.error && <Alert color="red">{stage.error.message}</Alert>}
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)}>
{tCommon("action.cancel")}
</Button>
<Button
disabled={!latest || (updateData?.hasLocalChanges && !replace)}
loading={stage.isPending}
onClick={() => {
if (!latest || !updateData?.draftDigest) return;
stage.mutate(
{
id: entry.id,
releaseId: latest.id,
replaceLocalDraft: true,
expectedDraftDigest: updateData.draftDigest,
},
{ onSuccess: (result) => window.location.assign(result.managementPath) },
);
}}
>
{t("reviewUpdate")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
export function PackageImportButton() {
const t = useI18n("customWidget.package");
const input = useRef<HTMLInputElement>(null);
const save = clientApi.customWidget.package.saveDraft.useMutation();
const importArchive = clientApi.customWidget.package.import.useMutation();
const importCollection = clientApi.customWidget.package.importCollection.useMutation();
const importing = useRef(false);
const [reading, setReading] = useState(false);
const [error, setError] = useState("");
const importFile = async (file: File) => {
if (importing.current) return;
importing.current = true;
setReading(true);
setError("");
try {
if (file.size > WIDGET_COLLECTION_MAX_BYTES) throw new Error(t("collections.importTooLarge"));
const raw: unknown = JSON.parse(await file.text());
if (!isRecord(raw)) throw new Error(t("invalidPackage"));
if (raw.format === "homarr-widget-collection-v1") {
const result = await importCollection.mutateAsync({ archive: raw });
window.location.assign(`/manage/custom-widgets/packages/collections/${result.importId}`);
return;
}
if (file.size > 25_000_000) throw new Error(t("importTooLarge"));
if (raw.format === "homarr-widget-archive-v3") {
const result = await importArchive.mutateAsync({ archive: customWidgetArchiveSchema.parse(raw) });
window.location.assign(result.managementPath);
return;
}
let source = raw;
if (isRecord(raw.source)) source = raw.source;
let name = file.name.replace(/\.json$/iu, "");
if (isRecord(source.manifest) && typeof source.manifest.name === "string") name = source.manifest.name;
const result = await save.mutateAsync({ name, source });
window.location.assign(result.managementPath);
} catch (cause) {
setError(extractErrorMessage(cause));
} finally {
importing.current = false;
setReading(false);
}
};
return (
<Stack gap="xs">
<input
ref={input}
type="file"
accept="application/json,.json"
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void importFile(file);
}}
/>
<Button
variant="default"
leftSection={<IconUpload size={16} />}
loading={reading || save.isPending || importArchive.isPending || importCollection.isPending}
onClick={() => input.current?.click()}
>
{t("import")}
</Button>
{error && (
<Alert color="red" withCloseButton onClose={() => setError("")}>
{error}
</Alert>
)}
</Stack>
);
}
function PackageRowActions({ entry }: { entry: RouterOutputs["customWidget"]["package"]["list"][number] }) {
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const fork = clientApi.customWidget.package.fork.useMutation();
const enable = clientApi.customWidget.package.setEnabled.useMutation();
const busy = fork.isPending || enable.isPending;
const path = `/manage/custom-widgets/packages/${entry.id}`;
const run = async (action: () => Promise<unknown>) => {
try {
await action();
await utils.customWidget.package.list.invalidate();
} catch (error) {
showErrorNotification({ title: t("title"), message: extractErrorMessage(error) });
}
};
return (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("actionsMenu")} loading={busy}>
<IconDots size={20} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item component={Link} href={`${path}?tab=installation`} leftSection={<IconLayoutDashboard size={16} />}>
{t("addToBoard")}
</Menu.Item>
<Menu.Item component={Link} href={`${path}?tab=connections`} leftSection={<IconPlugConnected size={16} />}>
{t("connections")}
</Menu.Item>
<Menu.Item
component={Link}
href={`/manage/custom-widgets/publish/${entry.id}?kind=package`}
leftSection={<IconBuildingStore size={16} />}
>
{t("publishWorkshop")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
disabled={busy}
leftSection={<IconCopy size={16} />}
onClick={() =>
void run(async () => {
await fork.mutateAsync({ id: entry.id, name: t("forkDefaultName", { name: entry.name }).slice(0, 128) });
})
}
>
{t("duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconDownload size={16} />}
onClick={() =>
void run(async () => {
const draft = await utils.customWidget.package.get.fetch({ id: entry.id });
downloadPackage(entry.name, draft.source);
})
}
>
{t("exportDraft")}
</Menu.Item>
{entry.activeArtifactId && (
<Menu.Item
leftSection={<IconDownload size={16} />}
onClick={() =>
void run(async () => {
downloadPackage(entry.name, await utils.customWidget.package.export.fetch({ id: entry.id }));
})
}
>
{t("exportActive")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
disabled={busy || !entry.activeArtifactId}
leftSection={entry.enabled ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />}
onClick={() => void run(() => enable.mutateAsync({ id: entry.id, enabled: !entry.enabled }))}
>
{entry.enabled ? t("disable") : t("enable")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,264 @@
"use client";
import { extractErrorMessage } from "@homarr/common";
import { useMemo, useState } from "react";
import { Accordion, Alert, Button, Checkbox, Group, Paper, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { customWidgetPackageSchema, stringifyWidgetJson } from "@homarr/custom-widgets/package";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import { AddPackagePlacement, PackagePlacements, PackageConvertedPlacements } from "./_package-placements";
import { PackageArtifactReview } from "./_package-workshop";
import { downloadPackage } from "@homarr/custom-widgets/workbench/package";
type Installation = RouterOutputs["customWidget"]["package"]["get"];
export function PackageOperations({
installation,
initialBoardId,
source,
options,
dirty,
trusted,
connectionsDirty,
onTrustChange,
onSave,
saving,
onError,
onMessage,
}: {
installation?: Installation;
initialBoardId?: string;
source?: CustomWidgetPackage;
options: Record<string, unknown>;
dirty: boolean;
trusted: boolean;
connectionsDirty: boolean;
onTrustChange(trusted: boolean): void;
onSave(): void;
saving: boolean;
onError(error: string): void;
onMessage(message: string): void;
}) {
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const details = clientApi.customWidget.package.get.useQuery(
{ id: installation?.id ?? "" },
{ enabled: Boolean(installation), initialData: installation },
);
const current = details.data;
const matchesReviewedSource = useMemo(() => {
if (!source) return false;
const saved = customWidgetPackageSchema.safeParse(current?.source);
return saved.success && stringifyWidgetJson(saved.data) === stringifyWidgetJson(source);
}, [current?.source, source]);
const inspected = clientApi.customWidget.package.inspectArtifact.useQuery(
{ id: installation?.id ?? "" },
{ enabled: Boolean(installation) },
);
const activate = clientApi.customWidget.package.activate.useMutation();
const rollback = clientApi.customWidget.package.rollback.useMutation();
const enable = clientApi.customWidget.package.setEnabled.useMutation();
const exported = clientApi.customWidget.package.export.useQuery(
{ id: installation?.id ?? "" },
{ enabled: Boolean(current?.activeArtifactId) },
);
const activeSource = useMemo(() => customWidgetPackageSchema.safeParse(exported.data?.source), [exported.data]);
const [rollbackConfirmed, setRollbackConfirmed] = useState(false);
const refresh = async () => {
await Promise.all([
utils.customWidget.package.get.invalidate(),
utils.customWidget.package.list.invalidate(),
utils.customWidget.package.export.invalidate(),
utils.customWidget.package.guestGrants.invalidate(),
utils.customWidget.package.webhooks.invalidate(),
utils.customWidget.package.inspectArtifact.invalidate(),
utils.widget.customApi.getData.invalidate(),
]);
};
const run = async (action: () => Promise<unknown>, message: string) => {
try {
await action();
await refresh();
onMessage(message);
} catch (error) {
onError(extractErrorMessage(error));
}
};
return (
<Stack>
<Text size="sm" c="dimmed">
{t("useDescription")}
</Text>
{!current && (
<Paper withBorder p="md">
<Stack>
<Text>{t("saveFirst")}</Text>
<Button onClick={onSave} loading={saving}>
{t("saveDraft")}
</Button>
</Stack>
</Paper>
)}
{current && (
<>
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("activation")}</Text>
<Text size="sm">{t("activationDescription", { count: current.placements.length })}</Text>
{dirty && (
<Alert color="yellow">
<Stack gap="xs">
<Text size="sm">{t("saveBeforeActivate")}</Text>
<Button size="xs" onClick={onSave} loading={saving}>
{t("saveDraft")}
</Button>
</Stack>
</Alert>
)}
{!dirty && !matchesReviewedSource && <Alert color="yellow">{t("savedSourceChanged")}</Alert>}
<Checkbox
checked={trusted}
onChange={(event) => onTrustChange(event.currentTarget.checked)}
label={t("trustAcknowledgement")}
/>
<Group>
<Button
disabled={dirty || connectionsDirty || !trusted || !source || !matchesReviewedSource}
loading={activate.isPending}
onClick={() =>
void run(
() =>
activate.mutateAsync({
id: current.id,
trusted: true,
expectedDraftDigest: current.draftDigest,
}),
t("activated"),
)
}
>
{t("activate")}
</Button>
</Group>
<Accordion>
<Accordion.Item value="release">
<Accordion.Control>{t("releaseTools")}</Accordion.Control>
<Accordion.Panel>
<Stack>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{t("activeArtifact")}: {current.activeArtifactId ?? t("none")}
</Text>
{inspected.data?.artifact && <PackageArtifactReview artifact={inspected.data.artifact} />}
{inspected.error && <Alert color="red">{inspected.error.message}</Alert>}
<Group>
{current.activeArtifactId && (
<Button
variant="default"
loading={enable.isPending}
onClick={() =>
void run(
() => enable.mutateAsync({ id: current.id, enabled: !current.enabled }),
t("stateSaved"),
)
}
>
{current.enabled ? t("disable") : t("enable")}
</Button>
)}
<Button
variant="default"
disabled={!exported.data}
onClick={() => {
if (exported.data) downloadPackage(current.name, exported.data);
}}
>
{t("exportActive")}
</Button>
</Group>
{current.previousArtifactId && (
<>
<Checkbox
checked={rollbackConfirmed}
onChange={(event) => setRollbackConfirmed(event.currentTarget.checked)}
label={t("rollbackAcknowledgement")}
/>
<Button
variant="light"
color="orange"
disabled={!rollbackConfirmed}
loading={rollback.isPending}
onClick={() =>
void run(
() => rollback.mutateAsync({ id: current.id, discardNewerWidgetData: true }),
t("rolledBack"),
)
}
>
{t("rollback")}
</Button>
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Paper>
<PackageConvertedPlacements
id={current.id}
origin={current.origin}
active={Boolean(current.activeArtifactId && current.enabled)}
onChange={() => void refresh()}
/>
{activeSource.success && current.enabled && (
<AddPackagePlacement
installation={current}
initialBoardId={initialBoardId}
source={activeSource.data}
initialOptions={options}
onAdded={() => void refresh()}
onError={onError}
onMessage={onMessage}
/>
)}
{activeSource.success && <PackagePlacements installation={current} source={activeSource.data} />}
<PackageActivity id={current.id} />
</>
)}
</Stack>
);
}
function PackageActivity({ id }: { id: string }) {
const t = useI18n("customWidget.package");
const activity = clientApi.customWidget.package.activity.useQuery({ id, limit: 20 });
return (
<Accordion>
<Accordion.Item value="activity">
<Accordion.Control>{t("activity")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Button variant="subtle" size="xs" onClick={() => void activity.refetch()}>
{t("refresh")}
</Button>
{(activity.data ?? []).map((entry) => (
<Text key={entry.id} size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{JSON.stringify(entry)}
</Text>
))}
{activity.data?.length === 0 && (
<Text size="sm" c="dimmed">
{t("noActivity")}
</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -0,0 +1,332 @@
"use client";
import { extractErrorMessage, isRecord } from "@homarr/common";
import { useState } from "react";
import { Accordion, Alert, Button, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { PackageOptionFields } from "@homarr/widgets/custom-api/package-option-fields";
import { PackageCollectors } from "./_package-collectors";
import { PackageWebhooks } from "./_package-webhooks";
type Installation = RouterOutputs["customWidget"]["package"]["get"];
export function AddPackagePlacement({
installation,
initialBoardId,
source,
initialOptions,
onAdded,
onError,
onMessage,
}: {
installation: Installation;
initialBoardId?: string;
source: CustomWidgetPackage;
initialOptions?: Record<string, unknown>;
onAdded(): void;
onError(error: string): void;
onMessage(message: string): void;
}) {
const t = useI18n("customWidget.package");
const boards = clientApi.board.getAllBoards.useQuery();
const add = clientApi.board.addItem.useMutation();
const [boardId, setBoardId] = useState<string | null>(initialBoardId ?? null);
const [configuration, setConfiguration] = useState<Record<string, unknown>>(initialOptions ?? {});
const values = { ...getCustomWidgetDefaultOptions(source.options), ...configuration };
const missingConnections = Object.entries(source.connections).filter(
([name, requirement]) => !requirement.optional && !installation.bindings[name],
);
const issues = validateCustomWidgetOptions(source.options, values);
return (
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("addToBoard")}</Text>
<Text size="sm" c="dimmed">
{t("placementDescription")}
</Text>
<Select
searchable
label={t("board")}
value={boardId}
onChange={setBoardId}
data={(boards.data ?? []).map((board) => ({ value: board.id, label: board.name }))}
/>
<PackageOptionFields schema={source.options} value={values} onChange={setConfiguration} />
{missingConnections.length > 0 && (
<Alert color="yellow">
{t("bindFirst", { names: missingConnections.map(([, requirement]) => requirement.label).join(", ") })}
</Alert>
)}
{issues.length > 0 && <Alert color="yellow">{issues[0]?.message}</Alert>}
<Button
disabled={!boardId || missingConnections.length > 0 || issues.length > 0}
loading={add.isPending}
onClick={() => {
if (!boardId) return;
add.mutate(
{
boardId,
kind: "customApi",
options: {
definitionId: installation.id,
configuration: values,
configurationVersion: source.manifest.configurationVersion,
connectionBindings: {},
refreshInterval: 30,
},
integrationIds: [],
},
{
onSuccess: () => {
onAdded();
onMessage(t("addedToBoard"));
},
onError: (error) => onError(error.message),
},
);
}}
>
{t("addToBoard")}
</Button>
</Stack>
</Paper>
);
}
export function PackagePlacements({
installation,
source,
}: {
installation: Installation;
source: CustomWidgetPackage;
}) {
const t = useI18n("customWidget.package");
const boards = clientApi.board.getAllBoards.useQuery();
const actions = Object.entries(source.manifest.handlers).filter(
([, handler]) => handler.kind === "action" && handler.guestAccess,
);
return (
<Stack gap="xs">
<Text fw={600}>{t("placements", { count: installation.placements.length })}</Text>
{installation.placements.map((placement) => {
const board = boards.data?.find((entry) => entry.id === placement.boardId);
return (
<Paper key={placement.id} withBorder p="sm">
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm">{board?.name ?? placement.boardId}</Text>
<Text size="xs" c="dimmed">
{placement.id}
</Text>
{board && (
<Button
component={Link}
href={`/boards/${encodeURIComponent(board.name)}`}
size="compact-xs"
variant="subtle"
>
{t("openBoard")}
</Button>
)}
</Group>
<Accordion>
<Accordion.Item value="webhooks">
<Accordion.Control>{t("webhook.title")}</Accordion.Control>
<Accordion.Panel>
<PackageWebhooks
itemId={placement.id}
handlers={Object.entries(source.manifest.handlers)
.filter(([, handler]) => handler.kind === "action" && handler.inputSchema)
.map(([name]) => name)}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="collection">
<Accordion.Control>{t("collection")}</Accordion.Control>
<Accordion.Panel>
<PackageCollectors
itemId={placement.id}
queries={Object.entries(source.manifest.handlers)
.filter(([, handler]) => handler.kind === "query")
.map(([name]) => name)}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{actions.length > 0 && (
<Accordion>
<Accordion.Item value="guests">
<Accordion.Control>{t("guestActions")}</Accordion.Control>
<Accordion.Panel>
<GuestActions
itemId={placement.id}
actions={actions.map(([name, handler]) => ({ name, description: handler.description }))}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
</Paper>
);
})}
</Stack>
);
}
function GuestActions({ itemId, actions }: { itemId: string; actions: Array<{ name: string; description?: string }> }) {
const t = useI18n("customWidget.package");
const grants = clientApi.customWidget.package.guestGrants.useQuery({ itemId });
const mutation = clientApi.customWidget.package.setGuestGrant.useMutation();
const [inputs, setInputs] = useState<Record<string, string>>({});
const [error, setError] = useState("");
return (
<Stack>
<Text size="sm">{t("guestDescription")}</Text>
{actions.map(({ name, description }) => {
const grant = grants.data?.find((entry) => entry.handler === name);
const enabled = Boolean(grant?.current);
return (
<Stack key={name} gap="xs">
<Text fw={500}>{name}</Text>
{description && <Text size="xs">{description}</Text>}
<Textarea
label={t("allowedInputs")}
value={inputs[name] ?? JSON.stringify(grant?.allowedInputs ?? [{}], null, 2)}
onChange={(event) => setInputs({ ...inputs, [name]: event.currentTarget.value })}
/>
<Button
variant="light"
loading={mutation.isPending}
onClick={() => {
const callbacks = {
onSuccess: () => void grants.refetch(),
onError: (cause: { message: string }) => setError(cause.message),
};
if (enabled) {
mutation.mutate({ itemId, handler: name, enabled: false }, callbacks);
return;
}
try {
const allowed: unknown = JSON.parse(inputs[name] ?? JSON.stringify(grant?.allowedInputs ?? [{}]));
if (!Array.isArray(allowed)) throw new Error(t("inputsArray"));
mutation.mutate({ itemId, handler: name, enabled: true, allowedInputs: allowed }, callbacks);
} catch (cause) {
setError(extractErrorMessage(cause));
}
}}
>
{enabled ? t("disableGuest") : t("enableGuest")}
</Button>
</Stack>
);
})}
{error && <Alert color="red">{error}</Alert>}
</Stack>
);
}
export function PackageConvertedPlacements({
id,
origin,
active,
onChange,
}: {
id: string;
origin: string | null;
active: boolean;
onChange(): void;
}) {
let definitionId: string | undefined;
try {
const value: unknown = JSON.parse(origin ?? "null");
if (isRecord(value) && value.kind === "converted-v2" && typeof value.definitionId === "string")
definitionId = value.definitionId;
} catch {
/* Other package origins have no legacy placement migration. */
}
if (!definitionId) return null;
return <ConvertedPlacementPicker id={id} definitionId={definitionId} active={active} onChange={onChange} />;
}
function ConvertedPlacementPicker({
id,
definitionId,
active,
onChange,
}: {
id: string;
definitionId: string;
active: boolean;
onChange(): void;
}) {
const t = useI18n("customWidget.package.conversion");
const placements = clientApi.customWidget.package.legacyPlacements.useQuery({ definitionId }, { enabled: active });
const migrate = clientApi.customWidget.package.migrateV2Placement.useMutation();
const [itemId, setItemId] = useState<string | null>(null);
const [replaced, setReplaced] = useState(false);
return (
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={600}>{t("replaceTitle")}</Text>
<Text size="sm">{t("replaceDescription")}</Text>
{!active && (
<Text size="sm" c="dimmed">
{t("activateFirst")}
</Text>
)}
{active && (
<>
<Select
label={t("choosePlacement")}
searchable
value={itemId}
onChange={(value) => {
setItemId(value);
setReplaced(false);
}}
data={(placements.data ?? []).map((placement) => ({
value: placement.id,
label: `${placement.boardName} · ${placement.id}`,
}))}
/>
{placements.data?.length === 0 && (
<Text size="sm" c="dimmed">
{t("noOriginalPlacements")}
</Text>
)}
<Button
disabled={!itemId}
loading={migrate.isPending}
onClick={() => {
if (!itemId) return;
migrate.mutate(
{ id, itemId, definitionId },
{
onSuccess: () => {
setItemId(null);
setReplaced(true);
void placements.refetch();
onChange();
},
},
);
}}
>
{t("replaceSelected")}
</Button>
</>
)}
{replaced && <Alert color="green">{t("replaced")}</Alert>}
{(migrate.error || placements.error) && (
<Alert color="red">{migrate.error?.message ?? placements.error?.message}</Alert>
)}
</Stack>
</Paper>
);
}
@@ -0,0 +1,401 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import {
Accordion,
Alert,
Badge,
Button,
Checkbox,
Group,
Select,
Stack,
Text,
NumberInput,
Paper,
Skeleton,
Title,
} from "@mantine/core";
import { IconCode, IconPlayerPlay } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { WidgetPreviewFrame, WidgetPreviewViewport } from "@homarr/widgets/preview";
import { downloadPackage } from "@homarr/custom-widgets/workbench/package";
import type { usePackageWorkspace } from "./_use-package-workspace";
import classes from "./_package-workspace.module.css";
import { clientApi } from "@homarr/api/client";
import { Link } from "@homarr/ui";
const TrustedWidgetPreview = dynamic(() => import("@homarr/widgets/custom-api/trusted-widget-preview"), { ssr: false });
export function PackagePreview({ state }: { state: ReturnType<typeof usePackageWorkspace> }) {
const t = useI18n("customWidget.package");
const [detailsOpen, setDetailsOpen] = useState<string | null>(null);
const {
parsed,
trusted,
setTrusted,
liveActions,
setLiveActions,
busy,
previewMutation,
save,
runPreview,
document,
source,
surfaceChoices,
surface,
setSurface,
width,
height,
scale,
previewTheme,
scenario,
setScenario,
stale,
preview,
previewReady,
previewLoadError,
setOptions,
} = state;
let buildStatus = t("previewNotBuilt");
if (previewMutation.isPending) buildStatus = t("previewBuilding");
if (previewMutation.isError) buildStatus = t("previewBuildFailed");
if (previewMutation.isSuccess) buildStatus = t("previewBuildReady");
return (
<Stack className={classes.preview} gap="sm">
<Group gap="xs" wrap="nowrap">
<Select
flex={1}
aria-label={t("surface")}
data={surfaceChoices}
value={surface}
onChange={(value) => {
if (value === "tile" || value === "advanced" || value === "configuration") setSurface(value);
}}
/>
<Button
leftSection={<IconPlayerPlay size={16} />}
loading={previewMutation.isPending || save.isPending}
disabled={!parsed.success || !trusted || busy}
onClick={() => void runPreview()}
>
{t("preview")}
</Button>
</Group>
<Checkbox
size="xs"
checked={trusted}
disabled={!parsed.success || busy}
onChange={(event) => setTrusted(event.currentTarget.checked)}
label={t("trustShort")}
/>
{parsed.success && Object.keys(parsed.data.previewScenarios ?? {}).length > 0 && (
<Select
label={t("previewScenario")}
value={scenario ?? ""}
disabled={busy}
onChange={(value) => setScenario(value || null)}
data={[
{ value: "", label: t("previewConnected") },
...Object.entries(parsed.data.previewScenarios ?? {}).map(([id, fixture]) => ({
value: id,
label: fixture.label,
})),
]}
/>
)}
<Checkbox
size="xs"
checked={liveActions}
disabled={!parsed.success || busy || Boolean(scenario)}
onChange={(event) => setLiveActions(event.currentTarget.checked)}
label={t("previewLiveActions")}
/>
{!state.installationId && (
<Text size="xs" c="dimmed">
{t("previewSavesFirst")}
</Text>
)}
{stale && (
<Alert color="yellow" p="xs">
{t("previewStale")}
</Alert>
)}
<WidgetPreviewFrame
dimensions={{ width, height, scale }}
resize={{ size: state.gridSize, maximumSize: { width: 12, height: 12 }, onChange: state.setGridSize }}
>
{({ scale: fittedScale }) => (
<>
{preview && (
<WidgetPreviewViewport width={width} height={height} scale={fittedScale} colorScheme={previewTheme}>
<TrustedWidgetPreview
data={preview.displayData}
width={width}
height={height}
displayScale={fittedScale}
surface={surface}
onOptionsChange={setOptions}
onSurfaceChange={setSurface}
frozen={stale || previewMutation.isPending}
onReady={previewReady}
onLoadError={previewLoadError}
/>
</WidgetPreviewViewport>
)}
{!preview && (
<Stack justify="center" align="center" mih={220} p="sm">
<IconCode size={32} stroke={1} />
<Text c="dimmed" size="sm" ta="center">
{t("previewEmpty")}
</Text>
</Stack>
)}
</>
)}
</WidgetPreviewFrame>
<Group gap="xs" align="center">
{preview && (
<Text size="xs" c="dimmed">
{t(preview.liveActions ? "previewLiveSession" : "previewSimulatedSession")}
</Text>
)}
{preview?.displayData.previewScenario && (
<Text size="xs" c="dimmed">
{t("previewSynthetic", { name: preview.displayData.previewScenario.label })}
</Text>
)}
</Group>
<PackagePreviewControls state={state} />
<Accordion variant="default" value={detailsOpen} onChange={setDetailsOpen}>
<Accordion.Item value="trust">
<Accordion.Control>{t("executionDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Badge variant="light">{buildStatus}</Badge>
{previewMutation.error && <Alert color="red">{previewMutation.error.message}</Alert>}
<Text size="sm">{t("browserExecution")}</Text>
<Text size="sm">
{parsed.success && parsed.data.manifest.entrypoints.server
? t("serverExecution")
: t("browserOnlyExecution")}
</Text>
<Text size="sm">{t("trustedCode")}</Text>
<Text size="sm">{t("runtimeLogs")}</Text>
{detailsOpen && state.installationId && <PackageRuntimeStatus id={state.installationId} />}
<Text size="xs" c="dimmed">
{t("previewDescription")}
</Text>
<Text size="xs">
{t("dependencies")}:{" "}
{(parsed.success &&
Object.entries(parsed.data.dependencies)
.map(([name, version]) => `${name}@${version}`)
.join(", ")) ||
t("hostOnly")}
</Text>
<Button variant="default" size="xs" onClick={() => downloadPackage(document.name, source)}>
{t("exportDraft")}
</Button>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
function PackagePreviewControls({ state }: { state: ReturnType<typeof usePackageWorkspace> }) {
const t = useI18n("customWidget.package");
const { width, height, scale, previewTheme, setWidth, setHeight, setScale, setPreviewTheme } = state;
return (
<Stack gap="xs">
<Group gap="xs" grow>
<Select
size="xs"
label={t("previewTheme")}
value={previewTheme}
data={[
{ value: "system", label: t("previewThemeCurrent") },
{ value: "light", label: t("previewThemeLight") },
{ value: "dark", label: t("previewThemeDark") },
]}
onChange={(value) => {
if (value === "system" || value === "light" || value === "dark") setPreviewTheme(value);
}}
/>
</Group>
<Accordion>
<Accordion.Item value="dimensions">
<Accordion.Control>{t("customViewport")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Text size="xs" c="dimmed">
{t("customViewportDescription")}
</Text>
<Group gap="xs" grow>
<NumberInput
size="xs"
label={t("width")}
min={100}
max={2400}
value={width}
onChange={(value) => {
if (typeof value === "number") setWidth(value);
}}
/>
<NumberInput
size="xs"
label={t("height")}
min={100}
max={1600}
value={height}
onChange={(value) => {
if (typeof value === "number") setHeight(value);
}}
/>
<NumberInput
size="xs"
label={t("previewScale")}
min={25}
max={90}
suffix="%"
step={10}
value={Math.round(scale * 100)}
onChange={(value) => {
if (typeof value === "number") setScale(Math.min(0.9, Math.max(0.25, value / 100)));
}}
/>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
function PackageRuntimeStatus({ id }: { id: string }) {
const t = useI18n("customWidget.package.runtime");
const status = clientApi.customWidget.package.runtimeStatus.useQuery({ id });
const data = status.data;
return (
<Stack gap="xs">
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Text size="xs" c="dimmed">
{t("description")}
</Text>
<Group>
{data && <Badge variant="light">{t(data.state)}</Badge>}
<Button variant="subtle" size="xs" loading={status.isFetching} onClick={() => void status.refetch()}>
{t("refresh")}
</Button>
</Group>
{data && "pid" in data && (
<Text size="xs">
{t("process", { pid: data.pid ?? "—", active: data.activeRequests, queued: data.queuedRequests })}
</Text>
)}
{data && "error" in data && <Alert color="red">{data.error}</Alert>}
{status.error && <Alert color="red">{status.error.message}</Alert>}
<Button
component={Link}
href="/manage/tools/logs"
target="_blank"
rel="noopener noreferrer"
variant="default"
size="xs"
>
{t("logs")}
</Button>
</Stack>
);
}
export function PackageSessionPreview({ previewId }: { previewId: string }) {
const t = useI18n("customWidget.package");
const { data, error, isLoading } = clientApi.customWidget.package.previewGet.useQuery(
{ previewId },
{ retry: false },
);
const [surface, setSurface] = useState<"tile" | "advanced" | "configuration">("tile");
const [width, setWidth] = useState(480);
const [height, setHeight] = useState(360);
if (isLoading) return <Skeleton height={360} />;
if (error || !data)
return (
<Alert color="yellow" title={t("sessionUnavailable")}>
{t("sessionUnavailableDescription")}
</Alert>
);
const choices = [{ value: "tile", label: t("tile") }];
if (data.displayData.manifest.entrypoints.advanced) choices.push({ value: "advanced", label: t("advanced") });
if (data.displayData.manifest.entrypoints.configuration)
choices.push({ value: "configuration", label: t("configuration") });
return (
<Stack>
<Group justify="space-between">
<Title order={2}>{data.displayData.manifest.name}</Title>
<Button
component={Link}
href={`/manage/custom-widgets/packages/${data.displayData.installationId}`}
variant="subtle"
>
{t("openWorkspace")}
</Button>
</Group>
<Text size="sm" c="dimmed">
{t("sessionDescription")}
</Text>
{data.displayData.previewScenario && (
<Text size="sm" c="dimmed">
{t("previewSynthetic", { name: data.displayData.previewScenario.label })}
</Text>
)}
<Group align="end">
<Select
label={t("surface")}
data={choices}
value={surface}
onChange={(value) => {
if (value === "tile" || value === "advanced" || value === "configuration") setSurface(value);
}}
/>
<NumberInput
w={110}
label={t("width")}
min={100}
max={2400}
value={width}
onChange={(value) => {
if (typeof value === "number") setWidth(value);
}}
/>
<NumberInput
w={110}
label={t("height")}
min={100}
max={2400}
value={height}
onChange={(value) => {
if (typeof value === "number") setHeight(value);
}}
/>
</Group>
<Paper withBorder p="sm" style={{ overflow: "auto" }}>
<div style={{ width, height }}>
<TrustedWidgetPreview
data={data.displayData}
width={width}
height={height}
surface={surface}
onSurfaceChange={setSurface}
/>
</div>
</Paper>
</Stack>
);
}
@@ -0,0 +1,202 @@
"use client";
import { useState, useMemo } from "react";
import dynamic from "next/dynamic";
import { Button, Group, Select, Stack, Tabs, Text, TextInput, Alert, Collapse, Tree, useTree } from "@mantine/core";
import { IconPlus, IconTrash, IconChevronRight, IconCode, IconFolder, IconFolderOpen } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
import { documentFromTemplate } from "@homarr/custom-widgets/workbench/package";
import type { usePackageWorkspace } from "./_use-package-workspace";
import classes from "./_package-workspace.module.css";
import type { TreeNodeData } from "@mantine/core";
const PackageLanguageEditor = dynamic(() => import("./_package-language-editor"), { ssr: false });
export function PackageSourceEditor({
state,
existing,
}: {
state: ReturnType<typeof usePackageWorkspace>;
existing: boolean;
}) {
const t = useI18n("customWidget.package");
const [filesOpen, setFilesOpen] = useState(true);
const [manageFiles, setManageFiles] = useState(false);
const { document, edit, selected, setSelected, path, setPath, busy, addFile, renameFile, removeFile, parsed } = state;
return (
<Tabs.Panel value="source" pt="md">
<Stack>
{!existing && (
<Select
label={t("starter")}
description={t("starterDescription")}
placeholder={t("chooseStarter")}
data={Object.entries(widgetReferencePackages).map(([key, template]) => ({
value: key,
label: template.manifest.name,
}))}
disabled={busy}
onChange={(starter) => {
if (!starter) return;
const template = Object.entries(widgetReferencePackages).find(([key]) => key === starter)?.[1];
if (!template) return;
edit(documentFromTemplate(document.name, template));
setSelected(template.manifest.entrypoints.tile);
}}
/>
)}
<Group gap="xs">
<Select
flex={1}
searchable
aria-label={t("filePath")}
value={selected}
data={[
{ value: "/widget.json", label: t("manifestFile") },
...Object.keys(document.files)
.toSorted()
.map((file) => ({ value: file, label: file })),
]}
onChange={(value) => {
if (value) setSelected(value);
}}
/>
<Button variant="subtle" size="sm" onClick={() => setFilesOpen(!filesOpen)} aria-expanded={filesOpen}>
{t("files")}
</Button>
</Group>
<div className={classes.editor} data-package-document data-files-open={filesOpen}>
{filesOpen && (
<Stack gap="xs" className={classes.files}>
<PackageFileTree files={document.files} selected={selected} onSelect={setSelected} />
<Button
size="compact-xs"
variant="subtle"
onClick={() => setManageFiles((value) => !value)}
aria-expanded={manageFiles}
>
{t("manageFiles")}
</Button>
<Collapse expanded={manageFiles}>
<Stack gap="xs">
<TextInput
aria-label={t("filePath")}
placeholder={t("filePathExample")}
value={path}
onChange={(event) => setPath(event.currentTarget.value)}
disabled={busy}
/>
<Group gap={4}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconPlus size={12} />}
onClick={addFile}
disabled={busy || !path.trim()}
>
{t("addFile")}
</Button>
<Button
size="compact-xs"
variant="subtle"
onClick={renameFile}
disabled={busy || !path.trim() || selected === "/widget.json"}
>
{t("renameFile")}
</Button>
<Button
size="compact-xs"
color="red"
variant="subtle"
leftSection={<IconTrash size={12} />}
onClick={removeFile}
disabled={busy || selected === "/widget.json"}
>
{t("removeFile")}
</Button>
</Group>
</Stack>
</Collapse>
</Stack>
)}
<PackageLanguageEditor state={state} />
</div>
{!parsed.success && (
<Alert color="yellow">
<Text fw={500}>{t("draftInvalid")}</Text>
{parsed.error.issues.slice(0, 8).map((issue, index) => (
<Text key={index} size="sm">
{issue.path.join(".")}: {issue.message}
</Text>
))}
</Alert>
)}
</Stack>
</Tabs.Panel>
);
}
function PackageFileTree({
files,
selected,
onSelect,
}: {
files: Record<string, string>;
selected: string;
onSelect(path: string): void;
}) {
const t = useI18n("customWidget.package");
const data = useMemo(() => {
const roots: TreeNodeData[] = [{ value: "/widget.json", label: t("manifestFile") }];
for (const path of Object.keys(files).toSorted()) {
const parts = path.split("/");
let siblings = roots;
let prefix = "";
for (const [index, label] of parts.entries()) {
prefix += label;
if (index === parts.length - 1) {
siblings.push({ value: path, label });
break;
}
prefix += "/";
let folder = siblings.find((node) => node.value === prefix);
if (!folder) {
folder = { value: prefix, label, children: [] };
siblings.push(folder);
}
siblings = folder.children ?? [];
}
}
return roots;
}, [files, t]);
const tree = useTree({
selectedState: [selected],
onSelectedStateChange: (values) => {
const value = values[0];
if (value && !value.endsWith("/")) onSelect(value);
},
});
return (
<Tree
aria-label={t("files")}
data={data}
tree={tree}
levelOffset={16}
selectOnClick
renderNode={({ node, expanded, hasChildren, elementProps }) => {
let Icon = IconCode;
if (hasChildren) Icon = IconFolder;
if (hasChildren && expanded) Icon = IconFolderOpen;
return (
<Group {...elementProps} gap={6} wrap="nowrap" py={5} title={node.value}>
{hasChildren && <IconChevronRight size={12} style={{ transform: `rotate(${expanded ? 90 : 0}deg)` }} />}
<Icon size={16} style={{ flexShrink: 0 }} />
<Text size="xs" truncate>
{node.label}
</Text>
</Group>
);
}}
/>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import { Alert, Badge, Button, CopyButton, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
type IssuedWebhook = RouterOutputs["customWidget"]["package"]["createWebhook"];
export function PackageWebhooks({ itemId, handlers }: { itemId: string; handlers: string[] }) {
const t = useI18n("customWidget.package.webhook");
const commonT = useI18n("common.action");
const hooks = clientApi.customWidget.package.webhooks.useQuery({ itemId });
const create = clientApi.customWidget.package.createWebhook.useMutation();
const revoke = clientApi.customWidget.package.revokeWebhook.useMutation();
const [handler, setHandler] = useState<string | null>(null);
const [issued, setIssued] = useState<IssuedWebhook | null>(null);
const [error, setError] = useState("");
return (
<Stack gap="sm">
<Text size="sm">{t("description")}</Text>
<Group align="end">
<Select
flex={1}
label={t("handler")}
data={handlers}
value={handler}
onChange={setHandler}
disabled={handlers.length === 0}
/>
<Button
size="sm"
disabled={!handler}
loading={create.isPending}
onClick={() => {
if (!handler) return;
setError("");
create.mutate(
{ itemId, handler },
{
onSuccess: (result) => {
setIssued(result);
create.reset();
void hooks.refetch();
},
onError: (cause) => setError(cause.message),
},
);
}}
>
{t("create")}
</Button>
</Group>
{handlers.length === 0 && (
<Text size="xs" c="dimmed">
{t("noHandlers")}
</Text>
)}
{issued && (
<Alert color="yellow" title={t("issued")} withCloseButton onClose={() => setIssued(null)}>
<Stack gap="xs">
<Text size="xs">{t("issuedDescription")}</Text>
<TextInput label={t("url")} readOnly value={new URL(issued.path, window.location.origin).href} />
<CopyButton value={new URL(issued.path, window.location.origin).href}>
{({ copied, copy }) => (
<Button size="xs" variant="subtle" onClick={copy}>
{copied ? commonT("copied") : t("copyUrl")}
</Button>
)}
</CopyButton>
<PasswordInput label={t("token")} readOnly value={issued.token} />
<CopyButton value={issued.token}>
{({ copied, copy }) => (
<Button size="xs" variant="subtle" onClick={copy}>
{copied ? commonT("copied") : t("copyToken")}
</Button>
)}
</CopyButton>
</Stack>
</Alert>
)}
{(hooks.data ?? []).map((hook) => (
<Group key={hook.id} justify="space-between">
<Text size="sm">{hook.handler}</Text>
<Badge color={hook.current ? "green" : "yellow"}>{hook.current ? t("current") : t("reissue")}</Badge>
<Button
size="compact-xs"
color="red"
variant="subtle"
loading={revoke.isPending}
onClick={() =>
revoke.mutate(
{ itemId, id: hook.id },
{
onSuccess: () => {
if (issued?.id === hook.id) setIssued(null);
void hooks.refetch();
},
onError: (cause) => setError(cause.message),
},
)
}
>
{t("revoke")}
</Button>
</Group>
))}
{(error || hooks.error) && <Alert color="red">{error || hooks.error?.message}</Alert>}
</Stack>
);
}
@@ -0,0 +1,195 @@
"use client";
import { extractErrorMessage } from "@homarr/common";
import { useState, useRef } from "react";
import { Button, Checkbox, Group, Paper, Select, Stack, Text, Accordion, Badge, SimpleGrid } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { IconBuildingStore } from "@tabler/icons-react";
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
import { CodeEditor } from "~/components/custom-widgets/code-editor";
import type { CustomWidgetArtifact } from "@homarr/custom-widgets/package";
export function PackageWorkshop({ id, dirty }: { id: string; dirty: boolean }) {
const t = useI18n("customWidget.package");
return (
<Paper withBorder p="md" radius="md">
<Stack align="flex-start">
<Text fw={600}>{t("publishWorkshop")}</Text>
<Text size="sm" c="dimmed">
{t("publishDescription")}
</Text>
{dirty && (
<Text size="sm" c="orange">
{t("saveBeforePublish")}
</Text>
)}
<Button
component={Link}
href={`/manage/custom-widgets/publish/${id}?kind=package`}
disabled={dirty}
leftSection={<IconBuildingStore size={16} />}
>
{t("publishWorkshop")}
</Button>
</Stack>
</Paper>
);
}
export function PackageTransfer({
id,
active,
dirty,
onError,
}: {
id: string;
active: boolean;
dirty: boolean;
onError(error: string): void;
}) {
const t = useI18n("customWidget.package");
const input = useRef<HTMLInputElement>(null);
const [replace, setReplace] = useState(false);
const update = clientApi.customWidget.package.stageUpdate.useMutation();
const changes = clientApi.customWidget.package.draftChanges.useQuery({ id });
const stage = async (file: File) => {
try {
if (file.size > 25_000_000) throw new Error(t("importTooLarge"));
const raw: unknown = JSON.parse(await file.text());
const archive = customWidgetArchiveSchema.parse(raw);
const result = await update.mutateAsync({ id, archive, replaceLocalDraft: true });
window.location.assign(result.managementPath);
} catch (error) {
onError(extractErrorMessage(error));
}
};
return (
<Stack>
<Accordion>
<Accordion.Item value="changes">
<Accordion.Control>{t("changes")}</Accordion.Control>
<Accordion.Panel>
<Stack>
<Text size="sm" c="dimmed">
{t("sourceOnly")}
</Text>
{changes.data?.valid && (
<>
<Text size="sm">
{changes.data.fromVersion ?? t("none")} → {changes.data.toVersion}
</Text>
<Group>
{changes.data.dependenciesChanged && <Badge color="yellow">{t("dependenciesChanged")}</Badge>}
{changes.data.connectionsChanged && <Badge color="yellow">{t("connectionsChanged")}</Badge>}
{changes.data.configurationChanged && <Badge color="yellow">{t("configurationChanged")}</Badge>}
</Group>
{changes.data.files.map((file) => (
<Stack key={file.name} gap="xs">
<Text fw={500}>{file.name}</Text>
<SimpleGrid cols={{ base: 1, lg: 2 }}>
<CodeEditor
id={`before-${file.name}`}
label={t("before")}
value={file.before ?? ""}
language="tsx"
readOnly
onChange={() => undefined}
/>
<CodeEditor
id={`after-${file.name}`}
label={t("after")}
value={file.after ?? ""}
language="tsx"
readOnly
onChange={() => undefined}
/>
</SimpleGrid>
</Stack>
))}
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{active && (
<Paper withBorder p="md">
<Stack>
<Checkbox
checked={replace}
onChange={(event) => setReplace(event.currentTarget.checked)}
label={t("replaceDraft")}
/>
<input
hidden
type="file"
ref={input}
accept=".json,application/json"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void stage(file);
}}
/>
<Button
variant="default"
disabled={!replace || dirty}
loading={update.isPending}
onClick={() => input.current?.click()}
>
{t("stageUpdate")}
</Button>
</Stack>
</Paper>
)}
</Stack>
);
}
export function PackageArtifactReview({ artifact }: { artifact: CustomWidgetArtifact }) {
const t = useI18n("customWidget.package.artifactReview");
const [selected, setSelected] = useState("dependency-lock.json");
const files: Record<string, string> = { "dependency-lock.json": JSON.stringify(artifact.dependencyLock, null, 2) };
for (const [surface, compiled] of Object.entries(artifact.client)) {
if (!compiled) continue;
files[`${surface}.js`] = compiled.javascript;
if (compiled.css) files[`${surface}.css`] = compiled.css;
}
if (artifact.server) files["server.js"] = artifact.server;
const path = Object.hasOwn(files, selected) ? selected : "dependency-lock.json";
return (
<Accordion>
<Accordion.Item value="artifact">
<Accordion.Control>{t("title")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Text size="sm">{t("description")}</Text>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{artifact.digest}
</Text>
<Select
label={t("file")}
value={path}
data={Object.keys(files)}
onChange={(value) => {
if (value) setSelected(value);
}}
/>
<CodeEditor
id={`artifact-${artifact.digest}-${path}`}
label={path}
value={files[path] ?? ""}
language={path.endsWith(".json") ? "json" : path.endsWith(".css") ? "css" : "tsx"}
readOnly
height="320px"
onChange={() => undefined}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -0,0 +1,85 @@
.workspace {
display: grid;
grid-template-columns: minmax(0, var(--widget-editor-width, 60%)) 10px minmax(0, 1fr);
gap: var(--mantine-spacing-xs);
align-items: start;
}
.divider {
width: 10px;
margin: 0;
border: 0;
align-self: stretch;
min-height: 280px;
cursor: col-resize;
touch-action: none;
border-radius: var(--mantine-radius-sm);
background: linear-gradient(var(--mantine-color-default-border), var(--mantine-color-default-border)) center / 2px
100% no-repeat;
}
.divider:hover,
.divider:focus-visible {
background-color: var(--mantine-primary-color-light);
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 1px;
}
.editor {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
min-width: 0;
}
.editor[data-files-open="true"] {
grid-template-columns: 190px minmax(0, 1fr);
}
.files {
overflow: auto;
max-height: 65vh;
border-right: 1px solid var(--mantine-color-default-border);
padding-right: var(--mantine-spacing-xs);
}
.preview {
position: sticky;
top: calc(var(--mantine-spacing-md) + 60px);
min-width: 0;
}
.canvas {
overflow: auto;
max-width: 100%;
max-height: max(260px, calc(100dvh - 270px));
background: var(--mantine-color-body);
}
.switcher {
display: none;
}
@media (max-width: 70em) {
.workspace {
grid-template-columns: minmax(0, 1fr);
}
.divider {
width: 10px;
margin: 0;
border: 0;
display: none;
}
.preview {
position: static;
}
.switcher {
display: block;
}
.workspace[data-pane="edit"] .preview {
display: none;
}
.workspace[data-pane="preview"] .authoring {
display: none;
}
}
@media (max-width: 40em) {
.editor,
.editor[data-files-open="true"] {
grid-template-columns: minmax(0, 1fr);
}
.files {
max-height: 180px;
}
}
@@ -0,0 +1,436 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import {
Alert,
Badge,
Button,
Group,
SegmentedControl,
Stack,
Tabs,
Text,
TextInput,
Tooltip,
Title,
ActionIcon,
Paper,
} from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp, IconInfoCircle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { PackageOptionFields } from "@homarr/widgets/custom-api/package-option-fields";
import { PackagePreview } from "./_package-preview";
import { PackageAssistant, usePackageAssistant } from "./_package-assistant";
import { PackageSourceEditor } from "./_package-source-editor";
import { PackageConnections } from "./_package-connections";
import { PackageOperations } from "./_package-operations";
import { PackageWorkshop, PackageTransfer } from "./_package-workshop";
import { usePackageWorkspace } from "./_use-package-workspace";
import type { Installation } from "./_use-package-workspace";
import classes from "./_package-workspace.module.css";
import type { CSSProperties, PropsWithChildren, ReactNode } from "react";
const workspaceTabs = ["source", "connections", "options", "installation", "workshop"] as const;
type WorkspaceTab = (typeof workspaceTabs)[number];
const isWorkspaceTab = (value: string | null): value is WorkspaceTab => workspaceTabs.some((tab) => tab === value);
export function PackageWorkspace({
installation,
userId,
initialSource,
initialName,
initialBindings,
initialBoardId,
}: {
installation?: Installation;
userId: string;
initialSource?: unknown;
initialName?: string;
initialBindings?: Record<string, string>;
initialBoardId?: string;
}) {
const t = useI18n("customWidget.package");
const searchParams = useSearchParams();
const [tab, setTab] = useState<WorkspaceTab>(() => {
const requested = searchParams.get("tab");
if (isWorkspaceTab(requested)) return requested;
return "source";
});
const [visitedTabs, setVisitedTabs] = useState(() => new Set([tab]));
const selectTab = (value: string | null) => {
if (!isWorkspaceTab(value)) return;
setVisitedTabs((current) => new Set(current).add(value));
setTab(value);
};
const workspace = usePackageWorkspace(
installation,
userId,
initialSource,
initialName,
initialBindings,
initialBoardId,
);
const {
editor,
document,
edit,
pane,
setPane,
trusted,
error,
setError,
message,
setMessage,
bindings,
setBindings,
setOptions,
parsed,
effectiveOptions,
save,
saveBindings,
busy,
saveDraft,
saveLocalBindings,
} = workspace;
usePackageAssistant(workspace);
const current = workspace.installation;
let status = t("draftOnly");
if (current?.activeArtifactId) status = t("disabled");
if (current?.activeArtifactId && current.enabled) status = t("active");
let draftStatus = t("saved");
if (editor.dirty) draftStatus = t("unsaved");
if (!workspace.installationId) draftStatus = t("notSaved");
return (
<Stack
gap="sm"
onKeyDownCapture={(event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.closest("[data-package-document]") || busy) return;
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z" || event.altKey) return;
event.preventDefault();
event.stopPropagation();
if (event.shiftKey) editor.redo();
else editor.undo();
}}
>
<Title order={1} size="h2">
{t("workbenchTitle")}
</Title>
<Group justify="space-between" align="end" data-package-document>
<TextInput
flex={1}
maw={440}
label={t("name")}
value={document.name}
disabled={busy}
onChange={(event) => edit({ ...document, name: event.currentTarget.value }, "name")}
/>
<Group gap="xs">
<Button variant="subtle" component={Link} href="/manage/custom-widgets/packages">
{t("library")}
</Button>
<Badge variant="light">{status}</Badge>
<Text size="xs" c="dimmed">
{draftStatus}
</Text>
<Button
variant="default"
loading={save.isPending}
disabled={!document.name.trim() || busy}
onClick={() => void saveDraft()}
>
{t("saveDraft")}
</Button>
<Button
onClick={() => {
selectTab("installation");
setPane("edit");
}}
>
{t("installation")}
</Button>
</Group>
</Group>
{!workspace.installationId && (
<Paper withBorder p="md">
<Stack gap="xs">
<Text fw={600}>{t("gettingStarted")}</Text>
<Text size="sm" c="dimmed">
{t("gettingStartedDescription")}
</Text>
<Text size="sm">{t("manualSteps")}</Text>
</Stack>
</Paper>
)}
{error && (
<Alert color="red" withCloseButton onClose={() => setError("")}>
{error}
</Alert>
)}
{message && (
<Alert color="blue" withCloseButton onClose={() => setMessage("")}>
{message}
</Alert>
)}
{workspace.bindingsDirty && (
<Alert color="yellow">
<Group justify="space-between">
<Text size="sm">{t("connectionsUnsaved")}</Text>
<Button
size="xs"
loading={saveBindings.isPending || save.isPending}
disabled={busy}
onClick={saveLocalBindings}
>
{t("saveBindings")}
</Button>
</Group>
</Alert>
)}
{editor.recovery && (
<Alert color="yellow">
<Stack gap="xs">
<Text>{t("recovery")}</Text>
<Group>
<Button size="xs" onClick={editor.restoreRecovery}>
{t("restore")}
</Button>
<Button size="xs" variant="subtle" onClick={editor.discardRecovery}>
{t("discard")}
</Button>
</Group>
</Stack>
</Alert>
)}
<Group gap="xs">
<PackageAssistant />
<Button
size="xs"
variant="default"
leftSection={<IconArrowBackUp size={14} />}
disabled={busy || !editor.historyState.undo}
onClick={editor.undo}
>
{t("undo")}
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconArrowForwardUp size={14} />}
disabled={busy || !editor.historyState.redo}
onClick={editor.redo}
>
{t("redo")}
</Button>
<Button size="xs" variant="subtle" disabled={busy || !editor.dirty} onClick={editor.reset}>
{t("reset")}
</Button>
<Tooltip label={t("historyDescription")} multiline maw={360}>
<ActionIcon variant="subtle" color="gray" aria-label={t("historyInfo")}>
<IconInfoCircle size={16} />
</ActionIcon>
</Tooltip>
</Group>
<SegmentedControl
className={classes.switcher}
value={pane}
onChange={setPane}
data={[
{ value: "edit", label: t("code") },
{ value: "preview", label: t("previewPane") },
]}
/>
<PackageSplit userId={userId} pane={pane} preview={<PackagePreview state={workspace} />}>
<Stack className={classes.authoring}>
<Tabs value={tab} onChange={selectTab}>
<Tabs.List>
<Tabs.Tab value="source">{t("code")}</Tabs.Tab>
<Tabs.Tab value="connections">{t("connections")}</Tabs.Tab>
<Tabs.Tab value="options">{t("options")}</Tabs.Tab>
<Tabs.Tab value="installation">{t("installation")}</Tabs.Tab>
<Tabs.Tab value="workshop">{t("workshopTab")}</Tabs.Tab>
</Tabs.List>
{visitedTabs.has("source") && (
<PackageSourceEditor state={workspace} existing={Boolean(workspace.installationId)} />
)}
<Tabs.Panel value="connections" pt="md">
{visitedTabs.has("connections") && (
<PackageConnections
requirements={parsed.success ? parsed.data.connections : {}}
bindings={bindings}
onChange={setBindings}
onPendingChange={workspace.connectionPreparationChanged}
onSave={saveLocalBindings}
saving={busy}
onConnectionSaved={workspace.connectionChanged}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="options" pt="md">
{visitedTabs.has("options") && (
<Stack>
<Text size="sm" c="dimmed">
{t("optionsDescription")}
</Text>
{parsed.success && (
<PackageOptionFields schema={parsed.data.options} value={effectiveOptions} onChange={setOptions} />
)}
{parsed.success && Object.keys(parsed.data.options).length === 0 && <Alert>{t("noOptions")}</Alert>}
<Button variant="subtle" onClick={() => setOptions({})}>
{t("resetOptions")}
</Button>
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="installation" pt="md">
{visitedTabs.has("installation") && (
<PackageOperations
installation={current}
initialBoardId={initialBoardId}
source={parsed.success ? parsed.data : undefined}
options={effectiveOptions}
dirty={editor.dirty}
trusted={trusted}
connectionsDirty={workspace.bindingsDirty || busy}
onTrustChange={workspace.setTrusted}
onSave={() => void saveDraft()}
saving={busy}
onError={setError}
onMessage={setMessage}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="workshop" pt="md">
{visitedTabs.has("workshop") && (
<Stack>
<Text size="sm" c="dimmed">
{t("workshopDescription")}
</Text>
{current ? (
<>
<PackageWorkshop id={current.id} dirty={editor.dirty || workspace.bindingsDirty || busy} />
<PackageTransfer
id={current.id}
active={Boolean(current.activeArtifactId)}
dirty={editor.dirty || workspace.bindingsDirty || busy}
onError={setError}
/>
</>
) : (
<Button onClick={() => void saveDraft()} loading={busy}>
{t("saveDraft")}
</Button>
)}
</Stack>
)}
</Tabs.Panel>
</Tabs>
</Stack>
</PackageSplit>
</Stack>
);
}
/* oxlint-disable jsx-a11y/prefer-tag-over-role -- A focusable window splitter is an interactive separator, not a thematic break. */
const defaultWidth = 60;
const clampWidth = (value: number, maximum = 75) => Math.min(maximum, Math.max(30, value));
/** The split is an editor preference; it never changes the widget's authored dimensions. */
function PackageSplit({
userId,
pane,
preview,
children,
}: PropsWithChildren<{ userId: string; pane: string; preview: ReactNode }>) {
const t = useI18n("customWidget.package");
const [width, setWidth] = useState(defaultWidth);
const [availableWidth, setAvailableWidth] = useState(0);
const container = useRef<HTMLDivElement>(null);
const key = `homarr:widget-workspace:split:${userId}`;
const latest = useRef(width);
let maximum = 75;
if (availableWidth >= 650) maximum = Math.min(75, ((availableWidth - 338) / availableWidth) * 100);
const visibleWidth = clampWidth(width, maximum);
useEffect(() => {
if (!container.current) return;
const observer = new ResizeObserver(([entry]) => setAvailableWidth(entry?.contentRect.width ?? 0));
observer.observe(container.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
try {
const saved = localStorage.getItem(key);
if (saved && Number.isFinite(Number(saved))) {
const next = clampWidth(Number(saved));
latest.current = next;
setWidth(next);
}
} catch {
// Browser storage is optional for editor preferences.
}
}, [key]);
const remember = (value: number) => {
const next = clampWidth(value, maximum);
latest.current = next;
setWidth(next);
try {
localStorage.setItem(key, String(next));
} catch {
// Resizing remains available when browser storage is disabled.
}
};
return (
<div
ref={container}
className={classes.workspace}
data-pane={pane}
style={{ "--widget-editor-width": `${visibleWidth}%` } as CSSProperties}
>
{children}
<div
className={classes.divider}
role="separator"
tabIndex={0}
aria-label={t("resizeEditor")}
title={t("resizeEditorHint")}
aria-orientation="vertical"
aria-valuemin={30}
aria-valuemax={Math.round(maximum)}
aria-valuenow={Math.round(visibleWidth)}
onDoubleClick={() => remember(defaultWidth)}
onKeyDown={(event) => {
let next = visibleWidth;
if (event.key === "ArrowLeft") next -= 5;
else if (event.key === "ArrowRight") next += 5;
else if (event.key === "Home") next = 30;
else if (event.key === "End") next = maximum;
else return;
event.preventDefault();
remember(next);
}}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
const bounds = container.current?.getBoundingClientRect();
if (!bounds?.width) return;
latest.current = clampWidth(((event.clientX - bounds.left) / bounds.width) * 100, maximum);
setWidth(latest.current);
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
event.currentTarget.releasePointerCapture(event.pointerId);
remember(latest.current);
}}
/>
{preview}
</div>
);
}
@@ -0,0 +1,326 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { extractErrorMessage, usePendingOperations } from "@homarr/common";
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { customWidgetPackageSchema, widgetPackagePathSchema } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import clock from "@homarr/widget-sdk/examples/clock.json";
import { getLogicalTrackSize } from "~/components/board/layout/geometry";
import {
documentFromSource,
documentFromTemplate,
documentSource,
usePackageDocument,
} from "@homarr/custom-widgets/workbench/package";
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
export type Installation = RouterOutputs["customWidget"]["package"]["get"];
type Preview = RouterOutputs["customWidget"]["package"]["preview"] & { liveActions: boolean };
export function usePackageWorkspace(
installation: Installation | undefined,
userId: string,
initialSource?: unknown,
initialName?: string,
initialBindings?: Record<string, string>,
initialBoardId?: string,
) {
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const [initial] = useState(() => {
if (installation) return documentFromSource(installation.name, installation.source);
return documentFromTemplate(initialName ?? t("newName"), initialSource ?? clock);
});
const [installationId, setInstallationId] = useState(installation?.id);
const [bindings, setBindings] = useState(installation?.bindings ?? initialBindings ?? {});
const [savedBindings, setSavedBindings] = useState(installation?.bindings ?? {});
const [connectionsPending, connectionPreparationChanged] = usePendingOperations();
const bindingsDirty =
JSON.stringify(Object.entries(bindings).toSorted()) !== JSON.stringify(Object.entries(savedBindings).toSorted());
const editor = usePackageDocument(initial, userId, installationId);
useUnsavedChangesGuard(editor.dirty || bindingsDirty, { navigationKey: installationId });
const { document, edit } = editor;
const [selected, setSelected] = useState(Object.keys(initial.files)[0] ?? "/widget.json");
const [path, setPath] = useState("");
useEffect(() => {
if (selected !== "/widget.json" && !Object.hasOwn(document.files, selected)) setSelected("/widget.json");
}, [document.files, selected]);
const [pane, setPane] = useState("edit");
const [trusted, setTrusted] = useState(false);
const [scenario, setScenario] = useState<string | null>(null);
const [liveActions, setLiveActions] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const currentInstallation = clientApi.customWidget.package.get.useQuery(
{ id: installationId ?? "" },
{ enabled: Boolean(installationId), initialData: installation },
);
const [options, setOptions] = useState<Record<string, unknown>>({});
const [preview, setPreview] = useState<Preview | null>(null);
const [previewExecutionKey, setPreviewExecutionKey] = useState("");
const [stale, setStale] = useState(false);
const [surface, setSurface] = useState<"tile" | "advanced" | "configuration">("tile");
const [gridSize, setGridSize] = useState({ width: 2, height: 2 });
const [width, setWidth] = useState(getLogicalTrackSize(2));
const [height, setHeight] = useState(getLogicalTrackSize(2));
const [scale, setScale] = useState(0.9);
const [previewTheme, setPreviewTheme] = useState<"system" | "light" | "dark">("system");
const previewId = useRef<string | null>(null);
const retiredPreviewIds = useRef(new Set<string>());
const generation = useRef(0);
const source = useMemo(() => documentSource(document), [document]);
const parsed = useMemo(() => customWidgetPackageSchema.safeParse(source), [source]);
const defaults = useMemo(() => (parsed.success ? getCustomWidgetDefaultOptions(parsed.data.options) : {}), [parsed]);
const effectiveOptions = useMemo(() => ({ ...defaults, ...options }), [defaults, options]);
const save = clientApi.customWidget.package.saveDraft.useMutation();
const previewMutation = clientApi.customWidget.package.preview.useMutation();
const discard = clientApi.customWidget.package.discardPreview.useMutation();
const saveBindings = clientApi.customWidget.package.setBindings.useMutation();
const discardPreview = discard.mutate;
const busy = save.isPending || saveBindings.isPending || previewMutation.isPending || connectionsPending;
const resetExecution = useCallback(() => {
generation.current += 1;
if (previewId.current) setStale(true);
}, []);
const executionKey = JSON.stringify([source, bindings, effectiveOptions, liveActions, scenario]);
useEffect(() => {
resetExecution();
}, [executionKey, resetExecution]);
useEffect(() => {
setTrusted(false);
}, [source]);
useEffect(() => {
setLiveActions(false);
}, [source, bindings, scenario]);
useEffect(() => {
if (scenario && parsed.success && !parsed.data.previewScenarios?.[scenario]) setScenario(null);
}, [parsed, scenario]);
useEffect(
() => () => {
generation.current += 1;
if (previewId.current) discardPreview({ previewId: previewId.current });
for (const id of retiredPreviewIds.current) discardPreview({ previewId: id });
retiredPreviewIds.current.clear();
},
[discardPreview],
);
const saveDraft = async () => {
if (busy) return;
setError("");
setMessage("");
const submitted = document;
try {
const result = await save.mutateAsync({ id: installationId, name: document.name, source });
setInstallationId(result.id);
editor.markSaved(submitted);
// Stay in the editor: a full navigation can trigger the unsaved-changes guard
// and discards selection, trust and the current preview after a successful save.
if (!installationId) {
const destination = new URL(window.location.href);
destination.pathname = destination.pathname.replace(/\/packages\/.*$/u, `/packages/${result.id}`);
if (initialBoardId) destination.searchParams.set("boardId", initialBoardId);
window.history.replaceState(null, "", destination.pathname + destination.search);
}
if (!installationId && Object.keys(bindings).length > 0) {
await saveBindings.mutateAsync({ id: result.id, bindings });
setSavedBindings(bindings);
}
await Promise.all([
utils.customWidget.package.get.invalidate(),
utils.customWidget.package.list.invalidate(),
utils.customWidget.package.draftChanges.invalidate(),
utils.customWidget.package.inspectArtifact.invalidate(),
]);
setMessage(t("draftSaved"));
return result.id;
} catch (cause) {
setError(extractErrorMessage(cause));
}
};
const runPreview = async () => {
if (!trusted || !parsed.success || busy) return;
const id = installationId ?? (await saveDraft());
if (!id) return;
resetExecution();
setError("");
const currentGeneration = generation.current;
try {
const next = await previewMutation.mutateAsync({
id,
source,
options: effectiveOptions,
bindings,
trusted: true,
liveActions,
scenario: scenario ?? undefined,
});
if (generation.current !== currentGeneration) {
discardPreview({ previewId: next.previewId });
return;
}
if (previewId.current) retiredPreviewIds.current.add(previewId.current);
previewId.current = next.previewId;
setPreview({ ...next, liveActions });
setPreviewExecutionKey(executionKey);
setStale(false);
setPane("preview");
} catch (cause) {
setError(extractErrorMessage(cause));
}
};
const addFile = () => {
const checked = widgetPackagePathSchema.safeParse(path.trim());
if (!checked.success) {
setError(checked.error.issues[0]?.message ?? t("invalidPath"));
return;
}
if (Object.hasOwn(document.files, checked.data)) {
setError(t("duplicatePath"));
return;
}
edit({ ...document, files: { ...document.files, [checked.data]: "" } });
setSelected(checked.data);
setPath("");
};
const removeFile = () => {
if (selected === "/widget.json") return;
const files = { ...document.files };
delete files[selected];
edit({ ...document, files });
setSelected("/widget.json");
};
const renameFile = () => {
if (selected === "/widget.json") return;
const checked = widgetPackagePathSchema.safeParse(path.trim());
if (!checked.success || Object.hasOwn(document.files, path.trim())) {
setError(t("invalidPath"));
return;
}
const files = { ...document.files, [checked.data]: document.files[selected] ?? "" };
delete files[selected];
edit({ ...document, files });
setSelected(checked.data);
setPath("");
setMessage(t("renameImports"));
};
let editorValue = document.metadata;
let language: "tsx" | "json" | "css" = "json";
if (selected !== "/widget.json") {
editorValue = document.files[selected] ?? "";
language = "tsx";
if (selected.endsWith(".css")) language = "css";
if (selected.endsWith(".json")) language = "json";
}
const surfaceChoices = [{ value: "tile", label: t("tile") }];
if (parsed.success && parsed.data.manifest.entrypoints.advanced)
surfaceChoices.push({ value: "advanced", label: t("advanced") });
if (parsed.success && parsed.data.manifest.entrypoints.configuration)
surfaceChoices.push({ value: "configuration", label: t("configuration") });
const saveLocalBindings = () => {
if (busy) return;
if (!installationId) {
void saveDraft();
return;
}
saveBindings.mutate(
{ id: installationId, bindings },
{
onSuccess: () => {
setSavedBindings(bindings);
setMessage(t("bindingsSaved"));
void utils.customWidget.package.get.invalidate();
void utils.customWidget.package.guestGrants.invalidate();
void utils.customWidget.package.webhooks.invalidate();
},
onError: (cause) => setError(cause.message),
},
);
};
const previewReady = (id: string) => {
if (previewId.current !== id) return;
for (const retired of retiredPreviewIds.current) {
if (retired !== id) discardPreview({ previewId: retired });
}
retiredPreviewIds.current.clear();
};
const previewLoadError = (id: string, cause: Error) => {
if (previewId.current !== id) return;
setStale(true);
setError(cause.message);
};
return {
installationId,
installation: currentInstallation.data,
editor,
document,
edit,
selected,
setSelected,
path,
setPath,
pane,
setPane,
trusted,
setTrusted,
liveActions,
setLiveActions,
scenario,
setScenario,
error,
setError,
message,
setMessage,
bindings,
bindingsDirty,
connectionPreparationChanged,
setBindings,
options,
setOptions,
preview,
stale: Boolean(preview) && (stale || previewExecutionKey !== executionKey),
previewReady,
previewLoadError,
surface,
setSurface,
width,
setWidth,
height,
setHeight,
gridSize,
setGridSize: (size: { width: number; height: number }) => {
setGridSize(size);
setWidth(getLogicalTrackSize(size.width));
setHeight(getLogicalTrackSize(size.height));
},
scale,
setScale,
previewTheme,
setPreviewTheme,
source,
parsed,
effectiveOptions,
save,
previewMutation,
saveBindings,
busy,
saveDraft,
runPreview,
addFile,
removeFile,
renameFile,
editorValue,
language,
surfaceChoices,
saveLocalBindings,
resetExecution,
connectionChanged: () => {
setLiveActions(false);
resetExecution();
},
};
}
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { Button } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { CustomWidgetTabs } from "../../../_custom-widget-tabs";
import { PackageCollectionSetup } from "../_collection-setup";
export default async function WidgetCollectionPage({ params }: { params: Promise<{ importId: string }> }) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const { importId } = await params;
const [collection, t] = await Promise.all([
api.customWidget.package.collection({ importId }),
getI18n("customWidget.package"),
]);
return (
<ManagePageLayout
title={collection.manifest.name}
breadcrumb={<DynamicBreadcrumb dynamicMappings={new Map([[importId, collection.manifest.name]])} />}
toolbar={<CustomWidgetTabs active="packages" />}
primaryAction={
<Button component={Link} href="/manage/custom-widgets/packages/collections" variant="default">
{t("collections.title")}
</Button>
}
>
<PackageCollectionSetup initialData={collection} />
</ManagePageLayout>
);
}
@@ -0,0 +1,136 @@
"use client";
import { extractErrorMessage } from "@homarr/common";
import { Alert, Badge, Button, Stack, Text, Accordion, Group, MultiSelect, TextInput } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { useState } from "react";
import { downloadPackage } from "@homarr/custom-widgets/workbench/package";
export function PackageCollectionList({
initialData,
installations,
}: {
initialData: RouterOutputs["customWidget"]["package"]["collections"];
installations: RouterOutputs["customWidget"]["package"]["list"];
}) {
const t = useI18n("customWidget.package.collections");
const collections = clientApi.customWidget.package.collections.useQuery(undefined, { initialData });
return (
<Stack>
<Text c="dimmed">{t("description")}</Text>
<PackageCollectionExport installations={installations} />
{collections.data?.length === 0 && <Alert>{t("empty")}</Alert>}
<ManageCollectionList ariaLabel={t("title")}>
{collections.data?.map((collection) => (
<ManageCollectionItem
key={collection.importId}
leading={<IconLayoutGrid size={26} stroke={1.4} />}
title={<Text fw={600}>{collection.manifest.name}</Text>}
badges={<Badge variant="light">{collection.manifest.version}</Badge>}
description={
<Stack gap={2}>
<Text size="sm" c="dimmed">
{collection.manifest.description}
</Text>
<Text size="sm" c="dimmed">
{t("members", { count: collection.installationIds.length, enabled: collection.enabledCount })}
</Text>
</Stack>
}
actions={
<Button
component={Link}
href={"/manage/custom-widgets/packages/collections/" + collection.importId}
variant="default"
>
{t("configure")}
</Button>
}
/>
))}
</ManageCollectionList>
</Stack>
);
}
export function PackageCollectionExport({
installations,
}: {
installations: RouterOutputs["customWidget"]["package"]["list"];
}) {
const t = useI18n("customWidget.package.collections");
const [selected, setSelected] = useState<string[]>([]);
const [name, setName] = useState("");
const [id, setId] = useState("");
const [version, setVersion] = useState("1.0.0");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const exportCollection = async () => {
setBusy(true);
setError("");
try {
const archive = await fetchApi.customWidget.package.exportCollection.query({
manifest: { id, name, version },
installationIds: selected,
});
downloadPackage(name, archive);
} catch (cause) {
setError(extractErrorMessage(cause));
} finally {
setBusy(false);
}
};
return (
<Accordion variant="contained">
<Accordion.Item value="export">
<Accordion.Control>{t("export")}</Accordion.Control>
<Accordion.Panel>
<Stack>
<Text size="sm" c="dimmed">
{t("exportDescription")}
</Text>
<MultiSelect
label={t("widgets")}
searchable
value={selected}
onChange={setSelected}
maxValues={32}
data={installations
.filter((entry) => entry.activeArtifactId)
.map((entry) => ({ value: entry.id, label: entry.name }))}
/>
<Group grow>
<TextInput label={t("name")} value={name} onChange={(event) => setName(event.currentTarget.value)} />
<TextInput
label={t("identity")}
description={t("identityDescription")}
placeholder={t("identityPlaceholder")}
value={id}
onChange={(event) => setId(event.currentTarget.value)}
/>
<TextInput
label={t("version")}
value={version}
onChange={(event) => setVersion(event.currentTarget.value)}
/>
</Group>
{error && <Alert color="red">{error}</Alert>}
<Button
loading={busy}
disabled={!selected.length || !name.trim() || !id.trim()}
onClick={() => void exportCollection()}
>
{t("export")}
</Button>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -0,0 +1,207 @@
"use client";
import { usePendingOperations } from "@homarr/common";
import { useState } from "react";
import { Accordion, Alert, Badge, Button, Checkbox, Group, Paper, Select, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { PackageOptionFields } from "@homarr/widgets/custom-api/package-option-fields";
import { PackageConnections } from "../_package-connections";
import { useCollectionPlacement } from "./_use-collection-placement";
import type { CollectionConfiguration, WidgetCollection } from "./_use-collection-placement";
export function PackageCollectionSetup({ initialData }: { initialData: WidgetCollection }) {
const t = useI18n("customWidget.package");
const query = clientApi.customWidget.package.collection.useQuery({ importId: initialData.importId }, { initialData });
const collection = query.data;
const boards = clientApi.board.getAllBoards.useQuery();
const saveBindings = clientApi.customWidget.package.setCollectionBindings.useMutation();
const [bindings, setBindings] = useState(initialData.bindings);
const [connectionsPending, connectionPreparationChanged] = usePendingOperations();
const [configuration, setConfiguration] = useState<CollectionConfiguration>({});
const [selected, setSelected] = useState(initialData.entries.map((entry) => entry.id));
const [boardId, setBoardId] = useState<string | null>(null);
const [reviewedKey, setReviewedKey] = useState<string | null>(null);
const reviewKey = JSON.stringify([
collection.entries.filter((entry) => selected.includes(entry.id)).map((entry) => [entry.id, entry.draftDigest]),
bindings,
]);
const trusted = reviewedKey === reviewKey;
const setTrusted = (value: boolean) => {
setReviewedKey(value ? reviewKey : null);
};
const [error, setError] = useState("");
const progress = useCollectionPlacement(collection);
const busy = progress.busy || saveBindings.isPending || connectionsPending;
const board = boards.data?.find((candidate) => candidate.id === boardId);
const requiredSlots = new Set(
collection.entries
.filter((entry) => selected.includes(entry.id))
.flatMap((entry) =>
Object.entries(entry.requirements)
.filter(([, requirement]) => !requirement.optional)
.map(([name]) => entry.mapping[name]),
),
);
const missing = Object.entries(collection.connections).filter(([name]) => requiredSlots.has(name) && !bindings[name]);
const save = () => {
if (busy) return;
setError("");
saveBindings.mutate(
{ importId: collection.importId, bindings },
{
onSuccess: () => void query.refetch(),
onError: (cause) => setError(cause.message),
},
);
};
return (
<Stack>
<Group>
<Badge variant="light">{collection.manifest.version}</Badge>
{collection.manifest.author && (
<Text size="sm" c="dimmed">
{collection.manifest.author}
</Text>
)}
{collection.manifest.license && (
<Text size="sm" c="dimmed">
{collection.manifest.license}
</Text>
)}
</Group>
<Text c="dimmed">{collection.manifest.description ?? t("collections.description")}</Text>
<Text size="sm">{t("collections.setupDescription")}</Text>
{(error || progress.error) && <Alert color="red">{error || progress.error}</Alert>}
{collection.bindingConflicts.length > 0 && (
<Alert color="yellow">
{t("collections.bindingConflicts", { names: collection.bindingConflicts.join(", ") })}
</Alert>
)}
{Object.keys(collection.connections).length > 0 && (
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("connections")}</Text>
<PackageConnections
requirements={collection.connections}
bindings={bindings}
onChange={setBindings}
onPendingChange={connectionPreparationChanged}
onSave={save}
saving={busy}
onConnectionSaved={() => setTrusted(false)}
/>
</Stack>
</Paper>
)}
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("collections.widgets")}</Text>
<Text size="sm" c="dimmed">
{t("collections.optionsDescription")}
</Text>
{collection.entries.map((entry) => (
<Stack key={entry.id} gap="xs">
<Group justify="space-between">
<Checkbox
checked={selected.includes(entry.id)}
disabled={busy || !entry.manifest}
label={entry.name}
onChange={(event) => {
const checked = event.currentTarget.checked;
setSelected((current) =>
checked ? [...current, entry.id] : current.filter((id) => id !== entry.id),
);
}}
/>
<Button
component={Link}
href={entry.managementPath}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
size="compact-xs"
>
{t("collections.review")}
</Button>
</Group>
{!entry.manifest && <Alert color="red">{t("collections.needsRepair", { name: entry.name })}</Alert>}
{progress.status[entry.id] && (
<Text size="sm" c={progress.status[entry.id]?.failed ? "red" : "dimmed"}>
{progress.status[entry.id]?.text}
</Text>
)}
{Object.keys(entry.optionDefinitions).length > 0 && (
<Accordion variant="default">
<Accordion.Item value="options">
<Accordion.Control>{t("options")}</Accordion.Control>
<Accordion.Panel>
<div inert={busy}>
<PackageOptionFields
schema={entry.optionDefinitions}
value={configuration[entry.id] ?? entry.configuration}
onChange={(value) => setConfiguration((current) => ({ ...current, [entry.id]: value }))}
/>
</div>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
))}
</Stack>
</Paper>
<Paper withBorder p="md">
<Stack>
<Select
searchable
label={t("board")}
value={boardId}
onChange={setBoardId}
disabled={busy}
data={(boards.data ?? []).map((candidate) => ({ value: candidate.id, label: candidate.name }))}
/>
{missing.length > 0 && (
<Alert color="yellow">
{t("bindFirst", { names: missing.map(([, requirement]) => requirement.label).join(", ") })}
</Alert>
)}
<Checkbox
checked={trusted}
disabled={busy}
onChange={(event) => setTrusted(event.currentTarget.checked)}
label={t("collections.trust")}
/>
<Text size="xs" c="dimmed">
{t("collections.activationDescription")}
</Text>
<Group>
<Button
loading={progress.busy}
disabled={!board || !trusted || !selected.length || missing.length > 0 || busy}
onClick={() => {
if (board) void progress.run(board, selected, bindings, configuration);
}}
>
{t("collections.activateAndAdd")}
</Button>
{progress.busy && (
<Button variant="default" disabled={progress.stopping} onClick={progress.stop}>
{t("collections.stop")}
</Button>
)}
{progress.completedBoard && (
<Button component={Link} href={"/boards/" + encodeURIComponent(progress.completedBoard)} variant="light">
{t("openBoard")}
</Button>
)}
</Group>
</Stack>
</Paper>
</Stack>
);
}
@@ -0,0 +1,128 @@
"use client";
import { extractErrorMessage } from "@homarr/common";
import { useEffect, useRef, useState } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi, fetchApi } from "@homarr/api/client";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
export type WidgetCollection = RouterOutputs["customWidget"]["package"]["collection"];
export type CollectionConfiguration = Record<string, Record<string, unknown>>;
export function useCollectionPlacement(collection: WidgetCollection) {
const t = useI18n("customWidget.package.collections");
const utils = clientApi.useUtils();
const stopRequested = useRef(false);
const running = useRef(false);
const [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false);
const [error, setError] = useState("");
const [status, setStatus] = useState<Record<string, { text: string; failed?: boolean }>>({});
const [completedBoard, setCompletedBoard] = useState<string | null>(null);
useEffect(
() => () => {
stopRequested.current = true;
},
[],
);
const run = async (
board: { id: string; name: string },
selected: string[],
bindings: Record<string, string>,
configuration: CollectionConfiguration,
) => {
if (running.current) return;
setError("");
setStatus({});
setCompletedBoard(null);
const entries = collection.entries.filter((entry) => selected.includes(entry.id));
for (const entry of entries) {
if (!entry.manifest) {
setError(t("needsRepair", { name: entry.name }));
return;
}
const issue = validateCustomWidgetOptions(
entry.optionDefinitions,
configuration[entry.id] ?? entry.configuration,
)[0];
if (issue) {
setError(entry.name + ": " + issue.message);
return;
}
}
stopRequested.current = false;
running.current = true;
setStopping(false);
setBusy(true);
let added = false;
try {
await fetchApi.customWidget.package.setCollectionBindings.mutate({ importId: collection.importId, bindings });
for (const entry of entries) {
if (stopRequested.current) break;
const report = (text: string, failed = false) =>
setStatus((current) => ({ ...current, [entry.id]: { text, failed } }));
try {
report(t("activating"));
await fetchApi.customWidget.package.activate.mutate({
id: entry.id,
trusted: true,
expectedDraftDigest: entry.draftDigest,
});
const installation = await fetchApi.customWidget.package.get.query({ id: entry.id });
// A response may have been lost after a successful previous add. Reading the installed
// placements makes a user's retry reuse that tile instead of duplicating it.
if (installation.placements.some((placement) => placement.boardId === board.id)) {
report(t("alreadyPlaced"));
added = true;
continue;
}
report(t("adding"));
await fetchApi.board.addItem.mutate({
boardId: board.id,
kind: "customApi",
integrationIds: [],
options: {
definitionId: entry.id,
configuration: configuration[entry.id] ?? entry.configuration,
configurationVersion: entry.manifest?.configurationVersion ?? 1,
connectionBindings: {},
refreshInterval: 30,
},
});
report(t("added"));
added = true;
} catch (cause) {
report(extractErrorMessage(cause), true);
}
}
if (added) setCompletedBoard(board.name);
} catch (cause) {
setError(extractErrorMessage(cause));
} finally {
running.current = false;
setBusy(false);
setStopping(false);
await Promise.allSettled([
utils.customWidget.package.collection.invalidate({ importId: collection.importId }),
utils.customWidget.package.collections.invalidate(),
utils.customWidget.package.list.invalidate(),
]);
}
};
return {
run,
busy,
stopping,
error,
status,
completedBoard,
stop() {
stopRequested.current = true;
setStopping(true);
},
};
}
@@ -0,0 +1,38 @@
import { redirect } from "next/navigation";
import { Button, Group } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { CustomWidgetTabs } from "../../_custom-widget-tabs";
import { PackageImportButton } from "../_package-list";
import { PackageCollectionList } from "./_collection-list";
export default async function WidgetCollectionsPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const [collections, installations, t] = await Promise.all([
api.customWidget.package.collections(),
api.customWidget.package.list(),
getI18n("customWidget.package"),
]);
return (
<ManagePageLayout
title={t("collections.title")}
toolbar={<CustomWidgetTabs active="packages" />}
primaryAction={
<Group gap="xs">
<Button component={Link} href="/manage/custom-widgets/packages" variant="subtle">
{t("library")}
</Button>
<PackageImportButton />
</Group>
}
>
<PackageCollectionList initialData={collections} installations={installations} />
</ManagePageLayout>
);
}
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { Container } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { PackageConvert } from "../../_package-convert";
export default async function ConvertWidgetPage({ params }: { params: Promise<{ definitionId: string }> }) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const { definitionId } = await params;
const definition = await api.customWidget.get({ id: definitionId }).catch(catchTrpcNotFound);
return (
<Container size="sm">
<PackageConvert definitionId={definitionId} name={definition.name} />
</Container>
);
}
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { Container, Stack } from "@mantine/core";
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
import { auth } from "@homarr/auth/next";
import { NativePackageWorkspace } from "../_package-convert";
import { PackageWorkspace } from "../_package-workspace";
export default async function NewWidgetPackagePage({
searchParams,
}: {
searchParams: Promise<{ starter?: string; sourceItemId?: string }>;
}) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const query = await searchParams;
const starter = Object.entries(widgetReferencePackages).find(([key]) => key === query.starter)?.[1];
if (query.sourceItemId && query.starter && Object.hasOwn(widgetReferencePackages, query.starter))
return (
<Container fluid>
<NativePackageWorkspace
userId={session.user.id}
sourceItemId={query.sourceItemId}
starter={query.starter as keyof typeof widgetReferencePackages}
/>
</Container>
);
return (
<Container fluid>
<Stack>
<PackageWorkspace userId={session.user.id} initialSource={starter} />
</Stack>
</Container>
);
}
@@ -0,0 +1,48 @@
import { redirect } from "next/navigation";
import { Button, Group } from "@mantine/core";
import { IconLayoutGrid, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetTabs } from "../_custom-widget-tabs";
import { PackageImportButton, PackageList } from "./_package-list";
export default async function WidgetPackagesPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const installations = await api.customWidget.package.list();
const t = await getI18n("customWidget.package");
return (
<ManagePageLayout
title={t("title")}
toolbar={<CustomWidgetTabs active="packages" />}
primaryAction={
<Group gap="xs">
<Button
component={Link}
href="/manage/custom-widgets/packages/collections"
variant="default"
leftSection={<IconLayoutGrid size={16} />}
>
{t("collections.title")}
</Button>
<PackageImportButton />
<MobileAffixButton
component={Link}
href="/manage/custom-widgets/packages/new"
leftSection={<IconPlus size={16} />}
>
{t("create")}
</MobileAffixButton>
</Group>
}
>
<PackageList initialData={installations} />
</ManagePageLayout>
);
}
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { Container } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { PackageSessionPreview } from "../../_package-preview";
export default async function PackagePreviewPage({ params }: { params: Promise<{ previewId: string }> }) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const { previewId } = await params;
return (
<Container fluid>
<PackageSessionPreview previewId={previewId} />
</Container>
);
}
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Checkbox,
@@ -25,12 +27,19 @@ import {
IconShieldCheck,
} from "@tabler/icons-react";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { extractErrorMessage } from "@homarr/common";
import { clientApi } from "@homarr/api/client";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { MAX_WORKSHOP_SCREENSHOT_BYTES, workshopScreenshotsSchema } from "@homarr/workshop/schema";
import {
MAX_WORKSHOP_SCREENSHOTS,
MAX_WORKSHOP_SCREENSHOT_BYTES,
workshopScreenshotsSchema,
} from "@homarr/workshop/schema";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
@@ -43,24 +52,43 @@ import {
} from "~/components/workshop/workshop-publish-definition";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
const listHref = "/manage/custom-widgets";
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
export function WorkshopPublishForm({
widget,
kind = "definition",
}: {
widget: { id: string; name: string; description?: string; version?: string };
kind?: "definition" | "package";
}) {
const isPackage = kind === "package";
const listHref = isPackage ? "/manage/custom-widgets/packages" : "/manage/custom-widgets";
const t = useI18n("workshop");
const tCommon = useI18n("common");
const { formatBytes } = useByteFormatter();
const session = useWorkshopSession();
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const utils = clientApi.useUtils();
const [title, setTitle] = useState(widget.name.slice(0, 100));
const [description, setDescription] = useState(widget.description ?? "");
const [version, setVersion] = useState(widget.version ?? "1.0.0");
const [changelog, setChangelog] = useState("");
const [publishing, setPublishing] = useState(false);
const inFlight = useRef(false);
const [screenshotError, setScreenshotError] = useState<string | null>(null);
const [screenshots, setScreenshots] = useState<File[]>([]);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
const createSubmission = useWorkshopCreateMutation(session.client);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id });
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: !isPackage });
const packageDraft = clientApi.customWidget.package.get.useQuery({ id: widget.id }, { enabled: isPackage });
const publishPackage = clientApi.customWidget.package.publishWorkshop.useMutation();
const packageSource = customWidgetPackageSchema.safeParse(packageDraft.data?.source);
const inspectedDefinition = isPackage ? packageDraft.data?.source : definition.data;
const loadError = isPackage ? packageDraft.isError : definition.isError;
const origin = packageDraft.data?.workshop;
const updating = Boolean(isPackage && session.user && origin?.author === session.user.id);
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
const definitionFingerprint = inspectedDefinition ? serializeWorkshopDefinition(inspectedDefinition) : null;
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const breadcrumb = <DynamicBreadcrumb dynamicMappings={new Map([[widget.id, widget.name]])} />;
@@ -68,17 +96,48 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
const publish = async () => {
// Publishing is immediate and public; a second in-flight call would create a
// duplicate listing.
if (createSubmission.isPending) return;
if (inFlight.current) return;
inFlight.current = true;
setPublishing(true);
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!inspectedDefinition) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) {
throw new Error(t("publish.invalidScreenshot", { maxSize: formatBytes(MAX_WORKSHOP_SCREENSHOT_BYTES) }));
}
if (isPackage) {
const token = session.client.authToken;
if (!token || !packageDraft.data || !packageSource.success) throw new Error(t("publish.error"));
if (updating && origin && screenshots.length > 0) {
const listing = await session.client.get(origin.submissionId);
if (listing.screenshots.length + screenshots.length > MAX_WORKSHOP_SCREENSHOTS)
throw new Error(t("publish.screenshotLimit"));
}
const result = await publishPackage.mutateAsync({
id: widget.id,
token,
mode: "auto",
title,
description,
version,
changelog,
expectedDraftDigest: packageDraft.data.draftDigest,
});
// A release is already public here. Screenshot failure must not invite a duplicate publication.
try {
await session.client.addScreenshots(result.submissionId, screenshots);
} catch (cause) {
setScreenshotError(extractErrorMessage(cause));
}
void utils.customWidget.package.list.invalidate();
void utils.customWidget.package.get.invalidate({ id: widget.id });
setPublishedSubmissionId(result.submissionId);
return;
}
let submissionId: string | null = null;
const result = await publishWorkshopDefinition({
inspectedDefinition: definition.data,
inspectedDefinition,
refetchDefinition: async () => (await definition.refetch()).data,
publish: async (content) => {
const submission = await createSubmission.mutateAsync({
@@ -98,6 +157,9 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
setPublishedSubmissionId(submissionId);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
} finally {
inFlight.current = false;
setPublishing(false);
}
};
@@ -115,6 +177,11 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
<Text c="dimmed" size="sm" ta="center" maw={520}>
{t("publish.manageDescription")}
</Text>
{screenshotError && (
<Alert color="yellow">
{t("publish.screenshotUploadFailed")} {screenshotError}
</Alert>
)}
<Group gap="sm">
<Button component={Link} href={listHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
{t("publish.done")}
@@ -137,9 +204,10 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
const blocked =
!session.user ||
createSubmission.isPending ||
!definition.data ||
definition.isError ||
publishing ||
!inspectedDefinition ||
loadError ||
(isPackage && !packageSource.success) ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed);
@@ -165,13 +233,20 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
<Box>
<Text fw={600}>{t("publish.introTitle")}</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.introDescription")}
{isPackage ? t("publish.packageDescription") : t("publish.introDescription")}
</Text>
</Box>
</Group>
</Paper>
{!session.user && <Alert color="blue">{t("publish.signInHint")}</Alert>}
{session.user && (
<Alert color="blue">
<Text size="sm">{t("publish.publishingAs", { name: session.user.name })}</Text>
{updating && <Text size="sm">{t("publish.updatingListing")}</Text>}
{isPackage && origin && !updating && <Text size="sm">{t("publish.attributedCopy")}</Text>}
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Stack gap="md">
@@ -185,6 +260,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
</Box>
<TextInput
label={t("publish.titleField")}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
@@ -211,6 +287,38 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
</Stack>
</Paper>
{isPackage && (
<Accordion variant="contained">
<Accordion.Item value="release">
<Accordion.Control>
<Group gap="xs">
{t("publish.releaseDetails")}
<Badge variant="light">v{version}</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack>
<TextInput
label={t("publish.version")}
value={version}
onChange={(event) => setVersion(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.releaseNotes")}
description={t("publish.releaseNotesDescription")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
autosize
minRows={2}
maxLength={2000}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
{privateSourceNames.length > 0 && (
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
@@ -225,7 +333,15 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{isPackage && packageDraft.data && !packageSource.success && (
<Alert color="yellow">
<Text size="sm">{t("publish.packageInvalid")}</Text>
<Button component={Link} href={`/manage/custom-widgets/packages/${widget.id}`} variant="subtle" size="xs">
{tCommon("action.edit")}
</Button>
</Alert>
)}
{loadError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<ManageStickyFooter
@@ -238,13 +354,8 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
<Button component={Link} href={listHref} variant="default">
{tCommon("action.cancel")}
</Button>
<Button
size="md"
loading={createSubmission.isPending || definition.isFetching}
disabled={blocked}
onClick={() => void publish()}
>
{t("publish.action")}
<Button size="md" loading={publishing} disabled={blocked} onClick={() => void publish()}>
{updating ? t("publish.updateAction") : t("publish.action")}
</Button>
</ManageStickyFooter>
</Stack>
@@ -1,5 +1,7 @@
import { redirect } from "next/navigation";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -8,6 +10,7 @@ import { WorkshopPublishForm } from "./_workshop-publish-form";
interface WorkshopPublishPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ kind?: string }>;
}
export default async function WorkshopPublishPage(props: WorkshopPublishPageProps) {
@@ -15,6 +18,22 @@ export default async function WorkshopPublishPage(props: WorkshopPublishPageProp
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { id } = await props.params;
const { kind } = await props.searchParams;
if (kind === "package") {
const installation = await api.customWidget.package.get({ id }).catch(catchTrpcNotFound);
const parsed = customWidgetPackageSchema.safeParse(installation.source);
let version = parsed.data?.manifest.version ?? "1.0.0";
if (installation.workshop?.version === version) {
const [major, minor, patch] = version.split(/[.+-]/u);
version = `${major}.${minor}.${Number(patch) + 1}`;
}
return (
<WorkshopPublishForm
kind="package"
widget={{ id, name: installation.name, description: parsed.data?.manifest.description, version }}
/>
);
}
const definition = await api.customWidget.get({ id }).catch(catchTrpcNotFound);
return <WorkshopPublishForm widget={{ id, name: definition.name }} />;
@@ -0,0 +1,108 @@
"use client";
import { usePendingOperations } from "@homarr/common";
import { useState } from "react";
import { Accordion, Alert, Button, Paper, Select, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "~/components/custom-widgets/code-editor";
import { PackageConnections } from "../../packages/_package-connections";
import { PackageArtifactReview } from "../../packages/_package-workshop";
export function WorkshopPackageReview({ submissionId }: { submissionId: string }) {
const t = useI18n("customWidget.package");
const releases = clientApi.customWidget.package.workshopReleases.useQuery({ submissionId });
const [selected, setSelected] = useState<string | null>(null);
const releaseId = selected ?? releases.data?.[0]?.id ?? "";
const release = clientApi.customWidget.package.workshopRelease.useQuery(
{ releaseId },
{ enabled: Boolean(releaseId) },
);
const [bindings, setBindings] = useState<Record<string, string>>({});
const [connectionsPending, connectionPreparationChanged] = usePendingOperations();
const install = clientApi.customWidget.package.installWorkshop.useMutation();
const busy = install.isPending || connectionsPending;
return (
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("releaseReview")}</Text>
<Text size="sm">{t("sourceOnly")}</Text>
<Select
label={t("release")}
data={(releases.data ?? []).map((entry) => ({
value: entry.id,
label: `${entry.version} · ${entry.created}`,
}))}
value={releaseId || null}
disabled={busy}
onChange={(value) => {
setSelected(value);
setBindings({});
}}
/>
{releases.error && <Alert color="red">{releases.error.message}</Alert>}
{release.error && <Alert color="red">{release.error.message}</Alert>}
{release.data && (
<>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{release.data.release.changelog}
</Text>
<Text size="sm">
{t("dependencies")}:{" "}
{Object.entries(release.data.source.dependencies)
.map(([name, version]) => `${name}@${version}`)
.join(", ") || t("hostOnly")}
</Text>
<Text size="xs" ff="monospace">
{release.data.origin.sourceDigest}
</Text>
<Accordion>
{Object.entries(release.data.source.files).map(([path, content]) => (
<Accordion.Item key={path} value={path}>
<Accordion.Control>{path}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id={`workshop-${path}`}
label={path}
language={path.endsWith(".css") ? "css" : "tsx"}
value={content}
readOnly
onChange={() => undefined}
/>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
{release.data.artifact && <PackageArtifactReview artifact={release.data.artifact} />}
<PackageConnections
requirements={release.data.source.connections}
bindings={bindings}
onChange={setBindings}
onPendingChange={connectionPreparationChanged}
saving={busy}
/>
<Text size="sm" c="dimmed">
{t("installDisabled")}
</Text>
<Button
loading={install.isPending}
disabled={busy}
onClick={() =>
install.mutate(
{ releaseId, bindings },
{ onSuccess: (result) => window.location.assign(result.managementPath) },
)
}
>
{t("installReview")}
</Button>
</>
)}
{install.error && <Alert color="red">{install.error.message}</Alert>}
</Stack>
</Paper>
);
}
@@ -20,6 +20,8 @@ import {
import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconFlag } from "@tabler/icons-react";
import { CUSTOM_WIDGET_PACKAGE_SCHEMA } from "@homarr/custom-widgets/package";
import { WorkshopPackageReview } from "./_package-review";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
@@ -27,7 +29,7 @@ import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop/schema";
import { validateWorkshopContent, workshopExportFilename } from "@homarr/workshop/schema";
import { useCustomWidgetImport } from "~/components/custom-widgets/use-custom-widget-import";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
@@ -53,11 +55,18 @@ export function WorkshopDetail({ id }: { id: string }) {
const reportSummaries = useWorkshopReportSummariesQuery(session.client, detail.data?.reportCount ? id : "");
const content = detail.data?.content;
const validation = useMemo(() => (content ? validateWorkshopWidget(content) : null), [content]);
const validation = useMemo(() => (content ? validateWorkshopContent("customWidget", content) : null), [content]);
const widget = useMemo<HomarrCustomWidgetV2 | null>(
() => (validation?.success && typeof validation.data !== "string" ? validation.data : null),
() =>
validation?.success && typeof validation.data !== "string" && validation.data.$schema === CUSTOM_WIDGET_SCHEMA
? validation.data
: null,
[validation],
);
const packageCompatible =
validation?.success &&
typeof validation.data !== "string" &&
validation.data.$schema === CUSTOM_WIDGET_PACKAGE_SCHEMA;
const compatible =
detail.data?.type === "customWidget" && widget !== null && detail.data.widgetSchema === CUSTOM_WIDGET_SCHEMA;
@@ -164,7 +173,7 @@ export function WorkshopDetail({ id }: { id: string }) {
)}
</Stack>
)}
{!compatible && (
{!compatible && !packageCompatible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("installError")}>
{t("installErrorDescription")}
{validation && !validation.success ? ` ${validation.error}` : ""}
@@ -180,6 +189,7 @@ export function WorkshopDetail({ id }: { id: string }) {
screenshots={submission.screenshots}
/>
{packageCompatible && <WorkshopPackageReview submissionId={submission.id} />}
{compatible && importer.review && (
<Paper withBorder radius="md" p="md">
<Stack gap="md">
@@ -258,14 +268,16 @@ export function WorkshopDetail({ id }: { id: string }) {
</>
}
>
<Button
size="md"
loading={importer.pending || importer.succeeded}
disabled={!compatible || !importer.ready || importer.succeeded}
onClick={importer.importWidget}
>
{t("install")}
</Button>
{!packageCompatible && (
<Button
size="md"
loading={importer.pending || importer.succeeded}
disabled={!compatible || !importer.ready || importer.succeeded}
onClick={importer.importWidget}
>
{t("install")}
</Button>
)}
</ManageStickyFooter>
</Stack>
</ManagePageLayout>
@@ -250,7 +250,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation>
<ClientShell hasNavigation management>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -80,7 +81,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -27,6 +27,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -125,7 +126,7 @@ export const UserGeneralSettingsForm = ({
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const weekDays = useMemo(() => dayjs.weekdays(false), []);
@@ -45,7 +45,7 @@ export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate),
expirationDate: new Date(values.expirationDate).toISOString(),
},
{
onSuccess: (result) => {
+12 -17
View File
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantRequestContextEntitiesAsync,
@@ -76,6 +76,7 @@ import {
needsCustomWidgetAuthoringContext,
} from "./custom-widget-authoring-context";
import { createAssistantMcpToolGroups } from "./assistant-tool-groups";
import { isWidgetPackageWorkbench, widgetPackageAuthoringInstructions } from "./widget-package-authoring-context";
import {
customWidgetAssistantInstructions,
getForcedAssistantToolName,
@@ -497,7 +498,10 @@ export async function POST(request: Request) {
return Response.json({ error: "The selected model does not support image input." }, { status: 400 });
}
const canAuthorCustomWidgets = session.user.permissions.includes("admin");
const customWidgetAuthoringActive = canAuthorCustomWidgets && needsCustomWidgetAuthoringContext(incomingMessages);
const widgetPackageAuthoringActive =
canAuthorCustomWidgets && isWidgetPackageWorkbench(parsed.data.clientContext?.pathname);
const customWidgetAuthoringActive =
canAuthorCustomWidgets && !widgetPackageAuthoringActive && needsCustomWidgetAuthoringContext(incomingMessages);
const customWidgetDiscoveryPhase = createCustomWidgetDiscoveryPhaseController();
const customWidgetToolStepGate = createCustomWidgetToolStepGate();
const loadedCustomWidgetContextRequests = new Set<string>();
@@ -547,19 +551,7 @@ export async function POST(request: Request) {
}
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
try {
const procedure = mcpTool.pathInRouter.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
if (typeof procedure !== "function") {
throw new Error("Procedure not callable");
}
const result = await (procedure as (value: unknown) => Promise<unknown>)(
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
);
const result = await callMcpTool(caller, mcpTool, executionInput);
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
return toAssistantToolOutput(result, {
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
@@ -640,7 +632,7 @@ export async function POST(request: Request) {
const activeCustomWidgetToolNames = getActiveCustomWidgetToolNames(
Object.keys(homarrTools),
incomingMessages,
canAuthorCustomWidgets,
canAuthorCustomWidgets && !widgetPackageAuthoringActive,
);
const getActiveToolNames = (steps: Parameters<typeof getCustomWidgetPhaseToolNames>[1] = []) => {
const enabledToolNames = assistantToolGroups
@@ -705,7 +697,10 @@ export async function POST(request: Request) {
const initialModelMessages = await convertAssistantMessagesToModelMessages(
prepareMessagesForModel(incomingMessages),
);
const baseInstructions = `${assistantInstructions}${customWidgetAuthoringActive ? customWidgetAssistantInstructions : ""}${openRouterServerToolsEnabled ? webSearchInstructions : ""}${requestContext}`;
let authoringInstructions = "";
if (widgetPackageAuthoringActive) authoringInstructions = widgetPackageAuthoringInstructions;
else if (customWidgetAuthoringActive) authoringInstructions = customWidgetAssistantInstructions;
const baseInstructions = `${assistantInstructions}${authoringInstructions}${openRouterServerToolsEnabled ? webSearchInstructions : ""}${requestContext}`;
const getStepInstructions = (activeToolNames: readonly string[]) => {
if (!customWidgetAuthoringActive) return undefined;
return appendActiveCustomWidgetToolInstruction(baseInstructions, activeToolNames);
@@ -0,0 +1,15 @@
export function isWidgetPackageWorkbench(pathname: string | undefined) {
return /(?:^|\/)manage\/custom-widgets\/packages\/(?:new|[A-Za-z0-9_-]+)\/?$/u.test(pathname ?? "");
}
export const widgetPackageAuthoringInstructions = `
The user is editing a trusted Custom Widget v3 package in the open creator workspace.
Use read_widget_package_draft to read the visible draft and its installed SDK reference before proposing code.
Use propose_widget_package_changes for edits to that draft. The user reviews the proposed source changes, and applying them creates one Undo step.
This is the full React/TypeScript/CSS package format, not the v2 JSX expression interpreter. Do not use v2 authoring bootstrap or v2 preview/create tools for this workspace.
Preserve existing files, dependencies, connection requirements, options, and local changes unless the requested edit needs to change them. Reuse supported native integration adapters.
Keep credentials in local connection settings. Never request or embed secrets in package source, examples, fixtures, prompts, or tool arguments.
A proposed or applied draft is not saved, previewed, activated, installed on a board, or published. Report those states only from actual lifecycle tool results for the exact candidate.
Preview execution requires explicit owner trust. SDK action simulation does not sandbox arbitrary trusted code. Offline services do not prevent draft saving.
For lifecycle operations, use the customWidget package tools with the returned installation identity and retain normal operation approvals.
`;
@@ -1,5 +1,6 @@
import crypto from "crypto";
import fs from "fs";
import { countEncryptedWidgetValues, reencryptWidgetValues } from "../widget-secrets";
import path from "path";
import { NextResponse } from "next/server";
@@ -133,6 +134,7 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKe
const oldKey = Buffer.from(importedKeyHex, "hex");
const newKey = Buffer.from(currentKeyHex, "hex");
reencryptWidgetValues(tempDb, oldKey, newKey);
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
integration_id: string;
@@ -305,7 +307,7 @@ export async function POST(req: Request) {
const secretCount = (tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number })
.count;
if (secretCount > 0 && !importedKey) {
if (secretCount + countEncryptedWidgetValues(tempDb) > 0 && !importedKey) {
throw new Error(
"Backup contains integration secrets but no encryption key. " +
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
Loaded 100 of 398 files, more files were not shown because too many files have changed in this diff. Show more