Compare commits
219
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37289a2247 | ||
|
|
7b672e180e | ||
|
|
854b59ede4 | ||
|
|
eb918b805a | ||
|
|
4a4b04d471 | ||
|
|
3943741838 | ||
|
|
e425e060d9 | ||
|
|
6754e53f56 | ||
|
|
357adffb30 | ||
|
|
d24eca12ef | ||
|
|
dfcd1dbb64 | ||
|
|
252ce998ae | ||
|
|
a940a8223d | ||
|
|
bbd1abd4f0 | ||
|
|
7adc18b1ce | ||
|
|
6a5162d615 | ||
|
|
02fe99d5aa | ||
|
|
7bbca3bd40 | ||
|
|
923a528038 | ||
|
|
bb599e7fae | ||
|
|
bb5df3a2e6 | ||
|
|
c805d5c631 | ||
|
|
747234c040 | ||
|
|
b828f59568 | ||
|
|
60ea03e72e | ||
|
|
e9853f1fb1 | ||
|
|
f8b364dce2 | ||
|
|
cd8773681f | ||
|
|
2f99f1f34b | ||
|
|
6fc851f054 | ||
|
|
7bedf4a13d | ||
|
|
a9c8b65c14 | ||
|
|
e336059079 | ||
|
|
708105ab3f | ||
|
|
e6bd1adfc2 | ||
|
|
b217d8e026 | ||
|
|
35b7c4fc2d | ||
|
|
251a4c8ae8 | ||
|
|
b4404113c4 | ||
|
|
c1efaabd36 | ||
|
|
b75483982f | ||
|
|
c1da19c9b2 | ||
|
|
46898cd0ea | ||
|
|
6f4cd7522f | ||
|
|
eaaeaaccdb | ||
|
|
d63653142d | ||
|
|
08588da0d1 | ||
|
|
464f30799a | ||
|
|
bd4bbcaca2 | ||
|
|
09b82ba171 | ||
|
|
f38eb034da | ||
|
|
a3a89db727 | ||
|
|
c099a8eef6 | ||
|
|
9437d211c8 | ||
|
|
096cf56ac9 | ||
|
|
851129e62e | ||
|
|
cc1b22f052 | ||
|
|
5df4358f0d | ||
|
|
821b5a555d | ||
|
|
a0f4a58386 | ||
|
|
7c840bcfa0 | ||
|
|
38d30acb87 | ||
|
|
ae515584cc | ||
|
|
9338d6d4ec | ||
|
|
d98b7797c1 | ||
|
|
3c739dc636 | ||
|
|
639cb8d35f | ||
|
|
f1ac482684 | ||
|
|
9cc7fd2051 | ||
|
|
d4d951b99c | ||
|
|
a4f115064e | ||
|
|
07e0642192 | ||
|
|
965ccb32d6 | ||
|
|
be08f70c8d | ||
|
|
4892c399ba | ||
|
|
687fb323f7 | ||
|
|
56cc021f98 | ||
|
|
836a4cd46b | ||
|
|
a607dff50a | ||
|
|
68751d85dc | ||
|
|
869dd45de5 | ||
|
|
352aa7dd64 | ||
|
|
801221d2da | ||
|
|
861538e9a0 | ||
|
|
cb7b49b3e7 | ||
|
|
f5cced4080 | ||
|
|
e429d7540a | ||
|
|
3bd80dcc58 | ||
|
|
0e01ac67df | ||
|
|
8259df58f6 | ||
|
|
3485ef0877 | ||
|
|
2214acd17e | ||
|
|
25b835dca9 | ||
|
|
2efa6bcd4c | ||
|
|
c67c893f8f | ||
|
|
e7e44375ef | ||
|
|
842e86012d | ||
|
|
a8a7545f40 | ||
|
|
ed7b30dc5f | ||
|
|
c09ed96978 | ||
|
|
44b55a401c | ||
|
|
f00f5b83b5 | ||
|
|
5553c4e0c5 | ||
|
|
6cdda54e10 | ||
|
|
0f9d038aaf | ||
|
|
a38e731209 | ||
|
|
b1b17ad69f | ||
|
|
add40370aa | ||
|
|
a2763d1f6f | ||
|
|
f3048f4804 | ||
|
|
6bfa9c33d0 | ||
|
|
16982aa178 | ||
|
|
a2400f8412 | ||
|
|
6e455760a3 | ||
|
|
0b9b77c632 | ||
|
|
346bff540f | ||
|
|
f8771c371f | ||
|
|
71cc3abbe9 | ||
|
|
a098c42013 | ||
|
|
4cf83443fd | ||
|
|
f5b4889663 | ||
|
|
06aa5caf0b | ||
|
|
d3534dfcf8 | ||
|
|
f7591dd196 | ||
|
|
0c5f7a45bb | ||
|
|
cdbba92f31 | ||
|
|
3b0633f6a8 | ||
|
|
d0880aa04c | ||
|
|
7cf6a5d5ab | ||
|
|
d5763737c3 | ||
|
|
00b0b215b0 | ||
|
|
5f08327c5c | ||
|
|
2645cb426a | ||
|
|
65e866064e | ||
|
|
392df12d8a | ||
|
|
6209980b27 | ||
|
|
3f04cf83a2 | ||
|
|
e5a8194c03 | ||
|
|
90cf0e1127 | ||
|
|
498481d539 | ||
|
|
65da74858b | ||
|
|
af2b7b3157 | ||
|
|
db40779492 | ||
|
|
91aeac8377 | ||
|
|
dfe2ddf34b | ||
|
|
34f2a0bd24 | ||
|
|
4227e392b5 | ||
|
|
6893205ee0 | ||
|
|
28ac23e66b | ||
|
|
cddae6719e | ||
|
|
0cb75982fc | ||
|
|
36de8131c8 | ||
|
|
2f28f45ff1 | ||
|
|
915af9ae96 | ||
|
|
db5d088330 | ||
|
|
d1a2ed8fd7 | ||
|
|
d6b114e423 | ||
|
|
e41d938a5f | ||
|
|
afa54755fd | ||
|
|
0482c040ec | ||
|
|
5dc4dbeaef | ||
|
|
f3f2778f1f | ||
|
|
c08b8979ee | ||
|
|
9a7a828618 | ||
|
|
352a8ed0d0 | ||
|
|
6d3f7e3e36 | ||
|
|
7833cca86b | ||
|
|
6edf78acd7 | ||
|
|
7d43f6e5d9 | ||
|
|
1723a9e744 | ||
|
|
2ba31fd37b | ||
|
|
327e5c15ae | ||
|
|
3efe64a062 | ||
|
|
40b726b8bc | ||
|
|
d2d0d16e4d | ||
|
|
63ad879022 | ||
|
|
2ae0c2ab43 | ||
|
|
2410ba5f5c | ||
|
|
8f0039245d | ||
|
|
0871e4cf0d | ||
|
|
db9f42520a | ||
|
|
3ebcedec26 | ||
|
|
c41a3d427e | ||
|
|
71c39aa18e | ||
|
|
000c51a131 | ||
|
|
2c44c7f2b8 | ||
|
|
c2cf25b4fb | ||
|
|
03ebb8c2db | ||
|
|
f0b153aa37 | ||
|
|
965d130ce7 | ||
|
|
a87a16edb1 | ||
|
|
1493252e28 | ||
|
|
46ff20b0a0 | ||
|
|
1bc84dd90e | ||
|
|
14cbc03621 | ||
|
|
9ec35b0cda | ||
|
|
2d6dd2f0d0 | ||
|
|
572bc71396 | ||
|
|
d4b1739c46 | ||
|
|
76495948e2 | ||
|
|
a95052c787 | ||
|
|
400127b45c | ||
|
|
f77c47b94b | ||
|
|
825636c4f6 | ||
|
|
3aa14e22ed | ||
|
|
acdfcbb18e | ||
|
|
3a1245cbc5 | ||
|
|
f0963405ca | ||
|
|
d078d761d9 | ||
|
|
84fcf02729 | ||
|
|
c308ae5c6e | ||
|
|
93192cfedc | ||
|
|
0f9ae13560 | ||
|
|
f4a6f37415 | ||
|
|
95bfaf5127 | ||
|
|
83e3b22f9a | ||
|
|
cd71404288 | ||
|
|
7a089ab177 | ||
|
|
2513e8454f |
No files matched your search
@@ -7,15 +7,11 @@ description: Author safe Homarr Custom JSX v2 widgets from API documentation.
|
||||
|
||||
Author one widget at a time. When connected to Homarr, retrieve `homarr://custom-widgets/schema` and only the component resources needed for the design. The live resources are authoritative for the installed release.
|
||||
|
||||
Keep documentation retrieval targeted: fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only when a validation issue or a concrete missing interaction requires it; never load the catalog component-by-component.
|
||||
|
||||
1. Read the API documentation.
|
||||
2. Create one credential-free widget with keyed `sources`, keyed `requests`, optional keyed `options`, and safe JSX `template`.
|
||||
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
|
||||
4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext.
|
||||
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. When connected through MCP, persist the exact final tested preview with `customWidget_createFromPreview`; do not resend a large template through `customWidget_create` unless no 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. Show concise freshness context when the API supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path.
|
||||
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
|
||||
|
||||
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. Templates read `data`, `status`, `options`, and temporary `inputs`.
|
||||
|
||||
@@ -23,10 +19,6 @@ Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$para
|
||||
|
||||
Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards.
|
||||
|
||||
Aim for distinctive polish without decorative gradients or fragile custom styling: use one semantic accent, clear surface contrast, purposeful typography, and compact rows that remain scannable in narrow tiles. Give standalone icons an accessible label or pair them with visible text.
|
||||
|
||||
Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
|
||||
|
||||
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
|
||||
|
||||
To install an existing widget, use `customWidget_workshopSearch`, inspect it with `customWidget_workshopGet`, then use `customWidget_workshopInstall`. Configure each self-hosted source afterward with `customWidget_sourceConfigure`, or call `customWidget_configurationRequestUser` so the user can enter the server URL and credentials directly in Homarr.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 100 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"label": "Advanced",
|
||||
"collapsed": true,
|
||||
"position": 5,
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"title": "Advanced Concepts or features"
|
||||
|
||||
@@ -57,8 +57,50 @@ Homarr becomes the instance administrator.
|
||||
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
|
||||
default data; use `pnpm db:seed` to seed it explicitly later.
|
||||
|
||||
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
[Workshop development guide](../../workshop/development).
|
||||
### Run PocketBase and documentation
|
||||
|
||||
Start PocketBase in another terminal:
|
||||
|
||||
```sh
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
Then start Docusaurus in a third terminal:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
|
||||
|
||||
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
|
||||
`http://127.0.0.1:3003/workshop`.
|
||||
|
||||
Point Homarr at the local services with:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See
|
||||
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
|
||||
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
|
||||
|
||||
### URL testing
|
||||
|
||||
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
|
||||
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
|
||||
separate API origin before release:
|
||||
|
||||
```sh
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 \
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
|
||||
pnpm dev:docs
|
||||
```
|
||||
|
||||
### Useful commands
|
||||
|
||||
@@ -70,6 +112,7 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
- `pnpm dev:cli -- rebuild <name>` — rebuild an image from its recorded checkout or pull request.
|
||||
- `pnpm dev:cli:install` — optionally install the developer CLI as a `homarr` binary.
|
||||
- `pnpm dev:docs` — Docusaurus only on port 3003.
|
||||
- `pnpm docker:workshop` — local PocketBase through Docker Compose.
|
||||
- `pnpm docker:dev:up` — Redis development service in the background.
|
||||
- `pnpm cli` — Homarr operations CLI.
|
||||
- `pnpm db:migration:sqlite:run` — create or update a SQLite database.
|
||||
@@ -79,6 +122,7 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
|
||||
- `pnpm typecheck` — typecheck all workspaces.
|
||||
- `pnpm test` — unit and contract suites.
|
||||
- `pnpm test:workshop` — disposable Workshop integration suite.
|
||||
- `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.
|
||||
@@ -37,9 +37,6 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` |
|
||||
|
||||
Workshop service operators can find its server-only configuration in the
|
||||
[Workshop operator guide](../../workshop/operator#environment-variables).
|
||||
|
||||
## Authentication
|
||||
|
||||
See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
|
||||
@@ -112,9 +109,12 @@ External redis is currently not supported with Proxmox Community Scripts
|
||||
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | --------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. |
|
||||
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts.
|
||||
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
|
||||
`WORKSHOP_API_URL` for one release. `WORKSHOP_API_URL` takes precedence.
|
||||
@@ -15,6 +15,20 @@ Homarr has the following keyboard shortcuts:
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
|
||||
## Board editing
|
||||
|
||||
When a tile is focused in edit mode:
|
||||
|
||||
| Shortcut | Description |
|
||||
| -------------------- | --------------------------------------------------------------------------- |
|
||||
| `Enter` or `Space` | Starts or stops keyboard editing |
|
||||
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
|
||||
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
|
||||
| `Esc` | Stops keyboard editing without undoing its changes |
|
||||
|
||||
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
|
||||
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
|
||||
|
||||
:::tip
|
||||
|
||||
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Community",
|
||||
"position": 6,
|
||||
"position": 5,
|
||||
"collapsed": true,
|
||||
"link": {
|
||||
"type": "generated-index"
|
||||
|
||||
@@ -233,7 +233,8 @@ The tile snaps to the grid. Equally-sized tiles swap when one is dropped into th
|
||||
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
|
||||
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
|
||||
Homarr scales the complete board uniformly, like zooming a page.
|
||||
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
|
||||
The complete canvas always fits the available width without horizontal board scrolling.
|
||||
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget independently.
|
||||
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
description: Configure and use Homarr Assistant.
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
- Administration
|
||||
---
|
||||
|
||||
# Assistant
|
||||
|
||||
Homarr Assistant can answer questions about your Homarr instance and use Homarr tools on your behalf. It follows the
|
||||
permissions of the signed-in user and asks for approval before it changes anything.
|
||||
|
||||

|
||||
|
||||
## Open the assistant
|
||||
|
||||
- Select **Assistant** in the user menu.
|
||||
- Press `Shift + A` from anywhere in Homarr.
|
||||
- Type a question in `Cmd/Ctrl + K`, then select **Ask Homarr Assistant**.
|
||||
- Add the **Assistant** widget to a board for a permanent conversation view.
|
||||
|
||||
You can minimize a conversation while it works. The activity bar shows its progress and the user menu shows unread
|
||||
responses.
|
||||
|
||||
## What it can do
|
||||
|
||||
- Find and update boards, apps, integrations, widgets, and dashboard settings.
|
||||
- Check downloads, media, Docker containers, service health, and other connected integrations.
|
||||
- Create formatted notes and Custom Widgets.
|
||||
- Search the web when OpenRouter web search is enabled.
|
||||
- Show tool activity, reasoning summaries, usage, time, and cost when the provider reports them.
|
||||
|
||||
Use `@` to add an app, board, integration, widget, or tool as context. Use `/` for common prompts. You can also paste or
|
||||
attach images and text files when the selected model supports them.
|
||||
|
||||
## Configure the assistant
|
||||
|
||||
Open **Management → Assistant**.
|
||||
|
||||

|
||||
|
||||
1. Select a provider.
|
||||
2. Check the API base URL and add an API key if required.
|
||||
3. Select **Save provider connection**.
|
||||
4. Refresh the model list and choose a model.
|
||||
5. Enable the assistant, then select **Save assistant**.
|
||||
|
||||
Homarr includes presets for OpenRouter, OpenAI, Claude, Gemini, xAI, Groq, Mistral, DeepSeek, Together AI, Ollama, and
|
||||
LM Studio. Use **Custom endpoint** for another OpenAI-compatible Chat Completions service.
|
||||
|
||||
The [Homarr provider](../workshop/homarr-provider) does not require an administrator API key. Each user signs in to the
|
||||
Community Workshop and receives a separate daily allowance. A shared Workshop daily quota also applies, so requests
|
||||
can be rejected after that shared quota is exhausted.
|
||||
|
||||
:::info Local model servers
|
||||
|
||||
Inside a container, `localhost` means the Homarr container. Use the model server's container name, local DNS name, or
|
||||
host gateway address instead.
|
||||
|
||||
:::
|
||||
|
||||
## Approvals and data
|
||||
|
||||
Read-only tools can run automatically. Changes show an approval card with the exact action before Homarr runs it.
|
||||
Normal Homarr permissions still apply.
|
||||
|
||||
API keys and custom headers are encrypted in the Homarr database and are never returned to the browser. Prompts,
|
||||
attachments, tool definitions, and tool results are sent to the configured model provider. Check that provider's data
|
||||
policy before enabling the assistant.
|
||||
|
||||
Conversations are stored per user. Users can edit or retry messages, copy responses, export a conversation as Markdown,
|
||||
and delete conversations.
|
||||
|
||||
## If setup does not work
|
||||
|
||||
- **No models found:** check the base URL, model discovery path, and API key. You can enter a model ID manually.
|
||||
- **The assistant is unavailable:** enable it and save the configuration.
|
||||
- **A model stops during a tool call:** use a model that supports streaming and structured tool calls.
|
||||
- **A local endpoint cannot be reached:** check container networking and the endpoint's listen address.
|
||||
|
||||
Homarr exposes its MCP-enabled procedures to external clients through the [MCP server](./mcp). MCP omits
|
||||
`customWidget_*` tools for non-admin API keys, while the built-in assistant adds browser actions and optional
|
||||
OpenRouter web search. Custom Widget authors can find the full workflow in
|
||||
[Agent authoring](./custom-widgets/agent-authoring).
|
||||
@@ -22,8 +22,6 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
## Exporting a Backup
|
||||
|
||||

|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
@@ -35,8 +33,6 @@ The backup contains the complete database. It includes Custom Widgets and all ot
|
||||
|
||||
## Restoring a Backup
|
||||
|
||||

|
||||
|
||||
### 1. Upload
|
||||
|
||||
Drag and drop or select a `.zip` backup file. The import card validates that the file contains a valid `db.sqlite` and metadata.
|
||||
|
||||
@@ -107,7 +107,7 @@ The complete canvas always fits the available width and never adds horizontal bo
|
||||
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
||||
|
||||
Mobile and Base can be renamed and their column counts and rails can be changed, but they cannot be removed. You can add removable custom layouts between their breakpoints.
|
||||
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between their breakpoints.
|
||||
|
||||
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Base or any custom layout. New custom layouts are generated from Base and scaled to their selected column count.
|
||||
|
||||
@@ -128,7 +128,7 @@ The total number of columns in the layout. Changing it projects the saved positi
|
||||
|
||||
#### Fixed rails
|
||||
|
||||
Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
|
||||
Base and custom responsive layouts can enable a fixed left rail, a fixed right rail, or both. Mobile has no fixed rails.
|
||||
Set a rail width to zero to disable it for that layout.
|
||||
Choose a width from one to three columns with the three-step slider.
|
||||
The preview shows how much space remains for the main canvas.
|
||||
@@ -247,10 +247,6 @@ Change the radius of your items and Containers.
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's Custom
|
||||
CSS and other visual or behavior settings. The assistant reads the current stylesheet first and presents an
|
||||
editable review form before requesting approval to save the change.
|
||||
|
||||
[See this guide on how to work with custom CSS](/docs/advanced/styling/).
|
||||
|
||||
### Behaviour
|
||||
|
||||
@@ -17,9 +17,7 @@ Install the Agent Skill from the verified Homarr repository:
|
||||
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
|
||||
```
|
||||
|
||||
The portable skill gives concise authoring guidance. For connected agents, Homarr returns the complete `SKILL.md` and
|
||||
all bundled skill references once in one lazy tool response. The Homarr MCP prompt then gives the agent a
|
||||
retrieval workflow: read the live schema and fetch only the component documentation needed for the widget.
|
||||
The portable skill gives concise authoring guidance. The Homarr MCP prompt gives connected agents a retrieval workflow: read the live schema, then fetch only the component documentation needed for the widget.
|
||||
|
||||
The workbench's **Copy AI prompt** action is separate. It creates a self-contained prompt below 12,000 characters for an ordinary ChatGPT or Claude conversation. It includes the lean contract, bindings, recommended components, and two polished examples. It never tells an offline model to call MCP tools.
|
||||
|
||||
@@ -39,55 +37,38 @@ Repository code cannot give this attribution.
|
||||
|
||||
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
|
||||
|
||||
`GET /api/custom-widgets/skill` and `customWidget_getSkill` return a self-contained structured skill payload:
|
||||
`GET /api/custom-widgets/skill` and the MCP tool return the concise skill discovery payload:
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "homarr-custom-widget";
|
||||
version: string;
|
||||
skillMd: string; // Complete SKILL.md
|
||||
references: Record<string, string>;
|
||||
skillMd: string;
|
||||
skillsShUrl: string;
|
||||
sourceUrl: string;
|
||||
installCommand: string;
|
||||
}
|
||||
```
|
||||
|
||||
The `homarr://custom-widgets/skill` MCP resource returns the same `SKILL.md` and references once as bundled Markdown.
|
||||
|
||||
The component catalog is a compact index of names, categories, safety levels, shared prop names, policies, and example
|
||||
IDs. Per-component results contain only component-specific props and a reference to shared prop documentation. Use
|
||||
`customWidget_getSharedProps` once with only the shared prop names needed across the selected components. This avoids
|
||||
repeating hundreds of global props in every tool result. The catalog is deliberately not embedded in the skill or
|
||||
clipboard prompt; the skill response still contains the complete authoring, schema, runtime, and security references.
|
||||
The full installed component catalog stays available through `homarr://custom-widgets/components` and per-component resources. It is deliberately not embedded in the skill or clipboard prompt.
|
||||
|
||||
These interfaces do not return an archive URL or archive hash.
|
||||
|
||||
## Create a Custom Widget with MCP
|
||||
|
||||
1. Load `customWidget_getSkill`, then get the prompt and live schema.
|
||||
2. Call `customWidget_getComponentCatalog`, followed by `customWidget_getComponent` for only the relevant installed
|
||||
components and `customWidget_getExample` when one catalog example matches the interaction pattern. If shared props
|
||||
are needed, collect their catalog names and call `customWidget_getSharedProps` once for that set.
|
||||
3. Read the external API documentation.
|
||||
4. Build one definition, using `templateLines` for multiline JSX tool input.
|
||||
5. Validate the definition without saving it and repair every reported issue.
|
||||
6. Create a preview session.
|
||||
7. Run `customWidget_previewQuery` for every query returned by the preview and inspect its actual status and data shape.
|
||||
8. Test relevant actions in simulation mode.
|
||||
9. Open the returned preview path with browser tools.
|
||||
10. Inspect the redacted journal and revise the local candidate until validation, data, and rendering all work.
|
||||
11. Create the widget, including any source URLs and credentials the user already supplied.
|
||||
12. Open its returned management path, and use a structured question when the user must choose whether to place it on a board.
|
||||
13. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL.
|
||||
14. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again.
|
||||
15. Update the saved definition if the final verification finds a problem. `customWidget_update` also accepts
|
||||
`templateLines` for multiline JSX without resetting omitted options.
|
||||
16. Repeat the procedure for the next widget.
|
||||
|
||||
Successful validation returns a compact summary instead of echoing the full widget and template. Preview creation
|
||||
returns its session, links, and query checklist without repeating the submitted definition. Keep the candidate locally
|
||||
and reuse it for the next lifecycle step.
|
||||
1. Get the prompt, live schema, and component catalog.
|
||||
2. Read the external API documentation.
|
||||
3. Build one definition.
|
||||
4. Validate the definition without saving it.
|
||||
5. Create a preview session.
|
||||
6. Test the queries and actions.
|
||||
7. Open the preview URL with browser tools.
|
||||
8. Inspect the redacted journal and revise the local candidate until it is valid.
|
||||
9. Create the widget, including any source URLs and credentials the user already supplied.
|
||||
10. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL.
|
||||
11. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again.
|
||||
12. Update the saved definition if the final verification finds a problem.
|
||||
13. Repeat the procedure for the next widget.
|
||||
|
||||
Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and
|
||||
can only be opened and completed by an authenticated Homarr administrator. The administrator confirms the server URL,
|
||||
@@ -113,51 +94,11 @@ Homarr maintainers can run the same self-contained prompt that the workbench cop
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai
|
||||
```
|
||||
|
||||
The manifest suite covers nine complex and public-API prompts. Its deterministic fixture-backed cases include a fake
|
||||
homelab health API, CoinMarketCap's keyless public API, Bored, Agify, and a multi-request Pokédex. Before the configured
|
||||
model judges a result, the candidate must pass Homarr's real clipboard parser, canonical schema, and deterministic
|
||||
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
|
||||
runtime components.
|
||||
The suite uses DeepSeek V4 Pro to author Pokédex, Portainer, football, Jellyfin, and Home Assistant widgets. Before DeepSeek V4 Flash judges a result, it must pass Homarr's real clipboard parser, canonical schema, and deterministic scenario checks for the documented API routes, authentication, bindings, actions, and essential runtime components. It saves every response, validated widget manifest, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
|
||||
|
||||
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
```
|
||||
|
||||
The same suites can target the Homarr provider with a Workshop PocketBase token instead of an OpenRouter key:
|
||||
|
||||
```sh
|
||||
AI_PROVIDER_BASE_URL=https://homarr.dev/api/ai/v1 \
|
||||
AI_PROVIDER_API_KEY=<workshop-token> \
|
||||
AI_PROVIDER_MODEL=homarr/model \
|
||||
pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
```
|
||||
|
||||
`AI_PROVIDER_JUDGE_MODEL` can select a separate judge when the configured endpoint offers one. The Homarr provider
|
||||
offers exactly one model, so generation and review both use `homarr/model`.
|
||||
|
||||
The assistant test exposes the same lazy skill, live schema, component catalog, targeted component references,
|
||||
validation, preview-query, and create-from-preview tools as the product. It refuses to save a definition that was not
|
||||
validated exactly or whose preview queries were not all tested. Multi-request fixtures return a different payload for
|
||||
each documented path and require manual-query parameters. Use `--case=pokedex`, `--case=coinmarketcap-keyless`,
|
||||
`--case=bored-activity`, or `--case=agify-name` for a focused run.
|
||||
|
||||
Both suites use the configured provider models for generation and harsh review. They save tool traces, validated
|
||||
manifests, raw reviews, and detailed category scores under
|
||||
`packages/custom-widgets/.ai-evaluations/`. These live tests are maintainer-invoked rather than CI-required because
|
||||
they consume a configured provider account and exercise a nondeterministic model. The repository's ordinary tests
|
||||
cover the lifecycle enforcement, fixture routing, score calculation, and failure behavior without network access.
|
||||
|
||||
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the endpoint,
|
||||
models, or per-scenario limit with `AI_PROVIDER_BASE_URL`, `AI_PROVIDER_API_KEY`, `AI_PROVIDER_MODEL`,
|
||||
`AI_PROVIDER_JUDGE_MODEL`, and
|
||||
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the models or
|
||||
per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and
|
||||
`CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result
|
||||
into the bundled-widget source deliberately.
|
||||
|
||||
The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state.
|
||||
|
||||
Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --public-ai-apis-only` to verify the live Bored and
|
||||
CoinMarketCap response shapes used by the assistant fixtures. The same command also verifies Agify when
|
||||
`AGIFY_API_KEY` is configured; otherwise it reports the authenticated check as skipped without weakening the
|
||||
deterministic Agify fixture tests.
|
||||
@@ -40,12 +40,6 @@ interface HomarrCustomWidgetV2 {
|
||||
}
|
||||
```
|
||||
|
||||
### Design with AI
|
||||
|
||||
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and, when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The assistant receives the widget schema, current non-secret configuration, and latest test response, then proposes the matching create or update tool for your approval.
|
||||
|
||||
Run **Test** first when possible so the prompt includes the endpoint's real response shape. Saved secrets are never added to the prompt. **Copy to clipboard** remains available when the Assistant is disabled or when you prefer another AI service.
|
||||
|
||||
<CustomWidgetCodeExample
|
||||
id="complete-widget-manifest"
|
||||
label="Complete manifest"
|
||||
|
||||
@@ -8,19 +8,10 @@ tags:
|
||||
|
||||
# Model Context Protocol (MCP)
|
||||
|
||||
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
|
||||
|
||||
The endpoint implements the stateless MCP 2026-07-28 protocol and accepts 2025 Streamable HTTP clients through a
|
||||
stateless compatibility path. It does not create protocol sessions or require sticky sessions. Homarr creates the
|
||||
server and extracts its tools only after an authenticated request reaches the route. The previous `/api/mcp/mcp` URL
|
||||
remains available as a compatibility alias, but new clients should always use `/api/mcp`.
|
||||
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
|
||||
|
||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||
|
||||
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
|
||||
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
||||
approval before running mutating procedures.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab.
|
||||
@@ -51,7 +42,7 @@ Add the following to your client's MCP configuration file:
|
||||
{
|
||||
"mcpServers": {
|
||||
"homarr": {
|
||||
"url": "https://your-homarr-instance.com/api/mcp",
|
||||
"url": "https://your-homarr-instance.com/api/mcp/mcp",
|
||||
"headers": {
|
||||
"ApiKey": "<your-api-key>"
|
||||
}
|
||||
@@ -82,7 +73,7 @@ Replace `<your-api-key>` with your actual API key. Never commit API keys to vers
|
||||
"mcpServers": {
|
||||
"homarr": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp", "--header", "ApiKey:<your-api-key>"]
|
||||
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:<your-api-key>"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,10 +96,6 @@ Custom Widget and Workshop authoring are different: their tools, prompts, and re
|
||||
key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an
|
||||
API key never elevates the owner.
|
||||
|
||||
OAuth authorization and token requests bind issued credentials to the canonical `/api/mcp` resource. Dynamic client
|
||||
registration remains available for compatibility with current MCP clients while the ecosystem transitions toward
|
||||
pre-registered clients and Client ID Metadata Documents.
|
||||
|
||||
## Testing the Connection
|
||||
|
||||
Verify your MCP endpoint is working:
|
||||
@@ -116,17 +103,13 @@ Verify your MCP endpoint is working:
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "ApiKey: <your-api-key>" \
|
||||
-H "Mcp-Method: server/discover" \
|
||||
-H "Mcp-Name: homarr" \
|
||||
-d '{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' \
|
||||
https://your-homarr-instance.com/api/mcp
|
||||
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' \
|
||||
https://your-homarr-instance.com/api/mcp/mcp
|
||||
```
|
||||
|
||||
A successful response includes the supported protocol versions and server capabilities. Use `tools/list` to see all
|
||||
available tools. MCP v2 clients add the per-request metadata and `Mcp-Method` / `Mcp-Name` headers automatically; the
|
||||
abbreviated request below uses the stateless 2025 compatibility path for readability:
|
||||
A successful response includes the server capabilities and available tools. Use `tools/list` to see all available tools:
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
@@ -134,36 +117,33 @@ curl -s -X POST \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "ApiKey: <your-api-key>" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
|
||||
https://your-homarr-instance.com/api/mcp
|
||||
https://your-homarr-instance.com/api/mcp/mcp
|
||||
```
|
||||
|
||||
Tool catalogs are returned in deterministic order and may be cached privately by the client for five minutes. The
|
||||
removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`; use the single Streamable HTTP endpoint instead.
|
||||
|
||||
## Available Tools
|
||||
|
||||
Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action).
|
||||
|
||||
| Namespace | Example Tools | Type |
|
||||
| ------------------ | ---------------------------------------------------------- | ---------- |
|
||||
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
|
||||
| `apiKeys` | List API keys | GET |
|
||||
| `beszel` | Systems status, alerts, historical system metrics | GET |
|
||||
| `board` | List boards, read settings/CSS, create/update/delete board | GET + POST |
|
||||
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
|
||||
| `icon` | Search icons | GET |
|
||||
| `info` | Server health, version info | GET |
|
||||
| `integration` | List integrations, test connection, delete integration | GET + POST |
|
||||
| `invite` | List invitations, delete invite | GET + POST |
|
||||
| `calendar` | Calendar events from media servers | GET |
|
||||
| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST |
|
||||
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
|
||||
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
|
||||
| `healthMonitoring` | Resource usage stats | GET |
|
||||
| `patchmon` | Host patch and security update statistics | GET |
|
||||
| `mediaRequests` | Approve/decline media requests | GET + POST |
|
||||
| `mediaServer` | Media server sessions and stats | GET |
|
||||
| `smartHome` | Execute automations, device states | GET + POST |
|
||||
| Namespace | Example Tools | Type |
|
||||
| ------------------ | --------------------------------------------------------- | ---------- |
|
||||
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
|
||||
| `apiKeys` | List API keys | GET |
|
||||
| `beszel` | Systems status, alerts, historical system metrics | GET |
|
||||
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
|
||||
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
|
||||
| `icon` | Search icons | GET |
|
||||
| `info` | Server health, version info | GET |
|
||||
| `integration` | List integrations, test connection, delete integration | GET + POST |
|
||||
| `invite` | List invitations, delete invite | GET + POST |
|
||||
| `calendar` | Calendar events from media servers | GET |
|
||||
| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST |
|
||||
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
|
||||
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
|
||||
| `healthMonitoring` | Resource usage stats | GET |
|
||||
| `patchmon` | Host patch and security update statistics | GET |
|
||||
| `mediaRequests` | Approve/decline media requests | GET + POST |
|
||||
| `mediaServer` | Media server sessions and stats | GET |
|
||||
| `smartHome` | Execute automations, device states | GET + POST |
|
||||
|
||||
The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance.
|
||||
|
||||
@@ -195,36 +175,21 @@ An agent creates one `homarr-custom-widget-v2` definition at a time. Start with
|
||||
The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview,
|
||||
secrets, publication, Workshop installation, and the HTTP authoring resources.
|
||||
|
||||
Load the installed skill before authoring. `customWidget_getSkill` returns the complete `SKILL.md` entrypoint and every
|
||||
bundled reference in one response, so an MCP client does not need filesystem access or a second documentation fetch.
|
||||
Then read the live schema and only the component resources required by the design. These resources describe the
|
||||
installed Mantine components.
|
||||
Then, read the live schema and component resources. These resources describe the installed Mantine components.
|
||||
|
||||
1. Call `customWidget_getSkill`, `customWidget_getAuthoringPrompt`, `customWidget_schema`, and
|
||||
`customWidget_getComponentCatalog`.
|
||||
2. Fetch only the relevant installed component documentation with `customWidget_getComponent` and, when useful,
|
||||
one interaction example with `customWidget_getExample`. Component results contain component-specific props; collect
|
||||
any needed shared prop names from the compact catalog and fetch them together once with `customWidget_getSharedProps`.
|
||||
3. Build one Custom Widget definition. Use `templateLines` for generated multiline JSX to avoid a fragile JSON-escaped
|
||||
template string.
|
||||
4. Call `customWidget_validate` without saving the definition and repair every returned issue path.
|
||||
5. Call `customWidget_previewCreate`.
|
||||
6. Call `customWidget_previewQuery` for every entry in the returned `queries` list. Supply the listed invocation
|
||||
parameters for manual queries and inspect the real HTTP status and response shape before binding template fields.
|
||||
7. Test relevant actions with `customWidget_previewAction` in simulation mode.
|
||||
8. Open the returned `previewPath` or `previewUrl`.
|
||||
9. Examine `customWidget_previewJournal`.
|
||||
10. Revise the local candidate and create another preview session until validation, query data, actions, and rendering
|
||||
are ready.
|
||||
11. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
|
||||
12. Open the returned `managementPath` to review the exact saved definition.
|
||||
13. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
|
||||
14. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
|
||||
15. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
|
||||
16. Repeat this procedure for the next Custom Widget.
|
||||
|
||||
Validation and preview creation return compact lifecycle metadata. They do not echo the submitted widget or duplicate
|
||||
its template as both a string and line array; the agent should retain and reuse its local candidate.
|
||||
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
|
||||
2. Build one Custom Widget definition.
|
||||
3. Call `customWidget_validate` without saving the definition.
|
||||
4. Call `customWidget_previewCreate`.
|
||||
5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
|
||||
6. Open the returned preview URL.
|
||||
7. Examine `customWidget_previewJournal`.
|
||||
8. Revise the local candidate and create another preview session until it is ready.
|
||||
9. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
|
||||
10. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
|
||||
11. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
|
||||
12. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
|
||||
13. Repeat this procedure for the next Custom Widget.
|
||||
|
||||
The agent receives the configuration-request status. The agent does not receive plaintext credentials. A preview-scoped setup URL configures only that temporary preview; use a definition-scoped URL when the values must persist.
|
||||
|
||||
@@ -260,10 +225,6 @@ The Custom Widget tools are:
|
||||
- `customWidget_workshopGet`
|
||||
- `customWidget_workshopInstall`
|
||||
- `customWidget_getAuthoringPrompt`
|
||||
- `customWidget_getComponentCatalog`
|
||||
- `customWidget_getComponent`
|
||||
- `customWidget_getSharedProps`
|
||||
- `customWidget_getExample`
|
||||
- `customWidget_getSkill`
|
||||
|
||||
Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
tags:
|
||||
- Workshop
|
||||
- Custom Widgets
|
||||
- Custom CSS
|
||||
---
|
||||
|
||||
# Community Workshop
|
||||
|
||||
:::warning Experimental feature
|
||||
|
||||
This feature is experimental and will probably change. If you encounter bugs, need more capabilities, or find something confusing, tell us in the [official discussion thread](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets). Your feedback directly shapes what comes next. Happy hacking!
|
||||
|
||||
:::
|
||||
|
||||
The [Community Workshop](pathname:///workshop) is where Homarr users share and discover
|
||||
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
|
||||
you like, report abuse, and install entries directly into your own Homarr instance.
|
||||
|
||||
The central `homarr.dev` Workshop is a public community. Browsing it on the website is public. Browsing, installing,
|
||||
publishing, or importing Workshop content **inside a Homarr instance is restricted to Homarr administrators**.
|
||||
|
||||
:::warning
|
||||
|
||||
The Workshop contains community-made content. Moderation is not a security boundary, and PocketBase does not interpret
|
||||
or validate submission content. Always review widget capabilities, network origins, actions, and CSS before installing.
|
||||
Homarr's installation-time validator is definitive and can reject content that the public service accepted earlier.
|
||||
Back up your instance before installing untrusted content.
|
||||
|
||||
:::
|
||||
|
||||
## Browsing and voting
|
||||
|
||||
- Filter by **Widgets** or **CSS**, show or hide outdated entries, and sort by popularity, creation date, recent updates, or discussion activity.
|
||||
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
|
||||
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
|
||||
- Open report counts are public on submission cards so visitors can make an informed installation decision. The submission author and Workshop moderators can read each report category and explanation; reporter identities remain moderator-only.
|
||||
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
|
||||
- The manual **Outdated** badge warns visitors that a submission may need an update.
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop moderators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
|
||||
|
||||
## Submitting
|
||||
|
||||
1. Sign in with GitHub.
|
||||
2. Click **Share yours** and choose the type:
|
||||
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
|
||||
It must match the `homarr-custom-widget-v2` schema (validated before submission).
|
||||
- **Custom CSS** — paste your stylesheet (max 16384 characters).
|
||||
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
|
||||
4. Submit the entry.
|
||||
|
||||
Custom widget JSON is normalized to readable multi-line formatting when it is published, and the Workshop detail page
|
||||
also formats older compact submissions before displaying them.
|
||||
|
||||
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
|
||||
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
|
||||
|
||||
Submissions are published immediately. Reported entries are reviewed and may be removed.
|
||||
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
|
||||
|
||||
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop moderator receives an email when a report is submitted. If a moderator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
|
||||
|
||||
:::info Separate roles
|
||||
|
||||
A Workshop moderator is a user with PocketBase's internal `isAdmin` field. This role moderates the central community
|
||||
and is unrelated to a Homarr instance administrator.
|
||||
|
||||
:::
|
||||
|
||||
## Submission guidelines
|
||||
|
||||
Submissions must be useful to Homarr users and safe to install. Do not submit content that is
|
||||
malicious, deceptive, illegal, hateful, sexually explicit, gore, gambling-focused, spam, or content
|
||||
that primarily promotes unrelated products or services.
|
||||
|
||||
## Installing into Homarr
|
||||
|
||||
You can install workshop entries without leaving Homarr:
|
||||
|
||||
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
|
||||
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
|
||||
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Import from Workshop**, choose a theme, review its full CSS in the second same-size modal step, and click **Import CSS** once. The board setting is still saved separately.
|
||||
|
||||
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
|
||||
|
||||
Custom Widget imports detect required bearer, basic, header, or query credentials. You can enter them during import or explicitly defer them; secret values are never stored in Workshop content. Missing credentials remain visible on the local Custom Widget afterward.
|
||||
|
||||
## Workshop deployment
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
|
||||
|
||||
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
|
||||
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
|
||||
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
|
||||
irreversible development reset.
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
|
||||
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
|
||||
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
|
||||
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and
|
||||
users cannot rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings
|
||||
empty. Profile and avatar URLs are derived from `https://github.com/<username>` and
|
||||
`https://github.com/<username>.png`, so PocketBase does not store duplicate profile metadata or avatar files. Configure
|
||||
SMTP for comment, report, and removal notices.
|
||||
|
||||
The public URL contract is:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| ------------------------ | ------------------------------------------------- | ----------------------------- |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
|
||||
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The
|
||||
combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts;
|
||||
the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it.
|
||||
|
||||
### Staging and demo deployment
|
||||
|
||||
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
|
||||
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_API_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
Start or update it from the repository root:
|
||||
|
||||
```sh
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml pull
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml up --detach
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
|
||||
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
|
||||
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
|
||||
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
|
||||
`/workshop` only on `WORKSHOP_WEB_URL`.
|
||||
|
||||
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
|
||||
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
|
||||
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
|
||||
GitHub OAuth requires both credentials and the callback
|
||||
`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health
|
||||
endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview.
|
||||
|
||||
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
|
||||
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
|
||||
|
||||
## Client architecture
|
||||
|
||||
Workshop access is centralized in `@homarr/workshop/backend`. It exports the typed `WorkshopBackend`, its typed PocketBase instance, stable TanStack Query option factories, and the `useWorkshop…` query and mutation hooks used by Homarr. The documentation site uses the same backend rather than maintaining a second API contract.
|
||||
|
||||
Listing fields derived by the `workshop_listings` view—such as submission type, score, and counts—are normalized by the shared client. Healthy deployments keep filtering, sorting, and pagination in PocketBase. If a persistent development volume still has an older view that rejects those fields, the client retries without them and applies the same operations locally; it can also fall back to the base submissions collection. Production deployments should nevertheless apply the current migration so the view exposes the complete metadata.
|
||||
@@ -17,6 +17,8 @@ import { anchorIntegration } from "@site/docs/integrations/anchor";
|
||||
|
||||
The Anchor Note widget shows a selected note from your Anchor integration. If your note permissions allow it, you can also edit the note title and content directly in Homarr.
|
||||
|
||||
Advanced view always shows the note title and update time alongside the complete formatted document, permission, creation date, and pinned or archived state. Compact title and update-time switches do not hide that information in advanced view.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import anchorNoteWidgetScreenshot from "./img/widget.png";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { archiveTeamWarriorWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={archiveTeamWarriorWidget} categories={["Archiving", "Monitoring"]} />
|
||||
|
||||
Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list and exact update time.
|
||||
Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and optional broadcast message visible. Advanced view adds the complete active-job list, job start times, project and bandwidth-session identifiers, session transfer totals, exact update time, and the full broadcast message whenever one is available. A failed refresh keeps the last snapshot visible with a stale-data warning.
|
||||
|
||||
### Supported integration
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
title: "Assistant"
|
||||
description: "Have a complete assistant conversation directly on a board."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { assistantWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
|
||||
|
||||
The Assistant widget brings the same conversation available from the user menu and command menu onto a
|
||||
board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls,
|
||||
usage details, and response actions remain synchronized with the app-wide assistant.
|
||||
|
||||
The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users
|
||||
on an instance without an enabled model see an unavailable state instead of a broken composer.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls
|
||||
and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on
|
||||
touch devices. When a board layout does not provide enough height for conversation controls, the widget shows
|
||||
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
|
||||
in that layout to keep the complete conversation inline.
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={assistantWidget.configuration} />
|
||||
|
||||
**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or
|
||||
the command menu. This is the default and keeps every assistant entry point synchronized.
|
||||
|
||||
**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the
|
||||
active conversation silently: select **Open conversation** inside the widget before continuing that thread.
|
||||
|
||||
### Conversation features
|
||||
|
||||
- Create, switch, rename, and delete conversations.
|
||||
- Send text and supported file or image attachments by picker, drag and drop, or image paste.
|
||||
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
|
||||
- Use `/` shortcuts for common dashboard tasks.
|
||||
- Review tool calls, approve mutations, and answer structured assistant questions inline.
|
||||
- Answer pending structured questions from the dock above the composer without scrolling through the conversation.
|
||||
- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks.
|
||||
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
|
||||
- Inspect syntax-highlighted Markdown, sources, generated files, tool results, streaming chain-of-thought,
|
||||
cumulative conversation usage, per-turn token and context use, timing, speed, provider routing, and cost.
|
||||
- Change the model and reasoning effort for the active conversation.
|
||||
- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically.
|
||||
|
||||
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
|
||||
background when the widget leaves the viewport or the expanded panel is minimized. Each visible surface keeps an
|
||||
independent draft, attachments, selection, and focus, so typing in a board widget cannot move or overwrite the
|
||||
floating panel composer.
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
export const assistantWidget: WidgetDefinition = {
|
||||
icon: IconRobot,
|
||||
name: "Assistant",
|
||||
description: "Have a complete assistant conversation directly on a board.",
|
||||
path: "../../widgets/assistant",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Conversation",
|
||||
description: "Follow the active conversation or attach the widget to one persisted conversation.",
|
||||
values: { type: "select", options: ["Current conversation", "Pinned conversation"] },
|
||||
defaultValue: "Current conversation",
|
||||
},
|
||||
{
|
||||
name: "Pinned conversation",
|
||||
description: "Search for a conversation. This field is hidden when Current conversation is selected.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -36,7 +36,9 @@ import widgetOverview from "./img/widget-overview.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
Advanced view shows every missing-subtitle and health summary regardless of compact visibility options, together with the already-fetched Sonarr and Radarr SignalR states and announcement count.
|
||||
|
||||
<WidgetInteractionGuide advancedView />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ This widget displays your Beszel alert configurations separated by status (trigg
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
@@ -34,6 +36,8 @@ Select a system card to open the complete statistics view, including system, fil
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
@@ -4,6 +4,8 @@ description: "Table view of all Beszel-monitored systems with sortable columns a
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
@@ -35,6 +37,8 @@ Select a system row to open the complete statistics view.
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
@@ -16,6 +16,7 @@ import { bookmarksWidget } from ".";
|
||||
The bookmarks widget allows you to display multiple app links in a single widget.
|
||||
You can add links to your favorite applications, websites, or any other resources you want quick access to.
|
||||
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
|
||||
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ import { icalIntegration } from "@site/docs/integrations/ical";
|
||||
|
||||
The calendar widget shows a month view of events from different integrations.
|
||||
It allows you to view calendar events or upcoming releases from your media managers.
|
||||
Calendar requests use normal human month numbers (`1` for January through `12` for December), including when
|
||||
the same data is requested through Homarr's MCP or Assistant tools.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -74,7 +72,7 @@ import withoutIntegrations from "./img/without-integrations.png";
|
||||
### Interactions
|
||||
|
||||
- Hover over a marked date to preview its events in a popover.
|
||||
- The advanced view replaces the month grid with a grouped event list. Use its previous and next controls to change months.
|
||||
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
|
||||
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
@@ -27,7 +27,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
|
||||
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -27,7 +27,11 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
Advanced view opens the Servers, Applications, and Services sections regardless of their compact visibility switches. It identifies each Coolify integration, shows server `is_reachable` and `is_usable` values, and lists applications nested in services when the API provides them. Healthy integrations remain visible if another source fails, with the failed source identified by a warning.
|
||||
|
||||
IP addresses remain masked in advanced view until you explicitly use the eye control. Public resource, Coolify dashboard, and log links retain the widget's safe new-tab handling.
|
||||
|
||||
<WidgetInteractionGuide advancedView />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
|
||||
<WidgetHeader widget={dnsHoleControlsWidget} categories={["DNS Hole"]} />
|
||||
|
||||
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
|
||||
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -17,6 +17,8 @@ import { dnsHoleSummaryWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={dnsHoleSummaryWidget} categories={["DNS Hole"]} />
|
||||
|
||||
When several DNS integrations are selected, Homarr keeps healthy sources visible if one source fails and shows a warning identifying the unavailable integration.
|
||||
|
||||
This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -43,12 +43,12 @@ import contextMenuImg from "./img/context-menu.png";
|
||||
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
|
||||
- **Sortable columns** — click any column header to sort ascending/descending
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
|
||||
- **Right-click context menu** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
|
||||
- **Row expansion** — click any row to see full details inline without opening a separate modal
|
||||
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode
|
||||
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
|
||||
- **Client filtering** — filter downloads by integration or status using the footer filter panel
|
||||
- **Global controls** — pause/resume all downloads across all clients from the footer bar
|
||||
- **Global controls** — pause/resume downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
|
||||
- **Status indicators** — color-coded badges show download state at a glance
|
||||
- **Speed overview** — total download and upload speeds displayed in the footer
|
||||
|
||||
@@ -89,13 +89,13 @@ import contextMenuImg from "./img/context-menu.png";
|
||||
|
||||
### Interactions
|
||||
|
||||
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
|
||||
- **Right-click** any download to copy its name; pause/resume and delete require Interact access and client support
|
||||
- **Click** any row to expand it and see full download details inline
|
||||
- **Hover** a download name for a quick stats tooltip
|
||||
- **Stats toggle** — show or hide the global stats bar from the footer
|
||||
- **Pause / Resume** all downloads using the footer controls
|
||||
- **Pause / Resume** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
|
||||
- **Filter** by integration or status using the filter button in the footer
|
||||
- **Sort** by clicking column headers (with persistent sort direction)
|
||||
- **Sort** the current view by clicking column headers to switch direction
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
{
|
||||
name: "Hide completed torrent under this threshold (in kiB/s)",
|
||||
description:
|
||||
"This will hide completed torrent entries that have a download speed below the specified threshold.",
|
||||
"This will hide completed torrent entries that have an upload speed below the specified threshold.",
|
||||
values: "Any number above 0, 0 to disable",
|
||||
defaultValue: "0",
|
||||
},
|
||||
|
||||
@@ -23,6 +23,8 @@ import { glancesIntegration } from "@site/docs/integrations/glances";
|
||||
|
||||
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
|
||||
|
||||
Advanced view is a complete health workspace: it shows every available CPU, temperature, memory, GPU, filesystem, update, reboot, and system-information section even when compact visibility switches hide some of them on the board. Proxmox clusters likewise show uptime, CPU, memory, and every node, VM, container, and storage section. Storage-volume filters, units, and permissions remain in effect.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import systemView from "./img/system-view.png";
|
||||
|
||||
@@ -33,7 +33,7 @@ import screen from "./img/screen.png";
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view displays the complete photo without cropping, groups previous, pause or resume, and next together, and includes every loaded photo in its thumbnail strip.
|
||||
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view displays the complete photo without cropping, groups previous, pause or resume, and next together, and includes every loaded photo in its thumbnail strip. If an Immich refresh fails, the last loaded album stays visible with a stale-data warning.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { sonarrIntegration } from "@site/docs/integrations/sonarr";
|
||||
|
||||
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
|
||||
|
||||
Advanced view always shows both Missing and Queued panels side by side when space allows, requests at least 50 items per source, exposes queue status and time remaining, and keeps partial-integration warnings visible. Compact tab visibility and page-size choices continue to shape the board tile.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import widgetMissing from "./img/missing.png";
|
||||
|
||||
@@ -19,6 +19,10 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
|
||||
Provides a list of newly added media or upcoming releases from your media server.
|
||||
|
||||
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
|
||||
|
||||
Advanced view displays every release in a roomy poster layout with its complete available metadata, description, tags, and visible source name, regardless of the compact layout and visibility options.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import backdrop from "./img/backdrop.png";
|
||||
|
||||
@@ -24,7 +24,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
Sessions without user metadata remain visible and are labeled as an unknown user.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { tdarrIntegration } from "@site/docs/integrations/tdarr";
|
||||
|
||||
The media transcoding widget provides you with an overview of your media transcoding status.
|
||||
It shows you the current queue, worker status, and statistics about your transcoding jobs.
|
||||
Advanced view presents all three panels together. If refreshes fail after data has loaded, Homarr keeps the last useful result visible and marks it as stale.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import { networkControllerStatusWidget } from ".";
|
||||
|
||||
Shows connected users and connected guest devices on wired or wireless networks.
|
||||
|
||||
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import wifiCard from "./img/wifi.png";
|
||||
|
||||
@@ -36,7 +36,9 @@ import summaryCard from "./img/summary.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ import notificationsScreenshot from "./img/notifications.png";
|
||||
items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
Advanced view always shows available notification logos and sources and removes the compact body line limit. External notification links retain the same safe new-tab behavior.
|
||||
|
||||
<WidgetInteractionGuide advancedView />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ The widget adapts its layout based on the available space:
|
||||
|
||||
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
|
||||
|
||||
### Advanced view
|
||||
|
||||
Advanced view is a complete PatchMon posture view. It always shows the compliance hero, all eight host and package statistics, the full OS distribution with versions, and the last-updated time. Compact visibility switches and OS limits still shape the board tile, but do not hide already-fetched security information in advanced view. Threshold and chart-style choices remain in effect.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
@@ -83,7 +87,7 @@ When the widget has enough space, a "last updated" timestamp appears at the bott
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
|
||||
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
|
||||
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ import screenshot from "./img/screenshot.png";
|
||||
|
||||
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
Advanced view keeps the configured feeds and post limit unchanged while showing each fetched post's full description, image, source, relative time, and exact publication timestamp. Right-to-left text and safe external links continue to follow the widget configuration.
|
||||
|
||||
<WidgetInteractionGuide advancedView />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ The entity state enables you to display the current state of an entity.
|
||||
This works with most entities and enables you display almost any data.
|
||||
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
|
||||
|
||||
Advanced view shows the safe Home Assistant metadata owned by Homarr: the friendly name, entity ID, unit, device class, icon, last-changed time, and last-updated time. Arbitrary attributes remain intentionally excluded.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import sun from "./img/sun.png";
|
||||
|
||||
@@ -20,6 +20,8 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
|
||||
|
||||
The execute automation widget allows you to trigger an automation with one click.
|
||||
|
||||
Advanced view shows the automation ID, interaction permission, last successful run in the current browser session, and the latest action error together. Session-only history is not presented as Home Assistant history.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import empty from "./img/empty.png";
|
||||
|
||||
@@ -18,6 +18,10 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
||||
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
|
||||
|
||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import screenshot from "./img/screenshot.png";
|
||||
|
||||
@@ -16,6 +16,8 @@ import { stockPriceWidget } from ".";
|
||||
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
|
||||
The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them.
|
||||
|
||||
In advanced view, the widget always shows the returned symbol and company name, configured range and interval, current and previous close, absolute and percentage change, range minimum and maximum, and the full price-history chart. These values use the same configured Yahoo Finance query as the compact widget; advanced view does not change the symbol, range, or interval.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import apple from "./img/apple.png";
|
||||
@@ -34,7 +36,7 @@ import nvidia from "./img/nvidia.png";
|
||||
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ This widget displays filesystem and SMART disk usage for supported server and NA
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
Advanced view keeps your configured volume filters while showing each integration name, usage percentage, used and total space, free-space percentage, SMART status, available temperature, and the usage background bar regardless of compact display options.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
|
||||
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
|
||||
|
||||
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
### Sections
|
||||
|
||||
| Section | Description |
|
||||
|
||||
@@ -17,6 +17,10 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
|
||||
|
||||
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
|
||||
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
|
||||
|
||||
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
|
||||
@@ -19,6 +19,10 @@ This widget displays the status of the UPS devices reported by a Network UPS Too
|
||||
status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and
|
||||
output voltage.
|
||||
|
||||
Every device keeps an integration-scoped identity. Advanced view always labels each device with its owning integration, while compact layouts add the source label only when multiple healthy integrations are present.
|
||||
|
||||
When multiple UPS integrations are selected, devices from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
### Layouts
|
||||
|
||||
import screenshotFull from "./img/screenshot.png";
|
||||
|
||||
@@ -17,6 +17,10 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
|
||||
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
|
||||
|
||||
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
|
||||
|
||||
When multiple Uptime Kuma integrations are selected, monitors from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
import { wudWidget } from ".";
|
||||
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
|
||||
|
||||
@@ -26,6 +27,10 @@ This widget shows two stats from your WUD instance: the total number of monitore
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
In advanced view, the widget always shows its title, update ring, monitored-container summary, and the full list of available container updates. The configured horizontal or vertical layout still applies. Visibility options only affect the compact board view. A failed refresh keeps the last WUD snapshot visible with a stale-data warning.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"label": "Workshop",
|
||||
"collapsed": true,
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "doc",
|
||||
"id": "workshop/index"
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
title: Workshop development
|
||||
sidebar_position: 4
|
||||
tags:
|
||||
- Workshop
|
||||
- Developer
|
||||
- Docusaurus
|
||||
- PocketBase
|
||||
---
|
||||
|
||||
# Workshop development
|
||||
|
||||
Install the repository dependencies and make sure Docker is available before starting the Workshop services. See the
|
||||
[main development guide](../advanced/development/getting-started) for the repository prerequisites.
|
||||
|
||||
## Run PocketBase and documentation
|
||||
|
||||
Start PocketBase in one terminal:
|
||||
|
||||
```sh
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
Then start Docusaurus in another terminal:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
|
||||
|
||||
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
|
||||
`http://127.0.0.1:3003/workshop`.
|
||||
|
||||
Point Homarr at the local services with:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Restart `pnpm dev:docs` after changing the Docusaurus URL variables. If Homarr is also running, restart its `pnpm dev`
|
||||
process after changing the Workshop URLs. GitHub OAuth is optional locally. See
|
||||
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
|
||||
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
|
||||
|
||||
## URL testing
|
||||
|
||||
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
|
||||
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
|
||||
separate API origin before release:
|
||||
|
||||
```sh
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 \
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
|
||||
pnpm dev:docs
|
||||
```
|
||||
|
||||
The [Workshop operator guide](./operator) covers production environment variables, staging, and service validation.
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Homarr provider
|
||||
sidebar_position: 2
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
- Privacy
|
||||
- Community
|
||||
---
|
||||
|
||||
import { OpenCollectiveLink } from "@site/src/components/assistant-provider/open-collective-link";
|
||||
|
||||
# Homarr provider
|
||||
|
||||
The Homarr provider gives Workshop users access to the **Homarr model** without an administrator API key. The Homarr
|
||||
team selects a capable model based on quality, reliability, and cost, and can change the upstream model without
|
||||
changing the user-facing provider. Each user signs in with GitHub and receives a separate daily allowance.
|
||||
|
||||
## Set it up
|
||||
|
||||
1. Open **Management → Assistant**.
|
||||
2. Select **Homarr** as the provider.
|
||||
3. Save the provider connection.
|
||||
4. Select the available model, enable the assistant, and save.
|
||||
5. Open the assistant and sign in to the Workshop when asked.
|
||||
|
||||
## Daily allowance
|
||||
|
||||
- Each user starts with **50 request units per UTC day**.
|
||||
- Each model request uses one unit. Agent tool loops can make multiple model requests.
|
||||
- Once Workshop forwards a request, it uses one unit even if the upstream later fails. This prevents failed requests
|
||||
from bypassing the shared cost and abuse limits.
|
||||
- The allowance resets at 00:00 UTC.
|
||||
|
||||
The battery beside the model selector shows the remaining allowance and reset time. The Workshop server enforces the
|
||||
limit before it sends a request to OpenRouter.
|
||||
|
||||
## Data and cost
|
||||
|
||||
The Workshop keeps the OpenRouter API key on its server. It does not send that key to Homarr or the browser.
|
||||
|
||||
Prompts, attachments, tool definitions, and tool results are sent through OpenRouter to generate a response. The
|
||||
Workshop proxy does not retain that content, responses, or per-request telemetry. It stores only the Workshop user,
|
||||
UTC day, and counters required to enforce per-user and global daily allowances. Requests require OpenRouter routing
|
||||
with zero data retention and data collection disabled. Homarr does not use this content to train language models.
|
||||
|
||||
We would rather become millionaires in a more ethical manner.
|
||||
|
||||
The Homarr community pays for these requests. Donations help cover the cost.
|
||||
|
||||
<OpenCollectiveLink />
|
||||
|
||||
Workshop operators can change the upstream model, per-user allowance, global allowance, and security limits in the
|
||||
[Workshop operator guide](./operator#homarr-provider-proxy).
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
title: Community Workshop
|
||||
sidebar_label: Overview
|
||||
tags:
|
||||
- Workshop
|
||||
- Custom Widgets
|
||||
- Custom CSS
|
||||
---
|
||||
|
||||
# Community Workshop
|
||||
|
||||
The [Community Workshop](pathname:///workshop) is where Homarr users share Custom Widgets and dashboard CSS.
|
||||
|
||||
:::warning Experimental
|
||||
|
||||
Workshop content is made by the community. Review it before installation and back up your Homarr instance.
|
||||
|
||||
:::
|
||||
|
||||
## Find and install content
|
||||
|
||||
Browse the Workshop by type, date, popularity, or recent activity. Each submission includes its source, screenshots,
|
||||
author, revision, comments, and reports.
|
||||
|
||||
Administrators can install content without leaving Homarr:
|
||||
|
||||
- **Custom Widgets:** open **Management → Custom Widgets**, then select **Import from Workshop**.
|
||||
- **Custom CSS:** open a board's **Settings → Custom CSS**, then select **Import from Workshop**.
|
||||
|
||||
Homarr shows the complete source and required permissions before installation. Widget credentials are entered in your
|
||||
Homarr instance and are never included in a Workshop submission.
|
||||
|
||||
## Share your work
|
||||
|
||||
1. Sign in to the Workshop with GitHub.
|
||||
2. Select **Share yours**.
|
||||
3. Choose **Custom Widget** or **Custom CSS**.
|
||||
4. Add the source, a short description, screenshots, and a changelog.
|
||||
5. Review and publish the submission.
|
||||
|
||||
Export Custom Widget JSON from **Management → Custom Widgets → Export**. You can edit a submission later without
|
||||
changing its Workshop URL.
|
||||
|
||||
## Voting, comments, and reports
|
||||
|
||||
Signed-in users can vote, comment, and report submissions. Authors can edit their own comments and submissions.
|
||||
Workshop moderators can review reports, remove comments, and delete unsafe content. Reports do not remove a submission
|
||||
automatically.
|
||||
|
||||
A Workshop moderator manages the public community. This is separate from the administrator role on a Homarr instance.
|
||||
|
||||
Deployment and server configuration are in the [Workshop operator guide](./operator). Local setup is in
|
||||
[Workshop development](./development).
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
title: Workshop operator guide
|
||||
sidebar_label: Operator guide
|
||||
sidebar_position: 3
|
||||
tags:
|
||||
- Workshop
|
||||
- Deployment
|
||||
- Environment variables
|
||||
- PocketBase
|
||||
---
|
||||
|
||||
# Workshop operator guide
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site
|
||||
and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile,
|
||||
Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first
|
||||
uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
|
||||
|
||||
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
|
||||
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
|
||||
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
|
||||
irreversible development reset. See [Workshop development](./development) for the complete local setup.
|
||||
|
||||
## Authentication and notifications
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins
|
||||
unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
|
||||
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
|
||||
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and users cannot
|
||||
rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings empty. Profile and avatar
|
||||
URLs are derived from `https://github.com/<username>` and `https://github.com/<username>.png`, so PocketBase does not
|
||||
store duplicate profile metadata or avatar files. Configure SMTP for comment, report, and removal notices.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The public URL contract and Workshop-only server configuration are:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| -------------------------------------- | ------------------------------------------------- | ------------------------------ |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
|
||||
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` |
|
||||
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` |
|
||||
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
|
||||
| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts. The deprecated docs-only `WORKSHOP_URL` alias remains for one
|
||||
release; `WORKSHOP_API_URL` takes precedence. The combined Workshop image writes the public URLs into its browser
|
||||
runtime configuration whenever the container starts; the Docker build arguments remain fallbacks. Restart the
|
||||
container after changing these values, without rebuilding it.
|
||||
|
||||
### Homarr provider proxy
|
||||
|
||||
The [Homarr provider](./homarr-provider) proxy is part of the Workshop image. `OPENROUTER_API_KEY` enables the proxy,
|
||||
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, `HOMARR_AI_OPENROUTER_MODEL` selects the
|
||||
private upstream model behind the stable `homarr/model` alias, and the two allowance variables provide per-user and
|
||||
shared request ceilings. Lowering either allowance takes effect for existing users. The upstream URL cannot contain
|
||||
embedded credentials, a query, or a fragment and must use HTTPS. Tests can explicitly opt into an insecure local mock
|
||||
with `HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true`; do not use that setting with a real API key.
|
||||
|
||||
The proxy overwrites client routing and generation limits. It requires OpenRouter zero-data-retention routing, denies
|
||||
data-collecting providers, limits responses to 32,768 completion tokens, and does not retain prompts, responses, or
|
||||
per-request telemetry. It accepts only uploaded image data, conservatively bounds text to the advertised context, and
|
||||
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
|
||||
limit as the final monetary backstop because request quotas are not dollar accounting.
|
||||
|
||||
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
|
||||
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
|
||||
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
|
||||
|
||||
An operator can explicitly smoke-test a deployed Workshop against its real configured upstream with a dedicated,
|
||||
low-quota test user:
|
||||
|
||||
```sh
|
||||
HOMARR_AI_LIVE_SMOKE=1 \
|
||||
WORKSHOP_LIVE_TEST_URL=https://workshop.example.com \
|
||||
WORKSHOP_LIVE_TEST_TOKEN=<temporary-pocketbase-user-token> \
|
||||
pnpm test:workshop-live-provider
|
||||
```
|
||||
|
||||
This optional check consumes one provider request. The test client needs neither the OpenRouter key nor the private
|
||||
upstream model ID. Do not run it in CI or with a normal user's Workshop token.
|
||||
|
||||
The Custom Widget AI tests can use this proxy. Set `AI_PROVIDER_BASE_URL` to the `/api/ai/v1` endpoint and
|
||||
`AI_PROVIDER_API_KEY` to a temporary Workshop user token. See
|
||||
[Agent authoring](../management/custom-widgets/agent-authoring#evaluate-the-copied-prompt) for the command.
|
||||
|
||||
## Staging and demo deployment
|
||||
|
||||
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
|
||||
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_API_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
Start or update it from the repository root:
|
||||
|
||||
```sh
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml pull
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml up --detach
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
|
||||
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
|
||||
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
|
||||
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
|
||||
`/workshop` only on `WORKSHOP_WEB_URL`.
|
||||
|
||||
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
|
||||
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
|
||||
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
|
||||
GitHub OAuth requires both credentials and the callback `https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart
|
||||
after changing environment values, then verify the health endpoint, `/workshop`, a public listing request, GitHub
|
||||
sign-in, and an installation from the Homarr preview.
|
||||
|
||||
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
|
||||
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
|
||||
|
||||
## Client architecture
|
||||
|
||||
Workshop access is centralized in `@homarr/workshop/backend`. It exports the typed `WorkshopBackend`, its typed
|
||||
PocketBase instance, stable TanStack Query option factories, and the `useWorkshop…` query and mutation hooks used by
|
||||
Homarr. The documentation site uses the same backend rather than maintaining a second API contract.
|
||||
|
||||
Listing fields derived by the `workshop_listings` view—such as submission type, score, and counts—are normalized by the
|
||||
shared client. Healthy deployments keep filtering, sorting, and pagination in PocketBase. If a persistent development
|
||||
volume still has an older view that rejects those fields, the client retries without them and applies the same
|
||||
operations locally; it can also fall back to the base submissions collection. Production deployments should
|
||||
nevertheless apply the current migration so the view exposes the complete metadata.
|
||||
@@ -362,7 +362,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
box-sizing: border-box;
|
||||
color: #4b5563;
|
||||
display: flex;
|
||||
font: 500 0.875rem/1.5 "Work Sans", system-ui, sans-serif;
|
||||
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"postcss": "^8.5.16",
|
||||
"posthog-docusaurus": "^2.0.5",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ifm-background-surface-color);
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
border-color: var(--ifm-color-emphasis-500);
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
color: var(--ifm-font-color-base);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import Translate from "@docusaurus/Translate";
|
||||
|
||||
import styles from "./open-collective-link.module.css";
|
||||
|
||||
export const OpenCollectiveLink = () => (
|
||||
<a className={styles.link} href="https://opencollective.com/homarr">
|
||||
<img className={styles.logo} src="/img/opencollective.svg" alt="" aria-hidden />
|
||||
<Translate id="workshop.provider.donate">Donate to Homarr on OpenCollective</Translate>
|
||||
</a>
|
||||
);
|
||||
@@ -1,84 +1,147 @@
|
||||
import styles from "../../../../pages/index.module.css";
|
||||
import { supportedIntegrations } from "../../../../constants/supported-integrations";
|
||||
import React, { useRef } from "react";
|
||||
|
||||
const nodePositions = [
|
||||
styles.dataflowNodeTopLeft,
|
||||
styles.dataflowNodeMiddleLeft,
|
||||
styles.dataflowNodeBottomLeft,
|
||||
styles.dataflowNodeTopRight,
|
||||
styles.dataflowNodeMiddleRight,
|
||||
styles.dataflowNodeBottomRight,
|
||||
];
|
||||
const cyclingIntegrations = supportedIntegrations.filter((integration) => integration.name !== "Homarr");
|
||||
import LeaderLine, { SocketType } from "leader-line-new";
|
||||
|
||||
const paths = [
|
||||
"M145 82 C330 82 330 235 480 260",
|
||||
"M145 260 C300 260 360 260 480 260",
|
||||
"M145 438 C330 438 330 285 480 260",
|
||||
"M480 260 C630 235 630 82 815 82",
|
||||
"M480 260 C600 260 660 260 815 260",
|
||||
"M480 260 C630 285 630 438 815 438",
|
||||
];
|
||||
export const DataflowVisualizationComponent = () => {
|
||||
const homarrRef = useRef<HTMLImageElement>(null);
|
||||
const sonarrRef = useRef<HTMLImageElement>(null);
|
||||
const lidarrRef = useRef<HTMLImageElement>(null);
|
||||
const radarrRef = useRef<HTMLImageElement>(null);
|
||||
const jellyfinRef = useRef<HTMLImageElement>(null);
|
||||
const plexRef = useRef<HTMLImageElement>(null);
|
||||
const sabnzbdRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
export const DataflowVisualizationComponent = () => (
|
||||
<section className={styles.dataflowSection}>
|
||||
<div className={styles.dataflowHeading}>
|
||||
<h2>
|
||||
return (
|
||||
<div className={"bg-black/[.10] py-20"}>
|
||||
<h2 className={"text-center lg:text-5xl text-3xl font-extrabold mb-24"}>
|
||||
No YAML configurations.
|
||||
<br />
|
||||
Easy and quick to manage integrations.
|
||||
</h2>
|
||||
<p>Homarr brings status, controls, and updates together in one dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.dataflowDiagram}
|
||||
role="img"
|
||||
aria-label={`Information flowing both ways between Homarr and rotating examples from ${cyclingIntegrations.length} supported integrations`}
|
||||
>
|
||||
<svg className={styles.dataflowLines} viewBox="0 0 960 520" preserveAspectRatio="none" aria-hidden="true">
|
||||
{paths.map((path) => (
|
||||
<g key={path}>
|
||||
<path className={styles.dataflowLine} d={path} pathLength="1" />
|
||||
<path className={styles.dataflowSignal} d={path} pathLength="1" />
|
||||
<path className={styles.dataflowSignalReverse} d={path} pathLength="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="relative max-w-128 h-80 mx-auto animated-dataflow mx-5">
|
||||
<img
|
||||
ref={homarrRef}
|
||||
className={"absolute left-1/2 top-1/2 -translate-y-1/2 -translate-x-1/2 aspect-square object-contain"}
|
||||
src={"/img/logo.png"}
|
||||
alt={"Homarr Logo"}
|
||||
width={100}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
{nodePositions.map((position, nodeIndex) => {
|
||||
const cycleStepInSeconds = 1.2 + nodeIndex * 0.13;
|
||||
const phaseOffsetInSeconds = nodeIndex * 2.7;
|
||||
<img
|
||||
ref={radarrRef}
|
||||
className={"absolute left-0 top-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/radarr.png?raw=true"}
|
||||
alt={"Radarr"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<img
|
||||
ref={sonarrRef}
|
||||
className={"absolute left-0 top-1/2 -translate-y-1/2"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sonarr.png?raw=true"}
|
||||
alt={"Sonarr"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<img
|
||||
ref={lidarrRef}
|
||||
className={"absolute left-0 bottom-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/lidarr.png?raw=true"}
|
||||
alt={"Lidarr"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div className={`${styles.dataflowNode} ${position}`} key={position}>
|
||||
<div className={styles.dataflowCyclingIcons} aria-hidden="true">
|
||||
{cyclingIntegrations.map((_, integrationIndex) => {
|
||||
const integration =
|
||||
cyclingIntegrations[(integrationIndex + nodeIndex * 4) % cyclingIntegrations.length];
|
||||
<img
|
||||
ref={jellyfinRef}
|
||||
className={"absolute right-0 top-1/2 -translate-y-1/2"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true"}
|
||||
alt={"Jellyfin"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
return (
|
||||
<img
|
||||
src={integration.iconUrl}
|
||||
alt=""
|
||||
width={54}
|
||||
height={54}
|
||||
key={integration.name}
|
||||
style={{
|
||||
animationDelay: `${-(integrationIndex * cycleStepInSeconds + phaseOffsetInSeconds)}s`,
|
||||
animationDuration: `${cyclingIntegrations.length * cycleStepInSeconds}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<img
|
||||
ref={plexRef}
|
||||
className={"absolute right-0 bottom-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/plex.png?raw=true"}
|
||||
alt={"Plex"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
<div className={`${styles.dataflowNode} ${styles.dataflowHub}`}>
|
||||
<img src="/img/logo.png" alt="" width={86} height={86} />
|
||||
<img
|
||||
ref={sabnzbdRef}
|
||||
className={"absolute right-0 top-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sabnzbd.png?raw=true"}
|
||||
alt={"Sabnzbd"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
<LineTree start={sonarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
|
||||
<LineTree start={radarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
|
||||
<LineTree start={lidarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
|
||||
<LineTree start={plexRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
|
||||
<LineTree start={jellyfinRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
|
||||
<LineTree start={sabnzbdRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export const LineTree = ({
|
||||
start,
|
||||
end,
|
||||
startSocket,
|
||||
endSocket,
|
||||
x = 0,
|
||||
}: {
|
||||
start: any;
|
||||
end: any;
|
||||
startSocket: SocketType;
|
||||
endSocket: SocketType;
|
||||
x?: number;
|
||||
}) => {
|
||||
const line = useRef<LeaderLine | null>(null);
|
||||
let leaderLine: LeaderLine;
|
||||
|
||||
React.useEffect(() => {
|
||||
const drawLine = () => {
|
||||
leaderLine = new LeaderLine(start.current, LeaderLine.pointAnchor(end.current, { x: `${x}%` }), {
|
||||
startSocket: startSocket,
|
||||
endSocket: endSocket,
|
||||
color: "var(--ifm-color-primary)",
|
||||
size: 3,
|
||||
dash: {
|
||||
animation: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
const timer = setInterval(() => {
|
||||
if (start.current) {
|
||||
clearInterval(timer);
|
||||
drawLine();
|
||||
}
|
||||
}, 5);
|
||||
return () => {
|
||||
timer && clearInterval(timer);
|
||||
leaderLine.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// scroll and resize listeners could be assigned here
|
||||
setTimeout(() => {
|
||||
// skip current even loop and wait
|
||||
// the end of parent's render call
|
||||
if (line.current && end?.current) {
|
||||
(line.current as any).position();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,100 +1,22 @@
|
||||
import { translate } from "@docusaurus/Translate";
|
||||
import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/icons-react";
|
||||
|
||||
import styles from "../../../../pages/index.module.css";
|
||||
import { AppWidget } from "./widgets/app-widget";
|
||||
import { ClockWidget } from "./widgets/clock-widget";
|
||||
import { DownloadsWidget } from "./widgets/downloads-widget";
|
||||
import { EntityStateWidget } from "./widgets/entity-state-widget";
|
||||
import { StockWidget } from "./widgets/stock-widget";
|
||||
import { WeatherWidget } from "./widgets/weather-widget";
|
||||
import { WidgetCard } from "./widgets/card";
|
||||
import { StockWidget } from "./widgets/stock-widget";
|
||||
import { EntityStateWidget } from "./widgets/entity-state-widget";
|
||||
|
||||
export const HeroCards = () => {
|
||||
return (
|
||||
<div className={`argos-ignore hero-cards ${styles.heroCards}`}>
|
||||
<div className="argos-ignore hero-cards flex flex-wrap max-w-[504px] gap-y-4 gap-x-3 text-gray-700 dark:text-gray-300">
|
||||
<StockWidget />
|
||||
<AppWidget name="Plex" />
|
||||
<AppWidget name="Jellyfin" />
|
||||
<AppWidget name="Homeassistant" label="Home Assistant" />
|
||||
<ClockWidget />
|
||||
<SystemResourcesWidget />
|
||||
<AppWidget />
|
||||
<AppWidget className="hidden 3xl:block" />
|
||||
<WeatherWidget />
|
||||
<EntityStateWidget />
|
||||
<AppWidget name="PiHole" label="Pi-hole" />
|
||||
<AppWidget name="qBittorrent" />
|
||||
<DownloadsWidget />
|
||||
<DockerWidget />
|
||||
<AppWidget name="Sonarr" />
|
||||
<AppWidget name="Radarr" />
|
||||
<EntityStateWidget />
|
||||
<AppWidget />
|
||||
|
||||
<ClockWidget />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const heroLabels = {
|
||||
systemResources: translate({ id: "homepage.preview.systemResources", message: "System resources" }),
|
||||
online: translate({ id: "homepage.preview.online", message: "Online" }),
|
||||
cpu: translate({ id: "homepage.preview.cpu", message: "CPU" }),
|
||||
memory: translate({ id: "homepage.preview.memory", message: "Memory" }),
|
||||
disk: translate({ id: "homepage.preview.disk", message: "Disk" }),
|
||||
docker: translate({ id: "homepage.preview.docker", message: "Docker" }),
|
||||
dockerSummary: translate({ id: "homepage.preview.dockerSummary", message: "2 running · 1 failed" }),
|
||||
healthy: translate({ id: "homepage.preview.healthy", message: "Healthy" }),
|
||||
running: translate({ id: "homepage.preview.running", message: "Running" }),
|
||||
exited: translate({ id: "homepage.preview.exited", message: "Exited" }),
|
||||
};
|
||||
|
||||
const SystemResourcesWidget = () => (
|
||||
<WidgetCard width={2} className={styles.resourcesWidget}>
|
||||
<div className={styles.widgetHeading}>
|
||||
<span className={styles.widgetTitle}>
|
||||
<IconDeviceDesktopAnalytics size={17} aria-hidden="true" />
|
||||
{heroLabels.systemResources}
|
||||
</span>
|
||||
<span className={styles.onlineStatus}>{heroLabels.online}</span>
|
||||
</div>
|
||||
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label={heroLabels.cpu} value={34} />
|
||||
<ResourceMeter label={heroLabels.memory} value={62} />
|
||||
<ResourceMeter label={heroLabels.disk} value={98} />
|
||||
</WidgetCard>
|
||||
);
|
||||
|
||||
const ResourceMeter = ({ icon, label, value }: { icon?: React.ReactNode; label: string; value: number }) => (
|
||||
<div className={`${styles.resourceMeter} ${value >= 90 ? styles.resourceMeterCritical : ""}`}>
|
||||
<div className={styles.resourceLabel}>
|
||||
<span>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<span>{value}%</span>
|
||||
</div>
|
||||
<progress className={styles.resourceTrack} aria-label={`${label} usage`} max={100} value={value} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DockerWidget = () => (
|
||||
<WidgetCard width={2} className={styles.dockerWidget}>
|
||||
<div className={styles.widgetHeading}>
|
||||
<span className={styles.widgetTitle}>
|
||||
<IconBrandDocker size={18} aria-hidden="true" />
|
||||
{heroLabels.docker}
|
||||
</span>
|
||||
<span className={styles.dockerCount}>{heroLabels.dockerSummary}</span>
|
||||
</div>
|
||||
<div className={styles.containerList}>
|
||||
{[
|
||||
{ name: "immich", status: heroLabels.healthy, failed: false },
|
||||
{ name: "jellyfin", status: heroLabels.running, failed: false },
|
||||
{ name: "paperless", status: heroLabels.exited, failed: true },
|
||||
].map(({ name, status, failed }) => (
|
||||
<div className={`${styles.containerRow} ${failed ? styles.containerRowFailed : ""}`} key={name}>
|
||||
<span className={styles.containerName}>{name}</span>
|
||||
<span className={styles.containerStatus}>
|
||||
<span className={`${styles.statusDot} ${failed ? styles.statusDotFailed : ""}`} aria-hidden="true" />
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</WidgetCard>
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "@docusaurus/Link";
|
||||
import { translate } from "@docusaurus/Translate";
|
||||
import { IconDownload, IconExternalLink } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import styles from "../../../../pages/index.module.css";
|
||||
@@ -8,9 +7,9 @@ import { HeroCards } from "./hero-cards";
|
||||
export default function HomeHero() {
|
||||
return (
|
||||
<header className={clsx("hero hero--primary", styles.heroBanner, styles.hero)}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.heroCopy}>
|
||||
<h1 className={clsx("font-extrabold lg:text-7xl text-3xl", styles.heroTitle)}>
|
||||
<div className="flex">
|
||||
<div className="ps-0 lg:ps-52">
|
||||
<h1 className="font-extrabold lg:text-7xl text-3xl">
|
||||
A simple, yet
|
||||
<br />
|
||||
powerful dashboard
|
||||
@@ -23,7 +22,7 @@ export default function HomeHero() {
|
||||
information.
|
||||
</p>
|
||||
|
||||
<div className={styles.heroActions}>
|
||||
<div className={"flex flex-nowrap gap-2"}>
|
||||
<Link
|
||||
data-attr="Install button"
|
||||
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
|
||||
@@ -58,12 +57,9 @@ export default function HomeHero() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
className={clsx(styles.heroPreview, "hover-animation")}
|
||||
aria-label={translate({ id: "homepage.preview.label", message: "Example Homarr dashboard" })}
|
||||
>
|
||||
<div className="flex-grow-1 w-full lg:block hidden hover-animation">
|
||||
<HeroCards />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { supportedIntegrations } from "@site/src/constants/supported-integrations";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import { getRndInteger } from "@site/src/tools/math";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface AppWidgetProps extends CommonWidgetProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const AppWidget = ({ name, label, className }: AppWidgetProps) => {
|
||||
const app = supportedIntegrations.find((integration) => integration.name === name);
|
||||
if (!app) return null;
|
||||
export const AppWidget = ({ className }: CommonWidgetProps) => {
|
||||
const randomApp = supportedIntegrations[getRndInteger(0, supportedIntegrations.length - 1)];
|
||||
|
||||
return (
|
||||
<WidgetCard width={1} className={clsx("app-card text-center", className)}>
|
||||
<span className={"text-sm font-bold"}>{label ?? app.name}</span>
|
||||
<img src={app.iconUrl} className="aspect-square scale-[0.6]" alt="" />
|
||||
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
|
||||
<WidgetCard width={1} className={clsx("text-center", className)}>
|
||||
<span className={"text-sm font-bold"}>{randomApp.name}</span>
|
||||
<img
|
||||
src={randomApp.iconUrl}
|
||||
className="aspect-square scale-[0.6] transition-transform"
|
||||
alt={`${randomApp.name} Icon`}
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2"></div>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
import styles from "../../../../../pages/index.module.css";
|
||||
|
||||
export interface CommonWidgetProps {
|
||||
className?: string;
|
||||
@@ -17,13 +16,15 @@ export const WidgetCard = ({ width, children, className, onClick }: WidgetCardPr
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
styles.widgetCard,
|
||||
"flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
|
||||
"rounded-xl flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
|
||||
className,
|
||||
onClick ? "cursor-pointer" : undefined,
|
||||
)}
|
||||
style={{ width: (128 * width + (width === 2 ? 16 : 0)) * 0.9, height: 128 }}
|
||||
type={onClick ? "button" : undefined}
|
||||
style={{
|
||||
width: (128 * width + (width === 2 ? 16 : 0)) * 0.9,
|
||||
height: 128,
|
||||
background: "rgb(from var(--ifm-navbar-background-color) r g b / 80%)",
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,40 +1,26 @@
|
||||
import { translate } from "@docusaurus/Translate";
|
||||
import { getRndInteger } from "@site/src/tools/math";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import clsx from "clsx";
|
||||
import styles from "../../../../../pages/index.module.css";
|
||||
|
||||
const downloadQueue = ["family-photos.zip", "linux-isos.mkv", "server-backup.zip", "holiday-video.mp4"];
|
||||
const downloadLabels = {
|
||||
file: translate({ id: "homepage.preview.file", message: "File" }),
|
||||
progress: translate({ id: "homepage.preview.progress", message: "Progress" }),
|
||||
};
|
||||
import { generateSlug } from "random-word-slugs";
|
||||
|
||||
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
|
||||
const [downloads, setDownloads] = useState<Download[]>([
|
||||
{ filename: "photos-backup.zip", progress: 72 },
|
||||
{ filename: "media-library.mkv", progress: 38 },
|
||||
]);
|
||||
const [downloads, setDownloads] = useState<Download[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
setDownloads((prev: Download[]) => {
|
||||
const filteredPrev = prev.filter((download) => download.progress !== 100);
|
||||
|
||||
const progressedDownloads = filteredPrev.map((download, index) => ({
|
||||
...download,
|
||||
progress: Math.min(100, download.progress + (index < 2 ? 8 : 4)),
|
||||
}));
|
||||
filteredPrev.forEach((download, index) => {
|
||||
download.progress = Math.min(100, download.progress + getRndInteger(0, index < 2 ? 16 : 8));
|
||||
});
|
||||
|
||||
if (progressedDownloads.length < 3 && progressedDownloads.length === prev.length) {
|
||||
const availableDownloads = downloadQueue.filter(
|
||||
(filename) => !progressedDownloads.some((download) => download.filename === filename),
|
||||
);
|
||||
const filename = availableDownloads[0];
|
||||
if (filename) progressedDownloads.push({ filename, progress: 0 });
|
||||
if (filteredPrev.length < 3 && filteredPrev.length === prev.length) {
|
||||
filteredPrev.push(generateRandomDownload());
|
||||
}
|
||||
|
||||
return progressedDownloads;
|
||||
return [...filteredPrev];
|
||||
});
|
||||
}, 500);
|
||||
|
||||
@@ -48,31 +34,19 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
|
||||
<table className="mb-0 w-full h-full text-xs">
|
||||
<thead className="inline-block w-full">
|
||||
<tr className="inline-block w-full">
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.file}</th>
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.progress}</th>
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">File</th>
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{downloads.map((file: Download) => {
|
||||
{downloads.map((file: Download, index: number) => {
|
||||
return (
|
||||
<tr key={file.filename}>
|
||||
<tr key={index}>
|
||||
<td className="border-none text-nowrap p-2">{file.filename}</td>
|
||||
<td
|
||||
className="border-none p-2 w-full"
|
||||
aria-label={translate(
|
||||
{ id: "homepage.preview.downloadComplete", message: "{progress}% complete" },
|
||||
{ progress: file.progress },
|
||||
)}
|
||||
>
|
||||
<progress
|
||||
className={styles.downloadProgress}
|
||||
value={file.progress}
|
||||
max={100}
|
||||
aria-label={translate(
|
||||
{ id: "homepage.preview.downloadProgress", message: "{filename} download progress" },
|
||||
{ filename: file.filename },
|
||||
)}
|
||||
/>
|
||||
<td className="border-none p-2 w-full">
|
||||
<div className="overflow-hidden rounded-xl h-2 w-full bg-zinc-600">
|
||||
<div className="bg-green-600 h-full animated-width" style={{ width: `${file.progress}%` }}></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -87,3 +61,15 @@ interface Download {
|
||||
filename: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const formats = ["mkv", "mp4"];
|
||||
|
||||
const generateRandomDownload = (): Download => {
|
||||
const randomMovie = generateSlug(2);
|
||||
const randomFormat = formats[getRndInteger(0, formats.length - 1)];
|
||||
|
||||
return {
|
||||
filename: `${randomMovie}.${randomFormat}`,
|
||||
progress: 0,
|
||||
};
|
||||
};
|
||||
@@ -27,7 +27,6 @@ export const StockWidget = ({ className }: CommonWidgetProps) => {
|
||||
HOMR
|
||||
</div>
|
||||
<Line
|
||||
ariaLabel="Simulated HOMR stock price trend"
|
||||
height={128}
|
||||
width={244}
|
||||
colors={[upwardTrend ? "green" : "red"]}
|
||||
|
||||
@@ -758,7 +758,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/docs/workshop/"
|
||||
to="/docs/management/workshop/"
|
||||
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Installation guide <IconExternalLink size={13} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
|
||||
@import "tailwindcss/theme" layer(theme);
|
||||
@import "tailwindcss/utilities" layer(utilities);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
--breakpoint-3xl: 1697px;
|
||||
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: "Work Sans", sans-serif;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -137,7 +137,7 @@
|
||||
--sidebar-border: oklch(0.9276 0.0058 264.5313);
|
||||
--sidebar-ring: oklch(0.56 0.205 25.6509);
|
||||
--destructive-foreground: oklch(0.9851 0 0);
|
||||
--font-sans: "Work Sans", sans-serif;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--shadow-color: #000000;
|
||||
@@ -491,10 +491,6 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hover-animation {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.submit-step-enter {
|
||||
animation: none;
|
||||
}
|
||||
@@ -545,7 +541,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
|
||||
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
|
||||
--sidebar-border: oklch(0.2739 0.0055 286.0326);
|
||||
--sidebar-ring: oklch(0.72 0.16 25.6509);
|
||||
--font-sans: "Work Sans", sans-serif;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--shadow-color: #000000;
|
||||
|
||||
@@ -28,457 +28,13 @@ section {
|
||||
}
|
||||
|
||||
.heroBanner {
|
||||
padding: clamp(3rem, 6vw, 6rem) clamp(1.25rem, 4vw, 4rem);
|
||||
padding: 4rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(25rem, 1fr) minmax(44rem, 48rem);
|
||||
align-items: center;
|
||||
gap: clamp(1rem, 3vw, 3rem);
|
||||
max-width: 108rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.heroPreview {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heroCards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 0.75rem;
|
||||
width: min(100%, 48rem);
|
||||
margin-inline: auto;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .heroCards {
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
.widgetCard {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, currentcolor 12%, transparent) !important;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
background: color-mix(in srgb, var(--ifm-navbar-background-color) 76%, transparent) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 12px 28px rgb(31 41 55 / 14%);
|
||||
}
|
||||
|
||||
.resourcesWidget,
|
||||
.dockerWidget {
|
||||
align-items: stretch !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.widgetHeading,
|
||||
.resourceLabel,
|
||||
.containerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.widgetTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onlineStatus,
|
||||
.dockerCount {
|
||||
flex: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.onlineStatus {
|
||||
color: #087f5b;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .onlineStatus {
|
||||
color: #69dbb1;
|
||||
}
|
||||
|
||||
.resourceMeter {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.resourceMeterCritical .resourceLabel {
|
||||
color: #a51111;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resourceLabel {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.resourceLabel > span:first-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.resourceTrack {
|
||||
display: block;
|
||||
appearance: none;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 0.375rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentcolor 16%, transparent);
|
||||
}
|
||||
|
||||
.resourceTrack::-webkit-progress-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resourceTrack::-webkit-progress-value,
|
||||
.resourceTrack::-moz-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.resourceMeterCritical .resourceTrack::-webkit-progress-value,
|
||||
.resourceMeterCritical .resourceTrack::-moz-progress-bar {
|
||||
background: #e03131;
|
||||
}
|
||||
|
||||
.downloadProgress {
|
||||
display: block;
|
||||
appearance: none;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentcolor 18%, transparent);
|
||||
}
|
||||
|
||||
.downloadProgress::-webkit-progress-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.downloadProgress::-webkit-progress-value,
|
||||
.downloadProgress::-moz-progress-bar {
|
||||
border-radius: inherit;
|
||||
background: #2f9e44;
|
||||
}
|
||||
|
||||
.containerList {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.containerRow {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.containerName {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.containerStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: color-mix(in srgb, currentcolor 82%, transparent);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: #12b886;
|
||||
}
|
||||
|
||||
.containerRowFailed {
|
||||
color: #a51111;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dataflowSection {
|
||||
margin-bottom: 0;
|
||||
padding: clamp(4rem, 8vw, 7rem) clamp(1rem, 4vw, 3rem);
|
||||
overflow: hidden;
|
||||
background: rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.dataflowHeading {
|
||||
max-width: 58rem;
|
||||
margin: 0 auto clamp(2rem, 5vw, 4rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dataflowHeading h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.dataflowHeading p {
|
||||
margin: 0 auto;
|
||||
max-width: 42rem;
|
||||
color: color-mix(in srgb, var(--ifm-font-color-base) 76%, transparent);
|
||||
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dataflowDiagram {
|
||||
position: relative;
|
||||
width: min(100%, 70rem);
|
||||
aspect-ratio: 960 / 520;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.dataflowLines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dataflowLine,
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.dataflowLine {
|
||||
stroke: color-mix(in srgb, var(--ifm-color-primary) 30%, transparent);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse {
|
||||
stroke: var(--ifm-color-primary);
|
||||
stroke-width: 7;
|
||||
stroke-dasharray: 0.035 0.965;
|
||||
animation: dataflow-signal 2.8s linear infinite;
|
||||
}
|
||||
|
||||
.dataflowSignalReverse {
|
||||
animation-direction: reverse;
|
||||
animation-delay: -1.4s;
|
||||
}
|
||||
|
||||
.dataflowNode {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
width: clamp(4.5rem, 10vw, 7.5rem);
|
||||
aspect-ratio: 1;
|
||||
padding: clamp(0.6rem, 1.5vw, 1rem);
|
||||
border-radius: 0.875rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 6px 12px rgb(31 41 55 / 14%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.dataflowNode img {
|
||||
width: clamp(2rem, 4.5vw, 3.4rem);
|
||||
height: clamp(2rem, 4.5vw, 3.4rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dataflowCyclingIcons {
|
||||
position: relative;
|
||||
width: clamp(2rem, 4.5vw, 3.4rem);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.dataflowCyclingIcons img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
animation: dataflow-icon-cycle linear infinite;
|
||||
}
|
||||
|
||||
.dataflowNodeTopLeft,
|
||||
.dataflowNodeMiddleLeft,
|
||||
.dataflowNodeBottomLeft {
|
||||
left: 15%;
|
||||
}
|
||||
|
||||
.dataflowNodeTopRight,
|
||||
.dataflowNodeMiddleRight,
|
||||
.dataflowNodeBottomRight {
|
||||
left: 85%;
|
||||
}
|
||||
|
||||
.dataflowNodeTopLeft,
|
||||
.dataflowNodeTopRight {
|
||||
top: 16%;
|
||||
}
|
||||
|
||||
.dataflowNodeMiddleLeft,
|
||||
.dataflowNodeMiddleRight,
|
||||
.dataflowHub {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.dataflowNodeBottomLeft,
|
||||
.dataflowNodeBottomRight {
|
||||
top: 84%;
|
||||
}
|
||||
|
||||
.dataflowHub {
|
||||
left: 50%;
|
||||
width: clamp(6rem, 13vw, 9rem);
|
||||
}
|
||||
|
||||
.dataflowHub img {
|
||||
width: clamp(3rem, 7vw, 5.4rem);
|
||||
height: clamp(3rem, 7vw, 5.4rem);
|
||||
}
|
||||
|
||||
@keyframes dataflow-signal {
|
||||
to {
|
||||
stroke-dashoffset: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dataflow-icon-cycle {
|
||||
0%,
|
||||
3.7% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
4.2%,
|
||||
99% {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .resourceMeterCritical .resourceLabel,
|
||||
:global([data-theme="dark"]) .containerRowFailed {
|
||||
color: #ffe3e3;
|
||||
}
|
||||
|
||||
.statusDotFailed {
|
||||
background: #fa5252;
|
||||
animation: failed-container-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes failed-container-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(1.65);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1280px) {
|
||||
.heroBanner {
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.heroCards {
|
||||
width: min(100%, 48rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.heroBanner {
|
||||
padding: 2.5rem 1rem;
|
||||
}
|
||||
|
||||
.heroPreview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heroActions > :global(*) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
.dataflowSection {
|
||||
padding-block: 4rem;
|
||||
}
|
||||
|
||||
.dataflowHeading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dataflowDiagram {
|
||||
aspect-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
.dataflowNode {
|
||||
width: 4.5rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.statusDotFailed,
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse,
|
||||
.dataflowCyclingIcons img {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dataflowCyclingIcons img:first-child {
|
||||
opacity: 1;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import Layout from "@theme/Layout";
|
||||
import HomepageFeatures from "../components/pages/home/features/features";
|
||||
import HomepageUserReviews from "../components/pages/home/review-list/review-list";
|
||||
import HomeHero from "../components/pages/home/hero/hero";
|
||||
import { DataflowVisualizationComponent } from "../components/pages/home/dataflow/dataflow";
|
||||
|
||||
import styles from "./index.module.css";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import { SectionContainer } from "@site/src/components/pages/home/container/section-container";
|
||||
import { DragAndDropShowcase } from "@site/src/components/pages/home/drag-and-drop/drag-and-drop-showcase";
|
||||
import { AvailableIntegrations } from "@site/src/components/pages/home/integrations/available-integrations";
|
||||
@@ -32,7 +32,13 @@ export default function Home() {
|
||||
<SectionContainer>
|
||||
<HomepageFeatures />
|
||||
</SectionContainer>
|
||||
<DataflowVisualizationComponent />
|
||||
<BrowserOnly fallback={<span>loading...</span>}>
|
||||
{() => {
|
||||
const DataflowVisualizationComponent =
|
||||
require("../components/pages/home/dataflow/dataflow").DataflowVisualizationComponent;
|
||||
return <DataflowVisualizationComponent />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
<DragAndDropShowcase />
|
||||
<AvailableIntegrations />
|
||||
<SectionContainer>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getRndInteger(min: number, max: number) {
|
||||
return Math.round(Math.random() * (max - min)) + min;
|
||||
}
|
||||
-2
@@ -2152,7 +2152,6 @@
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"assistant",
|
||||
"wud"
|
||||
]
|
||||
},
|
||||
@@ -2166,7 +2165,6 @@
|
||||
},
|
||||
"integrationIds": {
|
||||
"default": [],
|
||||
"maxItems": 32,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
-7
@@ -1,7 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenCollective</title>
|
||||
<path
|
||||
fill="#7FADF2"
|
||||
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12c2.54 0 4.894-.79 6.834-2.135l-3.107-3.109a7.715 7.715 0 1 1 0-13.512l3.107-3.109A11.943 11.943 0 0 0 12 0zm9.865 5.166-3.109 3.107A7.67 7.67 0 0 1 19.715 12a7.682 7.682 0 0 1-.959 3.727l3.109 3.107A11.943 11.943 0 0 0 24 12c0-2.54-.79-4.894-2.135-6.834z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 447 B |
@@ -14,12 +14,6 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "catalog:",
|
||||
"@ai-sdk/react": "catalog:",
|
||||
"@assistant-ui/react": "catalog:",
|
||||
"@assistant-ui/react-ai-sdk": "catalog:",
|
||||
"@assistant-ui/react-lexical": "catalog:",
|
||||
"@assistant-ui/react-markdown": "catalog:",
|
||||
"@dnd-kit/abstract": "catalog:",
|
||||
"@dnd-kit/collision": "catalog:",
|
||||
"@dnd-kit/core": "catalog:",
|
||||
@@ -68,7 +62,7 @@
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
@@ -83,8 +77,6 @@
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
"adm-zip": "catalog:",
|
||||
"ai": "catalog:",
|
||||
"assistant-stream": "catalog:",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"chroma-js": "catalog:",
|
||||
@@ -97,9 +89,10 @@
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mantine-datatable": "catalog:",
|
||||
"mantine-react-table": "catalog:",
|
||||
"mermaid": "catalog:",
|
||||
"mcp-handler": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
@@ -107,14 +100,11 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-error-boundary": "catalog:",
|
||||
"react-shiki": "catalog:",
|
||||
"react-simple-code-editor": "catalog:",
|
||||
"remark-breaks": "catalog:",
|
||||
"remark-directive": "catalog:",
|
||||
"remark-gfm": "catalog:",
|
||||
"sass": "catalog:",
|
||||
"sql.js": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
"trpc-to-mcp": "catalog:",
|
||||
"use-deep-compare-effect": "catalog:",
|
||||
"yaml": "catalog:",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -22,7 +22,6 @@ export async function GET() {
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp:tools"],
|
||||
resource_indicators_supported: true,
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function GET() {
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
resource: `${baseUrl}/api/mcp`,
|
||||
resource: baseUrl,
|
||||
authorization_servers: [baseUrl],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useDisclosure, useMounted } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
@@ -32,7 +32,6 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
const isMounted = useMounted();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const form = useZodForm(extendedValidation, {
|
||||
initialValues: {
|
||||
@@ -133,7 +132,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
|
||||
{providers.includes("credentials") && (
|
||||
<Stack gap="sm">
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
|
||||
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
||||
{t("action.login.label")}
|
||||
</SubmitButton>
|
||||
<PasswordForgottenCollapse username={form.values.name} />
|
||||
@@ -141,7 +140,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("ldap") && (
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
|
||||
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
||||
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||
</SubmitButton>
|
||||
)}
|
||||
@@ -152,7 +151,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("oidc") && (
|
||||
<Button fullWidth variant="light" disabled={!isMounted} onClick={async () => await signInAsync("oidc")}>
|
||||
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
|
||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||
</Button>
|
||||
)}
|
||||
@@ -162,13 +161,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
};
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isMounted: boolean;
|
||||
isPending: boolean;
|
||||
form: UseFormReturnType<FormType>;
|
||||
provider: "credentials" | "ldap";
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||
|
||||
return (
|
||||
@@ -178,7 +176,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW
|
||||
fullWidth
|
||||
onClick={() => form.setFieldValue("provider", provider)}
|
||||
loading={isPending && isCurrentProviderActive}
|
||||
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
|
||||
disabled={isPending && !isCurrentProviderActive}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
@@ -44,24 +43,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
const resolvedParams = await params;
|
||||
const queryClient = getQueryClient();
|
||||
const session = await auth();
|
||||
|
||||
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
|
||||
|
||||
const itemsMap = board.items.reduce((acc, item) => {
|
||||
const existing = acc.get(item.kind);
|
||||
@@ -113,8 +96,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Ignore not found and bad-request errors and return empty metadata
|
||||
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
|
||||
// Ignore not found errors and return empty metadata
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -222,15 +222,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
/>
|
||||
)}
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<GutterSettings
|
||||
left={layout.leftGutterColumnCount}
|
||||
right={layout.rightGutterColumnCount}
|
||||
columnCount={layout.columnCount}
|
||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
{layout.role !== "mobile" && (
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<GutterSettings
|
||||
left={layout.leftGutterColumnCount}
|
||||
right={layout.rightGutterColumnCount}
|
||||
columnCount={layout.columnCount}
|
||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
||||
|
||||
@@ -36,6 +36,11 @@ const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
||||
|
||||
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
|
||||
|
||||
const normalizeMobileLayoutGutters = (layouts: readonly FormValues["layouts"][number][]) =>
|
||||
layouts.map((layout) =>
|
||||
layout.role === "mobile" ? { ...layout, leftGutterColumnCount: 0, rightGutterColumnCount: 0 } : layout,
|
||||
);
|
||||
|
||||
const PARTIAL_FORM_KEYS = [
|
||||
"pageTitle",
|
||||
"metaTitle",
|
||||
@@ -70,7 +75,7 @@ const buildInitialValues = (board: Board): FormValues => ({
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
layouts: board.layouts,
|
||||
layouts: normalizeMobileLayoutGutters(board.layouts),
|
||||
});
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
@@ -127,7 +132,8 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const { layouts, ...partialSettings } = values;
|
||||
const { layouts: submittedLayouts, ...partialSettings } = values;
|
||||
const layouts = normalizeMobileLayoutGutters(submittedLayouts);
|
||||
const partialSettingsChanged = changed(...PARTIAL_FORM_KEYS);
|
||||
const layoutsChanged = changed("layouts");
|
||||
if (!partialSettingsChanged && !layoutsChanged) return values;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createElement, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardProviders } from "./_providers";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
searchParams: new URLSearchParams(),
|
||||
layoutOverrideId: undefined as string | null | undefined,
|
||||
initialOpen: undefined as boolean | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => mocks.searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
BoardProvider: ({ children, layoutOverrideId }: { children: React.ReactNode; layoutOverrideId: string | null }) => {
|
||||
mocks.layoutOverrideId = layoutOverrideId;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
EditModeProvider: ({ children, initialOpen }: { children: React.ReactNode; initialOpen: boolean }) => {
|
||||
mocks.initialOpen = initialOpen;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("board providers", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
mocks.layoutOverrideId = undefined;
|
||||
mocks.initialOpen = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["base", "base"],
|
||||
["custom", "custom"],
|
||||
])("uses the requested %s layout for an explicit edit request", (_name, requestedLayoutId) => {
|
||||
mocks.searchParams = new URLSearchParams(`layout=${requestedLayoutId}&edit=true&returnTo=settings`);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardProviders,
|
||||
{
|
||||
initialBoard: { layouts: [{ id: "mobile" }, { id: "custom" }, { id: "base" }] } as never,
|
||||
initialLayoutId: "mobile",
|
||||
initialViewportWidth: 390,
|
||||
canModify: true,
|
||||
},
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.layoutOverrideId).toBe(requestedLayoutId);
|
||||
expect(mocks.initialOpen).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["without edit mode", "layout=base", true],
|
||||
["for an unknown layout", "layout=unknown&edit=true", true],
|
||||
["without modify access", "layout=base&edit=true", false],
|
||||
])("keeps viewport layout selection %s", (_name, query, canModify) => {
|
||||
mocks.searchParams = new URLSearchParams(query);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardProviders,
|
||||
{
|
||||
initialBoard: { layouts: [{ id: "mobile" }, { id: "base" }] } as never,
|
||||
initialLayoutId: "mobile",
|
||||
initialViewportWidth: 390,
|
||||
canModify,
|
||||
},
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.layoutOverrideId).toBeNull();
|
||||
expect(mocks.initialOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
@@ -24,11 +23,10 @@ export const BoardProviders = ({
|
||||
canModify,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const searchParams = useSearchParams();
|
||||
const isDesktop = useMediaQuery("(min-width: 64em)");
|
||||
const requestedLayoutId = searchParams.get("layout");
|
||||
const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId);
|
||||
const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid;
|
||||
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
|
||||
const layoutOverrideId = isLayoutEditRequest ? requestedLayoutId : null;
|
||||
|
||||
return (
|
||||
<BoardProvider
|
||||
|
||||
@@ -13,7 +13,6 @@ import { notFound } from "next/navigation";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env as authEnv } from "@homarr/auth/env";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
@@ -28,8 +27,6 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import type { AssistantAvailability } from "~/components/assistant/assistant-gate";
|
||||
import { AssistantGate } from "~/components/assistant/assistant-gate";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
import { env } from "~/env";
|
||||
|
||||
@@ -100,20 +97,11 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
|
||||
!session
|
||||
? "unauthenticated"
|
||||
: api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error"),
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
const [session, user, serverSettings, colorScheme] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
assistantAvailabilityPromise,
|
||||
]);
|
||||
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
@@ -157,7 +145,6 @@ export default async function Layout(props: {
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
|
||||
|
||||
describe("getAssistantConnectionState", () => {
|
||||
it("marks a saved required-key provider as ready", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: false,
|
||||
providerRequiresApiKey: true,
|
||||
apiKeyConfigured: true,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: true,
|
||||
connectionPending: false,
|
||||
connectionReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the endpoint to be saved before exposing model controls", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: true,
|
||||
providerRequiresApiKey: true,
|
||||
apiKeyConfigured: true,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: false,
|
||||
connectionPending: true,
|
||||
connectionReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call a required-key connection ready after its key is removed", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: false,
|
||||
providerRequiresApiKey: true,
|
||||
apiKeyConfigured: false,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: false,
|
||||
connectionPending: true,
|
||||
connectionReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a saved local endpoint without an API key", () => {
|
||||
expect(
|
||||
getAssistantConnectionState({
|
||||
connectionConfigured: true,
|
||||
destinationChanged: false,
|
||||
providerRequiresApiKey: false,
|
||||
apiKeyConfigured: false,
|
||||
}),
|
||||
).toEqual({
|
||||
hasStoredApiKey: false,
|
||||
connectionPending: false,
|
||||
connectionReady: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAssistantContextWindow", () => {
|
||||
it("uses Homarr's compact number format for large context windows", () => {
|
||||
expect(formatAssistantContextWindow(1_048_576)).toBe("1M");
|
||||
expect(formatAssistantContextWindow(131_072)).toBe("131k");
|
||||
});
|
||||
});
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { formatNumber } from "@homarr/common";
|
||||
|
||||
interface AssistantConnectionStateInput {
|
||||
connectionConfigured: boolean;
|
||||
destinationChanged: boolean;
|
||||
providerRequiresApiKey: boolean;
|
||||
apiKeyConfigured: boolean;
|
||||
}
|
||||
|
||||
export const getAssistantConnectionState = ({
|
||||
connectionConfigured,
|
||||
destinationChanged,
|
||||
providerRequiresApiKey,
|
||||
apiKeyConfigured,
|
||||
}: AssistantConnectionStateInput) => {
|
||||
const hasStoredApiKey = apiKeyConfigured && !destinationChanged;
|
||||
const credentialsMissing = providerRequiresApiKey && !hasStoredApiKey;
|
||||
const connectionPending = !connectionConfigured || destinationChanged || credentialsMissing;
|
||||
|
||||
return {
|
||||
hasStoredApiKey,
|
||||
connectionPending,
|
||||
connectionReady: !connectionPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatAssistantContextWindow = (tokens: number) => formatNumber(tokens, 0);
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
.configuration {
|
||||
width: 100%;
|
||||
max-width: 74rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.summaryProvider {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summaryProviderText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summaryEndpoint {
|
||||
max-width: min(46rem, 70vw);
|
||||
}
|
||||
|
||||
.section {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
padding: var(--mantine-spacing-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepNumber {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex: 0 0 1.75rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mantine-primary-color-light);
|
||||
color: var(--mantine-primary-color-light-color);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.sectionActions {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.credentialSummary {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.credentialSummaryContent {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.advancedControl {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.selectedModel {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.selectedModelText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectedModelBadges {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.capabilityRow,
|
||||
.enableRow {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.securityNote {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flowActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.summaryEndpoint {
|
||||
max-width: 78vw;
|
||||
}
|
||||
|
||||
.summaryActions,
|
||||
.credentialActions,
|
||||
.sectionActions > :global(.mantine-Group-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summaryActions > *,
|
||||
.credentialActions > *,
|
||||
.flowActions > *,
|
||||
.sectionActions :global(.mantine-Button-root) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.credentialSummary {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.flowActions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.selectedModel {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectedModelBadges {
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.capabilityRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.configuration *,
|
||||
.configuration *::before,
|
||||
.configuration *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
-798
@@ -1,798 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDatabaseSearch,
|
||||
IconKey,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconShieldLock,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
assistantProviderCanUseOpenRouterServerTools,
|
||||
assistantProviderIds,
|
||||
assistantProviderPresets,
|
||||
getAssistantModelOptionLabel,
|
||||
resolveAssistantModelId,
|
||||
} from "@homarr/definitions";
|
||||
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
|
||||
|
||||
import classes from "./assistant-configuration.module.css";
|
||||
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
|
||||
|
||||
type HeaderEntry = { id: number; name: string; value: string };
|
||||
type CredentialFlow = "idle" | "remove";
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => {
|
||||
if (providerId === "homarr") {
|
||||
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
|
||||
}
|
||||
|
||||
const preset = assistantProviderPresets[providerId];
|
||||
if (!preset.iconUrl) return <IconWorld size={size} aria-hidden />;
|
||||
|
||||
const darkIconUrl = "darkIconUrl" in preset ? preset.darkIconUrl : null;
|
||||
if (!darkIconUrl) {
|
||||
return <Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" darkHidden />
|
||||
<Image src={darkIconUrl} alt="" aria-hidden w={size} h={size} fit="contain" lightHidden />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfigurationSectionProps {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ConfigurationSection = ({ number, title, description, status, actions, children }: ConfigurationSectionProps) => (
|
||||
<section className={classes.section}>
|
||||
<Group className={classes.sectionHeader} justify="space-between" align="flex-start">
|
||||
<Group className={classes.sectionHeading} gap="sm" align="flex-start" wrap="nowrap">
|
||||
<Box className={classes.stepNumber} aria-hidden>
|
||||
{number}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={650}>{title}</Text>
|
||||
<Text size="sm" c="dimmed" maw="66ch">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{status}
|
||||
</Group>
|
||||
<Stack className={classes.sectionBody} gap="md">
|
||||
{children}
|
||||
</Stack>
|
||||
{actions && <Box className={classes.sectionActions}>{actions}</Box>}
|
||||
</section>
|
||||
);
|
||||
|
||||
const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
<Stack className={classes.configuration} gap="md" aria-label={label}>
|
||||
<Paper withBorder p="md">
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Skeleton circle h={36} />
|
||||
<Stack gap={5}>
|
||||
<Skeleton h={14} w={180} />
|
||||
<Skeleton h={10} w={280} />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Skeleton h={24} w={110} radius="xl" />
|
||||
</Group>
|
||||
</Paper>
|
||||
{[1, 2, 3].map((number) => (
|
||||
<Paper key={number} withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Skeleton h={18} w={220} />
|
||||
<Skeleton h={12} w="65%" />
|
||||
<Skeleton h={42} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const AssistantConfiguration = () => {
|
||||
const t = useScopedI18n("management.page.settings.section.assistant");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
|
||||
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
|
||||
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
|
||||
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
|
||||
const [clearHeaders, setClearHeaders] = useState(false);
|
||||
const [nextHeaderId, setNextHeaderId] = useState(1);
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const configuredProvider = configuration?.provider ?? "openrouter";
|
||||
setProvider(configuredProvider);
|
||||
setBaseUrl(
|
||||
configuredProvider === "homarr"
|
||||
? getWorkshopAssistantProviderUrl()
|
||||
: (configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl),
|
||||
);
|
||||
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
|
||||
setModelId(configuration?.modelId ?? "");
|
||||
setEnabled(configuration?.enabled ?? false);
|
||||
setWebSearchEnabled(configuration?.webSearchEnabled ?? false);
|
||||
setApiKey("");
|
||||
setCredentialFlow("idle");
|
||||
}, [configuration]);
|
||||
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, "");
|
||||
const normalizedModelDiscoveryPath = modelDiscoveryPath.trim() || null;
|
||||
const destinationChanged =
|
||||
configuration?.connectionConfigured === true &&
|
||||
(configuration.provider !== provider || configuration.baseUrl !== normalizedBaseUrl);
|
||||
const connectionChanged =
|
||||
configuration?.connectionConfigured === true &&
|
||||
(destinationChanged || configuration.modelDiscoveryPath !== normalizedModelDiscoveryPath);
|
||||
|
||||
const {
|
||||
data: models,
|
||||
error: discoveryError,
|
||||
isFetching: isDiscovering,
|
||||
refetch: discoverModels,
|
||||
} = clientApi.assistant.discoverModels.useQuery(undefined, {
|
||||
enabled:
|
||||
configuration?.connectionConfigured === true && configuration.modelDiscoveryPath !== null && !connectionChanged,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
(models ?? []).map((model) => ({
|
||||
value: model.id,
|
||||
label: getAssistantModelOptionLabel(model),
|
||||
})),
|
||||
[models],
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
|
||||
const items = assistantProviderIds
|
||||
.filter((providerId) => assistantProviderPresets[providerId].category === category)
|
||||
.map((providerId) => ({
|
||||
value: providerId,
|
||||
label: t(`provider.options.${providerId}.label`),
|
||||
}));
|
||||
|
||||
return { group: t(`provider.groups.${category}`), items };
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const selectedModel = connectionChanged ? undefined : models?.find((model) => model.id === modelId);
|
||||
const hasDiscoveredModels = !connectionChanged && (models?.length ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!models || models.length === 0) return;
|
||||
const resolvedModelId = resolveAssistantModelId(models, modelId);
|
||||
if (resolvedModelId && resolvedModelId !== modelId) {
|
||||
setModelId(resolvedModelId);
|
||||
}
|
||||
}, [modelId, models]);
|
||||
|
||||
const preset = assistantProviderPresets[provider];
|
||||
const isHomarrProvider = provider === "homarr";
|
||||
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
|
||||
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
|
||||
connectionConfigured: configuration?.connectionConfigured === true,
|
||||
destinationChanged,
|
||||
providerRequiresApiKey: preset.requiresApiKey,
|
||||
apiKeyConfigured: configuration?.apiKeyConfigured === true,
|
||||
});
|
||||
const hasEffectiveApiKey = apiKey.trim().length > 0 || hasStoredApiKey;
|
||||
const connectionValid =
|
||||
baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey);
|
||||
const modelControlsDisabled = connectionPending || isDiscovering;
|
||||
const canSaveConfiguration = !modelControlsDisabled && modelId.trim().length > 0;
|
||||
const canUseOpenRouterServerTools = assistantProviderCanUseOpenRouterServerTools(provider);
|
||||
|
||||
const updateConnection = clientApi.assistant.updateConnection.useMutation({
|
||||
onSuccess: async ({ credentialsClearedForDestinationChange }) => {
|
||||
setApiKey("");
|
||||
setCredentialFlow("idle");
|
||||
setHeaders([]);
|
||||
setClearHeaders(false);
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.discoverModels.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.connectionSaved.title"),
|
||||
message: credentialsClearedForDestinationChange
|
||||
? t("notification.connectionSaved.credentialsCleared")
|
||||
: t("notification.connectionSaved.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const saveConfiguration = clientApi.assistant.updateConfiguration.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
utils.assistant.getRuntimeOptions.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.saved.title"),
|
||||
message: t("notification.saved.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const pending = updateConnection.isPending || saveConfiguration.isPending;
|
||||
|
||||
const resetCredentialFlow = () => {
|
||||
setApiKey("");
|
||||
setCredentialFlow("idle");
|
||||
};
|
||||
|
||||
const clearDraftConnectionState = () => {
|
||||
resetCredentialFlow();
|
||||
setHeaders([]);
|
||||
setClearHeaders(false);
|
||||
setModelId("");
|
||||
setEnabled(false);
|
||||
setWebSearchEnabled(false);
|
||||
};
|
||||
|
||||
const changeProvider = (value: string | null) => {
|
||||
if (!value || !(value in assistantProviderPresets)) return;
|
||||
const nextProvider = value as AssistantProvider;
|
||||
const nextPreset = assistantProviderPresets[nextProvider];
|
||||
clearDraftConnectionState();
|
||||
setProvider(nextProvider);
|
||||
setBaseUrl(nextProvider === "homarr" ? getWorkshopAssistantProviderUrl() : nextPreset.baseUrl);
|
||||
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
|
||||
};
|
||||
|
||||
const changeBaseUrl = (value: string) => {
|
||||
if (value !== baseUrl) {
|
||||
clearDraftConnectionState();
|
||||
}
|
||||
setBaseUrl(value);
|
||||
};
|
||||
|
||||
const changeModelDiscoveryPath = (value: string) => {
|
||||
if (value !== modelDiscoveryPath) {
|
||||
setModelId("");
|
||||
setEnabled(false);
|
||||
}
|
||||
setModelDiscoveryPath(value);
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]);
|
||||
setNextHeaderId((current) => current + 1);
|
||||
setClearHeaders(false);
|
||||
};
|
||||
|
||||
const updateHeader = (id: number, field: "name" | "value", value: string) => {
|
||||
setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header)));
|
||||
};
|
||||
|
||||
const saveConnection = (clearApiKey = false) => {
|
||||
const customHeaders = isHomarrProvider
|
||||
? undefined
|
||||
: headers.length > 0
|
||||
? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value]))
|
||||
: undefined;
|
||||
updateConnection.mutate({
|
||||
provider,
|
||||
baseUrl,
|
||||
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
|
||||
apiKey: clearApiKey || isHomarrProvider ? undefined : apiKey.trim() || undefined,
|
||||
clearApiKey: clearApiKey || isHomarrProvider,
|
||||
customHeaders,
|
||||
clearCustomHeaders: clearHeaders || isHomarrProvider,
|
||||
});
|
||||
};
|
||||
|
||||
const saveAssistantConfiguration = () => {
|
||||
if (!canSaveConfiguration) return;
|
||||
saveConfiguration.mutate({
|
||||
enabled,
|
||||
modelId: modelId.trim(),
|
||||
webSearchEnabled: canUseOpenRouterServerTools && webSearchEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ConfigurationSkeleton label={t("loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack className={classes.configuration} gap="md">
|
||||
<Paper className={classes.summary} withBorder p="md" radius="md" pos="relative">
|
||||
<LoadingOverlay visible={pending} />
|
||||
<Group justify="space-between" align="center">
|
||||
<Group className={classes.summaryProvider} gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="default" size="lg" radius="md">
|
||||
<ProviderIcon providerId={provider} size={22} />
|
||||
</ThemeIcon>
|
||||
<Box className={classes.summaryProviderText}>
|
||||
<Group gap="xs">
|
||||
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={connectionChanged ? "yellow" : connectionReady ? "green" : "gray"}
|
||||
>
|
||||
{connectionChanged
|
||||
? t("overview.unsavedChanges")
|
||||
: connectionReady
|
||||
? t("overview.connectionReady")
|
||||
: t("overview.setupRequired")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text className={classes.summaryEndpoint} size="sm" c="dimmed" truncate>
|
||||
{configuration?.modelId ?? t("overview.noModel")} · {normalizedBaseUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group className={classes.summaryActions} gap="xs">
|
||||
{configuration?.webSearchEnabled && (
|
||||
<Badge variant="light" leftSection={<IconSearch size={12} />}>
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Badge>
|
||||
)}
|
||||
{configuration?.enabled === false && (
|
||||
<Badge variant="filled" color="gray">
|
||||
{t("overview.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Group gap="xs" align="flex-start" wrap="nowrap">
|
||||
<IconShieldCheck size={18} color="var(--mantine-color-blue-6)" aria-hidden />
|
||||
<Text className={classes.securityNote} size="sm" c="dimmed">
|
||||
{t("security.description")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<ConfigurationSection
|
||||
number={1}
|
||||
title={t("connection.endpointTitle")}
|
||||
description={t("connection.description")}
|
||||
status={
|
||||
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
|
||||
{t(`provider.options.${provider}.label`)}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t("provider.title")}
|
||||
description={t(`provider.options.${provider}.description`)}
|
||||
value={provider}
|
||||
onChange={changeProvider}
|
||||
data={providerOptions}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
leftSection={<ProviderIcon providerId={provider} />}
|
||||
renderOption={({ option }) => (
|
||||
<Group gap="sm" wrap="nowrap" align="center" w="100%">
|
||||
<ProviderIcon providerId={option.value as AssistantProvider} />
|
||||
<Box flex={1} miw={0}>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("baseUrl.title")}
|
||||
description={t("baseUrl.description")}
|
||||
leftSection={<IconWorld size={16} />}
|
||||
value={baseUrl}
|
||||
disabled={isHomarrProvider}
|
||||
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
|
||||
placeholder="https://provider.example/v1"
|
||||
/>
|
||||
<TextInput
|
||||
label={t("model.discoveryPath")}
|
||||
description={t("model.discoveryPathDescription")}
|
||||
value={modelDiscoveryPath}
|
||||
disabled={isHomarrProvider}
|
||||
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
||||
placeholder="/models"
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
number={2}
|
||||
title={t("credentials.title")}
|
||||
description={t("credentials.description")}
|
||||
status={
|
||||
isHomarrProvider ? (
|
||||
<Badge variant="light" color="blue">
|
||||
{t("credentials.workshop")}
|
||||
</Badge>
|
||||
) : hasStoredApiKey ? undefined : (
|
||||
<Badge variant="light" color={preset.requiresApiKey ? "yellow" : "gray"}>
|
||||
{preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Group justify="flex-end">
|
||||
{credentialFlow === "idle" && (
|
||||
<Button
|
||||
leftSection={<IconCheck size={16} />}
|
||||
onClick={() => saveConnection()}
|
||||
disabled={!connectionValid}
|
||||
loading={updateConnection.isPending}
|
||||
>
|
||||
{t("connection.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{isHomarrProvider ? (
|
||||
<Alert color="blue" icon={<IconBrandGithub size={18} />} title={t("provider.homarrAuth.title")}>
|
||||
{t("provider.homarrAuth.description")}
|
||||
</Alert>
|
||||
) : hasStoredApiKey && credentialFlow === "idle" ? (
|
||||
<Group className={classes.credentialSummary} justify="space-between" align="center">
|
||||
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color="teal" variant="light" radius="xl">
|
||||
<IconShieldLock size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} size="sm">
|
||||
{t("apiKey.savedTitle")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("apiKey.savedDescription")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group className={classes.credentialActions} gap="xs">
|
||||
<Button variant="subtle" color="red" onClick={() => setCredentialFlow("remove")}>
|
||||
{t("apiKey.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
) : credentialFlow === "remove" ? (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("apiKey.removeFlow.title")}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{preset.requiresApiKey
|
||||
? t("apiKey.removeFlow.description")
|
||||
: t("apiKey.removeFlow.optionalDescription")}
|
||||
</Text>
|
||||
<Box className={classes.flowActions}>
|
||||
<Button variant="default" onClick={resetCredentialFlow}>
|
||||
{t("apiKey.removeFlow.cancel")}
|
||||
</Button>
|
||||
<Button color="red" loading={updateConnection.isPending} onClick={() => saveConnection(true)}>
|
||||
{t("apiKey.removeFlow.confirm")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<PasswordInput
|
||||
label={t("apiKey.title")}
|
||||
description={preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")}
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.currentTarget.value)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
placeholder={t("apiKey.placeholder")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isHomarrProvider && (
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="headers">
|
||||
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("headers.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("headers.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
{configuration?.customHeadersConfigured && (
|
||||
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
|
||||
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={addHeader}
|
||||
>
|
||||
{t("headers.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
{headers.map((header) => (
|
||||
<Group key={header.id} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw="12rem"
|
||||
label={t("headers.name")}
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
|
||||
placeholder="X-Provider-Header"
|
||||
/>
|
||||
<PasswordInput
|
||||
flex={2}
|
||||
miw="12rem"
|
||||
label={t("headers.value")}
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
|
||||
placeholder={t("headers.valuePlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Tooltip label={t("headers.remove")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("headers.remove")}
|
||||
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
|
||||
>
|
||||
<IconTrash size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
))}
|
||||
{configuration?.customHeadersConfigured && headers.length === 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color={clearHeaders ? "gray" : "red"}
|
||||
onClick={() => setClearHeaders(!clearHeaders)}
|
||||
>
|
||||
{clearHeaders ? t("headers.keep") : t("headers.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
number={3}
|
||||
title={t("model.title")}
|
||||
description={t("model.description")}
|
||||
status={
|
||||
!configuration?.modelId ? (
|
||||
<Badge variant="light" color="gray">
|
||||
{t("overview.noModel")}
|
||||
</Badge>
|
||||
) : configuration.enabled ? undefined : (
|
||||
<Badge variant="light" color="gray">
|
||||
{t("overview.disabled")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={saveAssistantConfiguration}
|
||||
disabled={!canSaveConfiguration}
|
||||
loading={saveConfiguration.isPending}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{connectionPending ? t("model.saveConnectionFirst") : t("model.chooseModel")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<IconDatabaseSearch size={14} />}
|
||||
loading={isDiscovering}
|
||||
disabled={modelControlsDisabled || configuration?.modelDiscoveryPath === null}
|
||||
onClick={() => void discoverModels()}
|
||||
>
|
||||
{t("model.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
{hasDiscoveredModels ? (
|
||||
<Select
|
||||
label={t("model.title")}
|
||||
value={modelId || null}
|
||||
onChange={(value) => setModelId(value ?? "")}
|
||||
data={modelControlsDisabled ? [] : modelOptions}
|
||||
disabled={modelControlsDisabled}
|
||||
placeholder={t("model.placeholder")}
|
||||
description={t("model.discovered", { count: models?.length ?? 0 })}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
limit={100}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label={t("model.title")}
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.currentTarget.value)}
|
||||
disabled={modelControlsDisabled}
|
||||
placeholder={t("model.placeholder")}
|
||||
description={
|
||||
connectionPending
|
||||
? t("model.saveConnectionFirst")
|
||||
: isDiscovering
|
||||
? t("model.discovering")
|
||||
: discoveryError || models?.length === 0
|
||||
? t("model.manualFallback")
|
||||
: t("model.manual")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{discoveryError && !connectionPending && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
|
||||
{discoveryError.message}
|
||||
</Alert>
|
||||
)}
|
||||
{selectedModel && (
|
||||
<Group className={classes.selectedModel} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box className={classes.selectedModelText}>
|
||||
<Text fw={600}>{selectedModel.name}</Text>
|
||||
{selectedModel.description && (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{selectedModel.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
|
||||
{selectedModel.contextLength && (
|
||||
<Badge variant="light">
|
||||
{t("model.context", { count: formatAssistantContextWindow(selectedModel.contextLength) })}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
|
||||
leftSection={<IconTool size={12} />}
|
||||
>
|
||||
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{canUseOpenRouterServerTools && (
|
||||
<Stack gap="xs">
|
||||
<Divider label={t("serverTools.title")} labelPosition="left" />
|
||||
<Group className={classes.capabilityRow} justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="xl" size="md">
|
||||
<IconSearch size={17} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{t("serverTools.beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" maw="66ch">
|
||||
{provider === "custom"
|
||||
? t("serverTools.webSearch.proxyDescription")
|
||||
: t("serverTools.webSearch.description")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t("serverTools.webSearch.limit")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={webSearchEnabled}
|
||||
onChange={(event) => setWebSearchEnabled(event.currentTarget.checked)}
|
||||
disabled={modelControlsDisabled}
|
||||
aria-label={t("serverTools.webSearch.label")}
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group className={classes.enableRow} justify="space-between" align="center" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("enabled.label")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("enabled.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.currentTarget.checked)}
|
||||
disabled={!canSaveConfiguration}
|
||||
aria-label={t("enabled.label")}
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
</ConfigurationSection>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { AssistantConfiguration } from "./_components/assistant-configuration";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
return {
|
||||
title: `${t("title")} • Homarr`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AssistantPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.settings.section.assistant");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")}>
|
||||
<Text c="dimmed" maw="70ch">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<AssistantConfiguration />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||
import { IconCopy, IconRobot } from "@tabler/icons-react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
@@ -23,68 +21,31 @@ export const CopyAiPromptButton = ({
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
|
||||
|
||||
const handleAssistant = () => {
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const value = prompt();
|
||||
await navigator.clipboard.writeText(value);
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyAiPrompt"),
|
||||
message: t("notification.aiPromptCopiedWithCount", { count: value.length }),
|
||||
message: t("notification.aiPromptCopiedWithCount", { count: prompt.length }),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
|
||||
}
|
||||
};
|
||||
|
||||
if (!assistant?.enabled) {
|
||||
return (
|
||||
<Tooltip label={t("action.copyAiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.copyAiPrompt")}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Tooltip label={t("action.aiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.aiPrompt")}
|
||||
onClick={handleAssistant}
|
||||
disabled={!request?.trim()}
|
||||
>
|
||||
<IconRobot size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("action.copyAiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.copyAiPrompt")}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Tooltip label={t("action.copyAiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.copyAiPrompt")}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -144,6 +144,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
key={id}
|
||||
component="a"
|
||||
href={`#${id}`}
|
||||
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color={invalidSections.has(id) ? "red" : undefined}
|
||||
|
||||
+8
-1
@@ -22,6 +22,7 @@ vi.mock("@homarr/api/client", () => ({
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
|
||||
const rendererAttempt = vi.hoisted(() => vi.fn());
|
||||
const codeEditorProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
default: ({ data }: { data: Record<string, unknown> }) => {
|
||||
@@ -32,7 +33,12 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
return <div>widget-renderer</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
|
||||
vi.mock("./_code-editor", () => ({
|
||||
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
|
||||
codeEditorProps(props);
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
|
||||
|
||||
let root: Root;
|
||||
@@ -109,6 +115,7 @@ describe("Custom Widget preview panel", () => {
|
||||
);
|
||||
await act(async () => dataTab?.click());
|
||||
expect(host.textContent).toContain("requestData");
|
||||
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
|
||||
@@ -172,6 +172,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
label={t("requestData")}
|
||||
language="json"
|
||||
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
@@ -251,6 +252,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
label={t("journal")}
|
||||
language="json"
|
||||
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+3
-1
@@ -87,7 +87,9 @@ export function CustomWidgetSourcesEditor({
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
|
||||
};
|
||||
const addSource = () => {
|
||||
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
|
||||
let suffix = sources.length + 1;
|
||||
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
|
||||
const id = sources.length === 0 ? "default" : `source-${suffix}`;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
|
||||
Loaded 100 of 512 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user