feat(custom-widgets): add secure v2 authoring
This commit is contained in:
56 files changed
+12613
-1726
No files matched your search
@@ -5,43 +5,47 @@ tags:
|
||||
- Help
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
### Can I install Homarr on a Raspberry Pi?
|
||||
|
||||
Yes, Homarr is compatible with the architecture of the Raspberry Pi. Have a look in our <a href="/docs/category/installation-1">installation guide</a> on how to do so.
|
||||
|
||||
### I have a suggestion / idea for improvement or want to report a bug
|
||||
|
||||
You can report issues, suggestions, or ideas for improvements <a href="https://github.com/homarr-labs/homarr/issues/new/choose">here</a>.
|
||||
|
||||
### Can I use my own custom icons for apps?
|
||||
|
||||
Yes, check out our guide on <a href="/docs/advanced/customizations/icons">how to add your custom icons</a>.
|
||||
|
||||
### Some of my online icons are not working (anymore)
|
||||
|
||||
It seems the `cdn.jsdelivr.net` database of icon sometimes appear in the lists for DNS-Holes (PiHole, AdGuard Home...). <br />
|
||||
So make sure to manually whitelist this address.
|
||||
|
||||
### Can I add my own widgets? Do I need programming skills?
|
||||
We have decided, not to support custom widgets, until Homarr is in a stable and highly polished state.
|
||||
Enabling users to write their own widgets is easy at the first glance, but there are some major issues with it:
|
||||
|
||||
- There are mainly two options for adding plugins or custom widgets to Homarr. The first option is, to allow and inject custom code in Homarr. Apps like Nextcloud can do this easily, because they use the Runtime to render all components of the application (In reality, the process is still a bit more complicated). In contrast, Homarr is using Next.js - a framework that needs to be compiled in production. That means, that each time you would modify a custom widget or plugin, the app would have to be restarted. There are options to avoid this, but they are quite flakey and bring disadvantages in our experience. The second option is, to write widgets declaratively instead of imperatively. This way, we would be able to render and modify widgets without any restarting at all. However, we'd have to write our own interpreter for said declarative schema. This is totally possible, but requires a lot of planning and architecture design to function properly. The required work to implement either option is therefore quite substantial.
|
||||
- Custom widgets would need to be able to request data, so they work properly. We use tRPC for this. Many APIs use different formats and we'd have to convert and read those.
|
||||
- Using custom widgets, one could easily create harmful widgets, leak your personal data (potentially including your tokens) and trick your users. A proper implementation (eg. official plugin store, where we can ban harmful widgets from) would resolve this problem to a certain extent, but again requires additional work and time to be put in.
|
||||
Yes. Administrators can create [Custom Widgets](/docs/management/custom-widgets) that read an HTTP API and render the response with a built-in display type. These display types require no programming.
|
||||
|
||||
If you still want to add a widget, please consider to contribute it directly to Homarr. For that, basic programming skills are required. Our [Developer Guides](/docs/advanced/development/getting-started) explain this even more detailed.
|
||||
Custom JSX is available when you need a more specific layout or an interactive control. It uses a restricted declarative JSX interpreter with a classified Mantine component set. Network operations are declared as named, same-origin requests and are checked against board permissions on the server. It does not execute arbitrary JavaScript or expose browser `fetch`.
|
||||
|
||||
You should be comfortable reading JSON and JSX to author complex templates. The management workbench includes examples, diagnostics, a component reference, API response inspection, and a prompt you can copy into an AI assistant. Always review imported templates and request capabilities before saving them.
|
||||
|
||||
### Is it secure to expose Homarr to the web?
|
||||
|
||||
Yes, since v0.14.0, we are confident that Homarr is secure enough to be exposed to the web.
|
||||
Yes, since v0.14.0, we are confident that Homarr is secure enough to be exposed to the web.
|
||||
Make sure to enable the "allow anonymous access" [option in the settings](/docs/management/boards#access-control), if you want to allow people without an account to be able to see your dashboard (not edit)
|
||||
|
||||
### How do I open the console / log?
|
||||
|
||||
There are two different sources of information:
|
||||
|
||||
- console: This is your client (browser). It can tell us, if there is an issue on the client (crash, bug, ...).
|
||||
- log: This is your server. It will tell us, if there are issues with connections for the integrations, crashes and errors.
|
||||
|
||||
Both variants *can* contain personal data. It's recommended, that you don't post them publicly or remove personal data from it.
|
||||
Both variants _can_ contain personal data. It's recommended, that you don't post them publicly or remove personal data from it.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="apple" label="Read console" default>
|
||||
@@ -49,39 +53,45 @@ Both variants *can* contain personal data. It's recommended, that you don't post
|
||||
> Note: This will not work on most mobile browsers.
|
||||
|
||||
Open your browser and ensure, that Homarr is your current tab.
|
||||
Press ``CTRL`` + ``SHIFT`` + ``I`` on your keyboard. If this shortcut doesn't work, please use one of the alternatives: https://developer.chrome.com/docs/devtools/open/.
|
||||
Click on ``Console`` in the new window, that opened. There, you should see white, orange and sometimes red messages. You can use the Windows Snip & Sketch tool (or your respective tool on Linux / MacOS) to screenshot those messages.
|
||||
Press `CTRL` + `SHIFT` + `I` on your keyboard. If this shortcut doesn't work, please use one of the alternatives: https://developer.chrome.com/docs/devtools/open/.
|
||||
Click on `Console` in the new window, that opened. There, you should see white, orange and sometimes red messages. You can use the Windows Snip & Sketch tool (or your respective tool on Linux / MacOS) to screenshot those messages.
|
||||
|
||||
> Please never execute any commands in the console. Scammers are using the console to let you execute harmful commands. If you want to resolve an issue in Homarr, **you'll never have to execute any commands here**
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="orange" label="Read log">
|
||||
|
||||
This depends on your installation method. If you use Unraid, you can simply click on the App and on ``Log``. If you have a bare Docker install, use ``docker container logs homarr``. If you used a different name than ``homarr`` for your container, you need to replace said argument. Here is some additional information about the command: https://geekflare.com/check-docker-logs/.
|
||||
This depends on your installation method. If you use Unraid, you can simply click on the App and on `Log`. If you have a bare Docker install, use `docker container logs homarr`. If you used a different name than `homarr` for your container, you need to replace said argument. Here is some additional information about the command: https://geekflare.com/check-docker-logs/.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### How do I post logs?
|
||||
|
||||
Please check [the section above](#how-do-i-open-the-console--log), on how to obtain logs. Logs should always be posted to https://pastebin.com/ or https://rentry.co/.
|
||||
|
||||
### Can I use Homarr at my company?
|
||||
|
||||
Yes, you can! But please consider a donation, if our work has been useful to you.
|
||||
|
||||
### I think I found a bug. What can I do?
|
||||
|
||||
If you think, that you found a bug, please create an issue <a href="https://github.com/homarr-labs/homarr/issues/new/choose">here</a>.
|
||||
|
||||
### Why can't I use my custom icons?
|
||||
|
||||
You need to restart the Homarr Docker container after adding icons.
|
||||
|
||||
### Why can't homarr see/ping containers running locally behind GluetunVPN?
|
||||
|
||||
This is because GluetunVPN, and many other docker VPN services, secure their VPN connections with strict firewalls. These firewalls often block access to local containers, even on the same docker network. The workaround for <a href="https://github.com/qdm12/gluetun-wiki/blob/main/setup/options/firewall.md">GluetunVPN</a> is to add "- FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24" into the environmental variables of the docker compose file. If you are using a firewall on your system, you will need to add a rule that allows requests from ip's in the range of 192.168.xxx.xxx . If you are using UFW the command would be "sudo ufw allow from 192.168.0.0/16".
|
||||
Tip: If you are running your torrent client behind a VPN, and running Sonarr (or something similar) normally, make sure to open your torrent client's port in your firewall. If you don't, Sonarr will not be able to see it. Furthermore, use your machines IP (192.168.x.x) rather then 127.0.0.1 or localhost.
|
||||
|
||||
### Can I get in touch with you?
|
||||
|
||||
Yes, go <a href="/docs/community/get-in-touch">here</a> to get with us in touch.
|
||||
|
||||
<hr/>
|
||||
<hr />
|
||||
|
||||
:::tip
|
||||
|
||||
|
||||
@@ -6,203 +6,188 @@ tags:
|
||||
- JSX
|
||||
---
|
||||
|
||||
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
|
||||
|
||||
# Custom Widgets
|
||||
|
||||
:::warning
|
||||
Custom widgets connect a dashboard item to an HTTP API without compiling a new Homarr widget. Administrators choose the endpoint and authentication, then use a built-in display type or a Custom JSX template to render the response.
|
||||
|
||||
Custom widgets are a new feature. Share feedback, ask questions, and discuss ideas in our [GitHub Discussions](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets).
|
||||
:::caution
|
||||
|
||||
A custom widget can expose data or perform actions with the credentials configured for it. Import definitions only from sources you trust and review every network capability before using the widget on a shared board.
|
||||
|
||||
:::
|
||||
|
||||
Custom widgets allow you to fetch data from any HTTP API and display it on your Homarr dashboard. You define the API endpoint, authentication, and how the response should be rendered - no code compilation or server restart required.
|
||||
## Create a widget
|
||||
|
||||
## Creating a Custom Widget
|
||||
Open **Management → Custom Widgets**, select **Add**, and configure:
|
||||
|
||||
To create a custom widget, navigate to the **Custom Widgets** section in the management sidebar and click the **Add** button. Fill in the configuration:
|
||||
- **Name, description, and icon**: identify the widget in management and the widget picker.
|
||||
- **URL**: the full HTTP or HTTPS base endpoint.
|
||||
- **Authentication**: none, bearer token, basic authentication, API-key header, or API-key query parameter.
|
||||
- **Display type**: a built-in renderer or Custom JSX.
|
||||
|
||||
- **Name** - Display name shown in the widget picker and on the dashboard
|
||||
- **Description** - Optional description shown in tooltips and management UI
|
||||
- **Icon URL** - Optional URL to an icon image for the widget
|
||||
- **URL** - The API endpoint to fetch data from (must include protocol, e.g. `https://api.example.com/stats`)
|
||||
- **Authentication** - How the API request is authenticated:
|
||||
- `none` - Public APIs with no authentication
|
||||
- `bearer` - Bearer token authentication
|
||||
- `basic` - HTTP Basic auth (username + password)
|
||||
- `apiKeyHeader` - API key sent as a custom HTTP header
|
||||
- `apiKeyQuery` - API key sent as a query parameter
|
||||
- **Header Name** - Custom header or query parameter name for API key auth (e.g. `X-API-Key`)
|
||||
- **HTTP Method** - `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`
|
||||
- **Request Body** - JSON string body for `POST`/`PUT`/`PATCH` requests
|
||||
- **Display Type** - How the API response is rendered (see below)
|
||||
Use **Test** to run the current GET request. The preview workbench separates the rendered widget, sanitized response tree, redacted request journal, and diagnostics. It marks sample data as stale whenever the connection or display configuration changes after a test.
|
||||
|
||||
## Display Types
|
||||
Testing creates a five-minute, administrator-bound preview session for unsaved named queries. Action controls are simulated by default. An administrator can explicitly enable live actions for that session; DELETE still requires confirmation.
|
||||
|
||||
| Display Type | Description | Best For |
|
||||
| -------------------- | ---------------------------------------------------- | ---------------------------------------- |
|
||||
| **Single Value** | Extracts a single value with a label and unit | Metrics: temperature, uptime, user count |
|
||||
| **Key Value** | Multiple labeled key-value pairs with optional units | System stats: CPU, memory, disk |
|
||||
| **Table** | API array data rendered as a sortable table | Lists of items with columns |
|
||||
| **Stat Grid** | Labeled values in colored stat cards | Counts: movies, series, albums |
|
||||
| **Progress Bars** | Values as progress bars with percentages | Usage: disk, quota, capacity |
|
||||
| **Status Indicator** | Green/red status dots for health checks | Service monitoring |
|
||||
| **Count Grid** | Simple counts in a grid (no colors) | Library counts |
|
||||
| **Raw** | Pretty-prints the raw JSON response | Debugging |
|
||||
| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache |
|
||||
| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control |
|
||||
## Display types
|
||||
|
||||
### AI-Powered JSX Generation
|
||||
| Display type | Use it for |
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| Single Value | One metric with a label and unit |
|
||||
| Key Value | Several labeled values |
|
||||
| Table | Arrays of records |
|
||||
| Stat Grid | Compact colored metrics |
|
||||
| Progress Bars | Capacity, quota, and completion |
|
||||
| Status Indicator | Health and availability |
|
||||
| Count Grid | Compact counters |
|
||||
| Raw | Sanitized JSON inspection |
|
||||
| Action Button | One explicit API action |
|
||||
| Custom JSX | A declarative Mantine layout with optional named requests |
|
||||
|
||||
When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data.
|
||||
## Custom JSX workbench
|
||||
|
||||
<details>
|
||||
<summary>Example: Pokédex widget using Custom JSX</summary>
|
||||
The JSX editor provides syntax highlighting, formatting, undo/redo buttons and Ctrl/Cmd+Z shortcuts, a searchable component reference, character and cursor status, and diagnostics. Errors block saving; warnings identify behavior that still receives server-side validation. Templates can contain up to 50,000 characters and render up to 10,000 nodes.
|
||||
|
||||
The template reads the base GET response from `data`:
|
||||
|
||||
```jsx
|
||||
<Stack gap="xs" p="sm">
|
||||
<Text fw={600}>{data.name}</Text>
|
||||
<Badge color={data.online ? "green" : "red"}>{data.status}</Badge>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
The **Copy AI prompt** action is available on desktop and mobile. Its prompt, component list, and examples are generated from the same metadata used by the renderer, so the reference stays aligned with the installed Homarr version.
|
||||
|
||||
### JSX API versions
|
||||
|
||||
- **Version 2** is the default. Network access is declared in a named-request manifest and enforced by the server.
|
||||
- **Version 1** remains available for display-only templates. Inline network components are disabled until the definition is reviewed and migrated.
|
||||
|
||||
Definitions without `jsxApiVersion` are treated as version 1. Display-only templates can be migrated by selecting version 2 and saving an empty request list.
|
||||
|
||||
## Named requests
|
||||
|
||||
Version 2 templates never choose an arbitrary URL, method, header, or body at runtime. An administrator declares each allowed operation once, and the template references its `requestId` with typed parameters.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "homarr-custom-widget-v2",
|
||||
"name": "Pokédex",
|
||||
"description": "Browseable Pokémon list",
|
||||
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
|
||||
"url": "https://pokeapi.co/api/v2/pokemon?limit=75",
|
||||
"authType": "none",
|
||||
"method": "GET",
|
||||
"displayType": "customJsx",
|
||||
"displayConfig": {
|
||||
"type": "customJsx",
|
||||
"template": "<Stack gap=\"md\" p=\"xs\">\n <Card withBorder radius=\"xl\" p=\"md\" shadow=\"md\">\n <Group justify=\"space-between\">\n <Title order={3}>Pokédex</Title>\n </Group>\n </Card>\n <PaginatedList pageSize={12}>\n <Grid gutter=\"sm\">\n {data.results.map((pokemon, i) =>\n <Grid.Col span={1.5}>\n <Anchor href={pokemon.url} target=\"_blank\" underline=\"never\">\n <Card withBorder radius=\"xl\" p=\"xs\" shadow=\"md\">\n <Stack gap=\"sm\" align=\"center\">\n <Group justify=\"space-between\" style={{ width: \"100%\" }}>\n <Badge size=\"sm\" color=\"red\">#{i + 1}</Badge>\n <Text fw={800} tt=\"capitalize\">{pokemon.name}</Text>\n </Group>\n <Avatar\n src={\"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/\" + (i + 1) + \".png\"}\n size={92}\n radius=\"xl\"\n />\n </Stack>\n </Card>\n </Anchor>\n </Grid.Col>\n )}\n </Grid>\n </PaginatedList>\n</Stack>"
|
||||
}
|
||||
"type": "customJsx",
|
||||
"jsxApiVersion": 2,
|
||||
"networkScope": "public",
|
||||
"template": "<SubFetch requestId=\"service-detail\" params={{ id: data.id }}><SubData path=\"status\" as=\"Badge\" /></SubFetch>",
|
||||
"requests": [
|
||||
{
|
||||
"id": "service-detail",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/api/services/{id}",
|
||||
"parameters": { "id": "string" },
|
||||
"auth": "inherit",
|
||||
"minimumBoardPermission": "view",
|
||||
"cacheTtlSeconds": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
Path placeholders use `{parameter}` and are URL-encoded by the server. JSON body placeholders use `{ "$param": "parameter" }` and preserve the declared string, number, or boolean type.
|
||||
|
||||
## Importing and Exporting
|
||||
`SubData` remains available for simple paths. For richer nested output, pass an interpreter-created render child; it receives the response data and safe status metadata:
|
||||
|
||||
Custom widgets can be exported and imported as JSON files. To export, select the widget in the list and click the export button. To import, click the import button and paste the JSON. Secrets (API keys, passwords) are never included in exports and must be configured after import.
|
||||
```jsx
|
||||
<SubFetch requestId="service-detail" params={{ id: data.id }}>
|
||||
{(detail, meta) => (
|
||||
<Stack gap="xs">
|
||||
<Badge color={detail.online ? "green" : "red"}>{detail.status}</Badge>
|
||||
<Text size="xs">HTTP {meta.status}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</SubFetch>
|
||||
```
|
||||
|
||||
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
|
||||
| Component | Request kind | Behavior |
|
||||
| --------------- | ------------ | ------------------------------------------------------------- |
|
||||
| `SubFetch` | `query` | Loads a named GET request automatically or manually |
|
||||
| `ActionButton` | `action` | Runs a named POST, PUT, PATCH, or DELETE after a user gesture |
|
||||
| `ToggleSwitch` | `action` | Passes declared on/off parameters and rolls back on failure |
|
||||
| `RefreshButton` | None | Refreshes the parent GET response |
|
||||
|
||||
## Available Mantine Components for Custom JSX
|
||||
Queries require board **view** permission. POST, PUT, and PATCH actions require **modify** by default. DELETE requires **full** permission and confirmation. Allowing viewers to execute a non-DELETE action requires explicit administrator approval. Anonymous viewers cannot execute actions.
|
||||
|
||||
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
|
||||
### Network scopes
|
||||
|
||||
### Layout
|
||||
| Scope | Allowed destinations |
|
||||
| ---------- | ------------------------------------------------------------ |
|
||||
| `public` | Public IP addresses only |
|
||||
| `private` | Public addresses plus RFC1918 and IPv6 ULA private addresses |
|
||||
| `loopback` | Public, private, and loopback addresses |
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| `Stack` | [docs](https://mantine.dev/core/stack) |
|
||||
| `Group` | [docs](https://mantine.dev/core/group) |
|
||||
| `Flex` | [docs](https://mantine.dev/core/flex) |
|
||||
| `Grid` / `Grid.Col` | [docs](https://mantine.dev/core/grid) |
|
||||
| `SimpleGrid` | [docs](https://mantine.dev/core/simple-grid) |
|
||||
| `Center` | [docs](https://mantine.dev/core/center) |
|
||||
| `Container` | [docs](https://mantine.dev/core/container) |
|
||||
| `Space` | [docs](https://mantine.dev/core/space) |
|
||||
| `AspectRatio` | [docs](https://mantine.dev/core/aspect-ratio) |
|
||||
Link-local, cloud-metadata, unspecified, multicast, documentation, carrier-grade NAT, and IPv4-mapped bypass addresses remain blocked in every scope.
|
||||
|
||||
### Typography & Content
|
||||
### Example with a named query
|
||||
|
||||
| Component | Doc |
|
||||
| ----------------- | ------------------------------------------------- |
|
||||
| `Text` | [docs](https://mantine.dev/core/text) |
|
||||
| `Title` | [docs](https://mantine.dev/core/title) |
|
||||
| `Code` | [docs](https://mantine.dev/core/code) |
|
||||
| `Highlight` | [docs](https://mantine.dev/core/highlight) |
|
||||
| `Mark` | [docs](https://mantine.dev/core/mark) |
|
||||
| `Kbd` | [docs](https://mantine.dev/core/kbd) |
|
||||
| `Blockquote` | [docs](https://mantine.dev/core/blockquote) |
|
||||
| `Anchor` | [docs](https://mantine.dev/core/anchor) |
|
||||
| `NumberFormatter` | [docs](https://mantine.dev/core/number-formatter) |
|
||||
The editor and AI prompt include a shared Pokédex fixture that loads details only for the visible page. Its named request is equivalent to:
|
||||
|
||||
### Data Display
|
||||
```json
|
||||
{
|
||||
"id": "pokemon-detail",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/api/v2/pokemon/{name}",
|
||||
"parameters": { "name": "string" },
|
||||
"auth": "none",
|
||||
"minimumBoardPermission": "view",
|
||||
"cacheTtlSeconds": 300
|
||||
}
|
||||
```
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------------- | ------------------------------------------------- |
|
||||
| `Badge` | [docs](https://mantine.dev/core/badge) |
|
||||
| `Card` / `Card.Section` | [docs](https://mantine.dev/core/card) |
|
||||
| `Paper` | [docs](https://mantine.dev/core/paper) |
|
||||
| `Alert` | [docs](https://mantine.dev/core/alert) |
|
||||
| `ThemeIcon` | [docs](https://mantine.dev/core/theme-icon) |
|
||||
| `ColorSwatch` | [docs](https://mantine.dev/core/color-swatch) |
|
||||
| `Avatar` / `Avatar.Group` | [docs](https://mantine.dev/core/avatar) |
|
||||
| `Image` | [docs](https://mantine.dev/core/image) |
|
||||
| `BackgroundImage` | [docs](https://mantine.dev/core/background-image) |
|
||||
| `Indicator` | [docs](https://mantine.dev/core/indicator) |
|
||||
| `Pill` | [docs](https://mantine.dev/core/pill) |
|
||||
| `Spoiler` | [docs](https://mantine.dev/core/spoiler) |
|
||||
## Agent authoring through MCP
|
||||
|
||||
### Tables
|
||||
Administrators can use Homarr's MCP server to iterate on custom widgets without bypassing validation or network controls. A typical agent workflow is:
|
||||
|
||||
| Component | Doc |
|
||||
| --------------------------------------- | -------------------------------------- |
|
||||
| `Table` / `Table.Thead` / `Table.Tbody` | [docs](https://mantine.dev/core/table) |
|
||||
| `Table.Tr` / `Table.Th` / `Table.Td` | [docs](https://mantine.dev/core/table) |
|
||||
1. Call `customWidget_schema` to inspect the current import format.
|
||||
2. Generate a complete `homarr-custom-widget-v3` draft.
|
||||
3. Call `customWidget_validate` and correct every returned issue.
|
||||
4. Call `customWidget_preview` to test the base GET request through Homarr's hardened executor.
|
||||
5. Use `customWidget_previewQuery` for named queries and `customWidget_simulatePreviewAction` to verify action parameters without sending mutations.
|
||||
6. Inspect `customWidget_previewJournal`, then validate again.
|
||||
7. Call `customWidget_import`, or read an existing widget with `customWidget_byId` and apply a focused `customWidget_update`.
|
||||
|
||||
### Lists
|
||||
MCP preview actions are simulation-only. Enabling live preview actions remains an explicit administrator choice in the browser workbench. Stored secret values are never returned by MCP; omit `secrets` when updating an existing widget to preserve them.
|
||||
|
||||
| Component | Doc |
|
||||
| ---------------------------- | ----------------------------------------- |
|
||||
| `List` / `List.Item` | [docs](https://mantine.dev/core/list) |
|
||||
| `Timeline` / `Timeline.Item` | [docs](https://mantine.dev/core/timeline) |
|
||||
## Component reference
|
||||
|
||||
### Feedback & Loading
|
||||
Custom JSX exposes a classified subset of Mantine Core, Charts, Dates, and Homarr components. Allowed components render directly. Wrapped components receive safe props or read-only behavior. Denied components remain in the registry so dependency upgrades cannot silently expand the template surface.
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------------------- | ---------------------------------------------- |
|
||||
| `Progress` / `Progress.Section` | [docs](https://mantine.dev/core/progress) |
|
||||
| `RingProgress` | [docs](https://mantine.dev/core/ring-progress) |
|
||||
| `Skeleton` | [docs](https://mantine.dev/core/skeleton) |
|
||||
| `Loader` | [docs](https://mantine.dev/core/loader) |
|
||||
<CustomJsxComponentReference />
|
||||
|
||||
### Navigation & Overlay
|
||||
This table and the searchable component list in the editor are generated from Homarr's runtime registry.
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------------------------------------------------------------ | -------------------------------------------- |
|
||||
| `TabsContainer` / `TabPanel` | Custom interactive component |
|
||||
| `Accordion` / `Accordion.Item` / `Accordion.Control` / `Accordion.Panel` | [docs](https://mantine.dev/core/accordion) |
|
||||
| `ScrollArea` | [docs](https://mantine.dev/core/scroll-area) |
|
||||
| `Tooltip` | [docs](https://mantine.dev/core/tooltip) |
|
||||
Component props are filtered. Polymorphic roots, portals, refs, unsafe positioning, raw event handlers, and `dangerouslySetInnerHTML` are not exposed. Direct browser globals and direct `fetch` calls are unavailable, while ordinary text containing the word “fetch” is valid.
|
||||
|
||||
### Charts
|
||||
Safe bindings include conversion helpers, a restricted `Math` and `JSON` surface, object and array helpers, URI encoding helpers, and pure date formatting helpers. Collection callbacks and rendered output are subject to interpreter budgets.
|
||||
|
||||
| Component | Doc |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| `AreaChart` | [docs](https://mantine.dev/charts/area-chart) |
|
||||
| `BarChart` | [docs](https://mantine.dev/charts/bar-chart) |
|
||||
| `LineChart` | [docs](https://mantine.dev/charts/line-chart) |
|
||||
| `DonutChart` | [docs](https://mantine.dev/charts/donut-chart) |
|
||||
| `PieChart` | [docs](https://mantine.dev/charts/pie-chart) |
|
||||
| `RadarChart` | [docs](https://mantine.dev/charts/radar-chart) |
|
||||
| `RadialBarChart` | [docs](https://mantine.dev/charts/radial-bar-chart) |
|
||||
| `Sparkline` | [docs](https://mantine.dev/charts/sparkline) |
|
||||
## HTTP protections and limits
|
||||
|
||||
### Interactive Components
|
||||
All base, preview, query, and action requests use the same server executor:
|
||||
|
||||
| Component | Description |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| `PaginatedList` | Client-side pagination wrapper |
|
||||
| `Collapsible` | Expandable/collapsible section |
|
||||
| `StatBar` | Horizontal stat bar |
|
||||
| `TypeBadge` | Type-styled badge |
|
||||
| `Divider` | [docs](https://mantine.dev/core/divider) |
|
||||
- Only HTTP and HTTPS are accepted. URL credentials and fragments are rejected.
|
||||
- Named paths must stay on the base endpoint origin.
|
||||
- DNS results are validated against the selected network scope and pinned for the request.
|
||||
- Queries can follow up to three revalidated same-origin redirects. Actions do not follow redirects.
|
||||
- Authorization, cookie, host, forwarding, proxy, content-length, and hop-by-hop headers cannot be supplied by a template.
|
||||
- Request bodies are limited to 10 KiB. Decompressed responses are limited to 1 MiB, 32 JSON levels, and 50,000 JSON nodes.
|
||||
- GET requests can be cached and deduplicated. Actions are never cached, polled, or retried automatically.
|
||||
|
||||
## Template Bindings
|
||||
## Import and export
|
||||
|
||||
Your template receives:
|
||||
New exports use `"$schema": "homarr-custom-widget-v3"`. Version 2 imports remain accepted. Secrets are never exported and must be entered again after import.
|
||||
|
||||
- `data` - The sanitized API response object. Access fields via `data.fieldName` or `data.results[0].name`.
|
||||
- `String(v)` - Type-safe string conversion
|
||||
- `Number(v)` - Type-safe number conversion
|
||||
- `Boolean(v)` - Type-safe boolean conversion
|
||||
- `Math` - Safe subset: `round`, `floor`, `ceil`, `abs`, `min`, `max`, `pow`, `sqrt`, `PI`
|
||||
- `JSON.stringify(v)` - JSON serialization
|
||||
- `Array.isArray(v)` - Array type check
|
||||
- `Object.keys(v)`, `Object.values(v)`, `Object.entries(v)` - Object utilities
|
||||
Review the base origin, network scope, named request methods, inherited authentication, and required permissions before saving an imported widget. A version 1 template that contains inline network components stays disabled until it is converted to named requests.
|
||||
|
||||
## Security Constraints
|
||||
AI-generated Custom JSX can also be pasted as a Homarr authoring bundle: one fenced JSON block with `"template": "__HOMARR_TEMPLATE__"`, followed by one fenced JSX block containing the readable multiline template. Homarr combines the blocks before validation, avoiding JSON newline and quote escaping. Raw JSON imports remain supported.
|
||||
|
||||
- Event handlers (`onClick`, `onChange`, etc.) are stripped
|
||||
- Dangerous HTML attributes (`dangerouslySetInnerHTML`) are stripped
|
||||
- `script`, `iframe`, `object`, `embed`, `form`, `style`, `link`, `meta`, `base` tags are forbidden
|
||||
- Template strings containing `eval`, `Function`, `import`, `require`, `globalThis`, `window`, `document`, `fetch` are rejected
|
||||
- Anchor `href` attributes are validated to only allow `https://`, relative (`/`), and hash (`#`) URLs
|
||||
On the custom-widget list, paste a complete export with Ctrl+V or Command+V to open the import review and create it as a new definition. On an edit page, the same shortcut opens a replacement review and fills the existing form after confirmation. Pasted definitions never include secrets.
|
||||
@@ -73,13 +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/mcp",
|
||||
"--header",
|
||||
"ApiKey:<your-api-key>"
|
||||
]
|
||||
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:<your-api-key>"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,25 +120,26 @@ curl -s -X POST \
|
||||
|
||||
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, 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 |
|
||||
| `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.
|
||||
|
||||
@@ -154,6 +149,8 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
||||
- Tools inherit the permissions of the API key's owner
|
||||
- OAuth endpoints are rate-limited to prevent abuse
|
||||
- Tool execution errors are contained - a failing tool call does not crash the MCP server
|
||||
- Custom-widget MCP actions are simulation-only; live preview actions must be enabled explicitly in the browser workbench
|
||||
- Custom-widget secret values are never returned through MCP
|
||||
- All communication uses HTTPS (configure HTTP → HTTPS redirects on your reverse proxy)
|
||||
|
||||
## Use Cases
|
||||
@@ -165,3 +162,58 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
||||
- **"How many users are on my Homarr instance?"** - AI lists all users (admin only)
|
||||
- **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls
|
||||
- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts
|
||||
- **"Build and refine a custom widget for this API"** - AI validates the draft, tests GET data and named queries, simulates actions, and creates or updates the widget
|
||||
|
||||
## Custom Widget Workflow for AI Agents
|
||||
|
||||
AI agents can create and iterate on custom widgets using these tools in sequence.
|
||||
|
||||
:::tip Editing an existing widget?
|
||||
Skip to step 5. Use `customWidget_byId` to fetch it and `customWidget_readTemplate` / `customWidget_writeTemplate` to edit its template directly.
|
||||
:::
|
||||
|
||||
### 1. Get the schema
|
||||
|
||||
Call `customWidget_schema` to receive the JSON Schema for the `homarr-custom-widget-v3` format. This tells the agent what fields are required and their types.
|
||||
|
||||
### 2. Draft and validate
|
||||
|
||||
Build a widget JSON object and call `customWidget_validate` with it. The tool returns structured issues (path + message) so the agent can fix and re-validate until the draft is clean.
|
||||
|
||||
### 3. Create the widget
|
||||
|
||||
Call `customWidget_import` with the validated `homarr-custom-widget-v3` draft. This returns the new widget `id`. Use `customWidget_create` only when you intentionally need its lower-level creation input, including secret fields.
|
||||
|
||||
### 4. Test with real data
|
||||
|
||||
Call `customWidget_preview` with the widget's connection info. This makes a real GET request through Homarr's hardened executor and returns the sanitized response plus a preview session ID.
|
||||
|
||||
For Custom JSX v2 widgets with named requests, use `customWidget_previewQuery` to test each named GET query, and `customWidget_simulatePreviewAction` to dry-run actions without network calls.
|
||||
|
||||
### 5. Iterate on the template
|
||||
|
||||
Use `customWidget_readTemplate` to get the current JSX template as plain text, then `customWidget_writeTemplate` to push updates. The write tool accepts either:
|
||||
|
||||
- `template` — a single string (newlines as `\n`)
|
||||
- `templateLines` — an array of strings (joined with newlines server-side, avoids `\n` escaping)
|
||||
|
||||
Both options validate the template AST before saving. This avoids re-sending the full widget JSON for template-only edits.
|
||||
|
||||
For small changes, prefer `customWidget_patchTemplate`. Pass the `revision` returned by `customWidget_readTemplate` and one or more line edits. Homarr rejects stale revisions, applies all edits atomically, and validates the complete template against its named request manifest before saving.
|
||||
|
||||
### 6. Update non-template fields
|
||||
|
||||
Call `customWidget_byId` to get the full widget, modify fields, and call `customWidget_update` with only the changed fields.
|
||||
|
||||
### Available components in templates
|
||||
|
||||
Custom JSX templates support 100+ Mantine components plus:
|
||||
|
||||
- `<TablerIcon name="server" size={20} color="blue" />` — 100+ curated Tabler icons by kebab-case name (e.g. `"player-play"`, `"alert-triangle"`)
|
||||
- `<SubFetch>` / `<ActionButton>` / `<ToggleSwitch>` — network-capable components for named requests
|
||||
- Array methods: `map`, `filter`, `find`, `findIndex`, `some`, `every`, `reduce`, `sort`, `slice`, `flat`, `reverse`, `at`, `pop`, `join`, `includes`, `indexOf` (note: `pop` returns last element without mutating)
|
||||
- String methods: `toUpperCase`, `toLowerCase`, `trim`, `trimStart`, `trimEnd`, `slice`, `substring`, `includes`, `startsWith`, `endsWith`, `split`, `replace`, `replaceAll`, `repeat`, `padStart`, `padEnd`, `charAt`, `indexOf`, `lastIndexOf`
|
||||
|
||||
:::note
|
||||
All `customWidget_*` tools require admin-level API key permissions. The preview executor enforces rate limits (60 queries/min, 10 actions/min).
|
||||
:::
|
||||
@@ -1,29 +1,39 @@
|
||||
---
|
||||
title: 'Custom API'
|
||||
description: 'Displays data from a custom API endpoint using configurable display types and optional JSX templates.'
|
||||
title: "Custom API"
|
||||
description: "Displays data from an administrator-defined HTTP API using built-in renderers or safe Custom JSX."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { customApiWidget } from '.';
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { customApiWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
|
||||
|
||||
<WidgetHeader
|
||||
widget={customApiWidget}
|
||||
categories={['Custom', 'API']}
|
||||
/>
|
||||
The Custom API widget displays an API definition created by an administrator in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns the endpoint, credentials, display configuration, and any named network capabilities used by Custom JSX.
|
||||
|
||||
This widget fetches data from a custom API endpoint defined in [Custom Widgets management](/docs/management/custom-widgets) and renders it using configurable display types.
|
||||
|
||||
Custom API widgets are created and configured through the admin management page. Each widget definition specifies the API endpoint, authentication, display type, and refresh interval.
|
||||
|
||||
See the [Custom Widgets documentation](/docs/management/custom-widgets) for full details on creating and configuring custom widgets.
|
||||
|
||||
### Adding the widget
|
||||
### Add the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
Choose a custom-widget definition and a refresh interval. The board item stores only the selected definition and display options; credentials remain server-side.
|
||||
|
||||
:::note
|
||||
Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker.
|
||||
|
||||
A definition must be enabled and available to the board before it appears in the widget picker.
|
||||
|
||||
:::
|
||||
|
||||
### Permissions and actions
|
||||
|
||||
The base response and named query requests require permission to view the board. Named POST, PUT, and PATCH actions require permission to modify the board by default. DELETE requires full board permission and a confirmation. Anonymous viewers cannot execute actions.
|
||||
|
||||
Interactive controls are disabled while the board is in edit mode. Actions run only after an explicit user gesture and are never triggered by mounting, refresh polling, or automatic retries. Authoring previews simulate actions unless an administrator explicitly enables live actions for the short-lived preview session.
|
||||
|
||||
### Refresh behavior
|
||||
|
||||
The refresh interval applies to the base GET response. A Custom JSX query can declare its own cache duration and can be refreshed manually. Named actions can invalidate the parent response or related named queries after they succeed.
|
||||
|
||||
If the definition needs a non-GET base request, migrate it to a GET base response plus a named action. Existing non-GET Custom JSX definitions require review before network access is enabled.
|
||||
|
||||
See [Custom Widgets management](/docs/management/custom-widgets) for the editor, display types, named-request manifest, network scopes, security limits, and migration guidance.
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
|
||||
import customJsxComponentRegistry from "../generated/custom-jsx-components.json";
|
||||
|
||||
const enabledComponents = customJsxComponentRegistry.filter((component) => component.safety !== "denied");
|
||||
const deniedComponents = customJsxComponentRegistry.filter((component) => component.safety === "denied");
|
||||
const categories = [...new Set(enabledComponents.map((component) => component.category))];
|
||||
|
||||
export function CustomJsxComponentReference() {
|
||||
return (
|
||||
<>
|
||||
<h3>Enabled and wrapped components</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Components</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((category) => (
|
||||
<tr key={category}>
|
||||
<td>
|
||||
<code>{category}</code>
|
||||
</td>
|
||||
<td>
|
||||
{enabledComponents
|
||||
.filter((component) => component.category === category)
|
||||
.map((component) => (
|
||||
<span key={component.name} style={{ display: "inline-block", margin: "0 0.5rem 0.35rem 0" }}>
|
||||
<a href={component.documentationUrl}>
|
||||
<code>{component.name}</code>
|
||||
</a>
|
||||
{component.safety === "wrapped" && <small> (wrapped)</small>}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Denied components</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deniedComponents.map((component) => (
|
||||
<tr key={component.name}>
|
||||
<td>
|
||||
<a href={component.documentationUrl} aria-label={component.name}>
|
||||
<code>{component.name}</code>
|
||||
</a>
|
||||
</td>
|
||||
<td>{component.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,12 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "catalog:",
|
||||
"@codemirror/commands": "catalog:",
|
||||
"@codemirror/lang-javascript": "catalog:",
|
||||
"@codemirror/lang-json": "catalog:",
|
||||
"@codemirror/lint": "catalog:",
|
||||
"@codemirror/view": "catalog:",
|
||||
"@dnd-kit/core": "catalog:",
|
||||
"@dnd-kit/modifiers": "catalog:",
|
||||
"@dnd-kit/sortable": "catalog:",
|
||||
@@ -68,6 +74,7 @@
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@uiw/react-codemirror": "catalog:",
|
||||
"@xterm/addon-canvas": "catalog:",
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
margin-top: 4px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.footer {
|
||||
padding: 7px 10px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.viewport {
|
||||
max-height: min(62vh, 680px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.viewport :global(.cm-editor) {
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
|
||||
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.viewport :global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.viewport :global(.cm-scroller) {
|
||||
line-height: 1.65;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.viewport :global(.cm-gutters) {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.diagnosticButton {
|
||||
min-height: 44px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.diagnosticButton:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.viewport {
|
||||
max-height: 52vh;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import type { CompletionSource } from "@codemirror/autocomplete";
|
||||
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
Loader,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowBackUp,
|
||||
IconArrowForwardUp,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconComponents,
|
||||
IconFileCode,
|
||||
IconSearch,
|
||||
IconWand,
|
||||
} from "@tabler/icons-react";
|
||||
import { enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { validateCustomJsxTemplate } from "@homarr/validation/custom-jsx-template";
|
||||
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
|
||||
export const CUSTOM_JSX_TEMPLATE_LIMIT = 50_000;
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
| "ast"
|
||||
| "dangerousProperty"
|
||||
| "dynamicRequestId"
|
||||
| "eventHandler"
|
||||
| "invalidRequestManifest"
|
||||
| "legacyNetworkProps"
|
||||
| "missingRequestId"
|
||||
| "templateEmpty"
|
||||
| "templateTooLong"
|
||||
| "unknownRequest"
|
||||
| "unsafeTag";
|
||||
|
||||
export interface EditorDiagnostic {
|
||||
code: EditorDiagnosticCode;
|
||||
severity: "error" | "warning";
|
||||
line?: number;
|
||||
column?: number;
|
||||
index?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface AnalyzeJsxTemplateOptions {
|
||||
apiVersion?: 1 | 2;
|
||||
requestIds?: string[];
|
||||
}
|
||||
|
||||
const unsafeTagPattern = /<\s*\/?\s*(script|iframe|object|embed|form|style|link|meta|base)\b/giu;
|
||||
const eventHandlerPattern = /\s(on[A-Z][\w]*)\s*=/gu;
|
||||
const dangerousPropertyPattern =
|
||||
/(?:\.|\[\s*["'])(constructor|prototype|__proto__|caller|callee|arguments|bind|call|apply)(?:["']\s*\])?/giu;
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
|
||||
|
||||
const getLine = (source: string, index: number) => source.slice(0, index).split("\n").length;
|
||||
|
||||
const customJsxCompletionSource: CompletionSource = (context) => {
|
||||
const word = context.matchBefore(/[A-Z][\w.]*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
return {
|
||||
from: word?.from ?? context.pos,
|
||||
options: enabledCustomJsxComponents.map((component) => ({
|
||||
label: component.name,
|
||||
type: "class",
|
||||
detail: `${component.category} · ${component.safety}`,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export function analyzeJsxTemplate(
|
||||
template: string,
|
||||
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
): EditorDiagnostic[] {
|
||||
const diagnostics: EditorDiagnostic[] = [];
|
||||
|
||||
if (template.trim().length === 0) {
|
||||
diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
if (template.length > CUSTOM_JSX_TEMPLATE_LIMIT) {
|
||||
diagnostics.push({ code: "templateTooLong", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
diagnostics.push({
|
||||
code: "ast",
|
||||
severity: diagnostic.severity,
|
||||
line: diagnostic.line,
|
||||
column: diagnostic.column,
|
||||
index: diagnostic.index,
|
||||
value: diagnostic.message,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of template.matchAll(unsafeTagPattern)) {
|
||||
diagnostics.push({
|
||||
code: "unsafeTag",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(eventHandlerPattern)) {
|
||||
if (match[1] === "onParams") continue;
|
||||
diagnostics.push({
|
||||
code: "eventHandler",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(dangerousPropertyPattern)) {
|
||||
diagnostics.push({
|
||||
code: "dangerousProperty",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
|
||||
if (apiVersion === 2) {
|
||||
const knownRequestIds = new Set(requestIds);
|
||||
for (const match of template.matchAll(networkComponentPattern)) {
|
||||
const props = match[2] ?? "";
|
||||
const line = getLine(template, match.index);
|
||||
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
|
||||
}
|
||||
|
||||
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
|
||||
if (staticRequest?.[1]) {
|
||||
if (!knownRequestIds.has(staticRequest[1])) {
|
||||
diagnostics.push({
|
||||
code: "unknownRequest",
|
||||
severity: "error",
|
||||
line,
|
||||
index: match.index,
|
||||
value: staticRequest[1],
|
||||
});
|
||||
}
|
||||
} else if (/\brequestId\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "dynamicRequestId", severity: "error", line, index: match.index });
|
||||
} else {
|
||||
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRequestManifest(value: string): unknown[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
language: "jsx" | "json";
|
||||
diagnostics?: EditorDiagnostic[];
|
||||
error?: React.ReactNode;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
starter?: string;
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
language,
|
||||
diagnostics = [],
|
||||
error,
|
||||
required,
|
||||
maxLength,
|
||||
starter,
|
||||
}: CodeEditorProps) {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
||||
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const extensions = useMemo(() => {
|
||||
const diagnosticsExtension = linter((view) =>
|
||||
diagnostics.map((diagnostic) => {
|
||||
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
|
||||
return {
|
||||
from,
|
||||
to: Math.min(from + 1, view.state.doc.length),
|
||||
severity: diagnostic.severity,
|
||||
message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return language === "jsx"
|
||||
? [javascript({ jsx: true }), autocompletion({ override: [customJsxCompletionSource] }), diagnosticsExtension]
|
||||
: [json(), diagnosticsExtension];
|
||||
}, [diagnostics, language, t]);
|
||||
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
|
||||
const formattedValue = useMemo(() => {
|
||||
if (language === "json") {
|
||||
try {
|
||||
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trim();
|
||||
}, [language, value]);
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
required={required}
|
||||
labelProps={{ htmlFor: id }}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.root}>
|
||||
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
|
||||
<Group gap={6}>
|
||||
<IconCode size={16} aria-hidden />
|
||||
<Text size="xs" fw={600}>
|
||||
{language === "jsx" ? t("language.jsx") : t("language.json")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowBackUp size={14} />}
|
||||
onClick={() => editorView && undo(editorView)}
|
||||
disabled={!editorView || historyDepth.undo === 0}
|
||||
>
|
||||
{t("action.undo")}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowForwardUp size={14} />}
|
||||
onClick={() => editorView && redo(editorView)}
|
||||
disabled={!editorView || historyDepth.redo === 0}
|
||||
>
|
||||
{t("action.redo")}
|
||||
</Button>
|
||||
{language === "jsx" && <ComponentReference />}
|
||||
{starter && value.trim().length === 0 && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconFileCode size={14} />}
|
||||
onClick={() => onChange(starter)}
|
||||
>
|
||||
{t("action.insertStarter")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconWand size={14} />}
|
||||
onClick={() => onChange(formattedValue)}
|
||||
disabled={formattedValue === value}
|
||||
>
|
||||
{t("action.format")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div className={classes.viewport}>
|
||||
<Editor
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
extensions={extensions}
|
||||
theme={colorScheme}
|
||||
height={language === "jsx" ? "320px" : "220px"}
|
||||
basicSetup={{
|
||||
autocompletion: language !== "jsx",
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
foldGutter: true,
|
||||
history: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
indentOnInput: true,
|
||||
lineNumbers: true,
|
||||
}}
|
||||
onCreateEditor={setEditorView}
|
||||
onUpdate={(update) => {
|
||||
if (!update.selectionSet && !update.docChanged) return;
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setCursor({ line: line.number, column: head - line.from + 1 });
|
||||
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group className={classes.footer} justify="space-between" gap="xs">
|
||||
<Group gap={6}>
|
||||
{errorCount > 0 ? (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{t("status.errors", { count: errorCount })}
|
||||
</Badge>
|
||||
) : warningCount > 0 ? (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
{t("status.warnings", { count: warningCount })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("status.ready")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("status.position", cursor)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c={maxLength && value.length > maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"}
|
||||
>
|
||||
{maxLength
|
||||
? t("status.charactersWithLimit", { count: value.length, limit: maxLength })
|
||||
: t("status.characters", { count: value.length })}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{diagnostics.length > 0 && (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={t("diagnostics.title")}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentReference() {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const [query, setQuery] = useState("");
|
||||
const components = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return enabledCustomJsxComponents.filter(
|
||||
(component) =>
|
||||
normalizedQuery.length === 0 ||
|
||||
component.name.toLocaleLowerCase().includes(normalizedQuery) ||
|
||||
component.category.toLocaleLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Popover width={320} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
|
||||
{t("action.components")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t("componentReference.search")}
|
||||
aria-label={t("componentReference.search")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={300} type="auto">
|
||||
<Stack gap={4}>
|
||||
{components.map((component) => (
|
||||
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
|
||||
<Text size="xs" ff="monospace" fw={600}>
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{t("componentReference.empty")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("componentReference.count", { count: enabledCustomJsxComponents.length })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -5,272 +5,174 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
|
||||
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
|
||||
|
||||
## Your Task
|
||||
Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions.
|
||||
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
|
||||
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
|
||||
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
|
||||
|
||||
## Output Format
|
||||
- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead.
|
||||
- Do NOT include any text before or after the JSON block unless you have clarifying questions.
|
||||
- If your environment supports structured/JSON output mode, use it.
|
||||
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
|
||||
|
||||
## JSON Schema
|
||||
The output must conform to this JSON Schema:
|
||||
## Rules
|
||||
- Use \`"$schema": "homarr-custom-widget-v3"\`.
|
||||
- \`displayType\` and \`displayConfig.type\` must match.
|
||||
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
|
||||
- The base widget request must use GET. Mutations belong in named action requests.
|
||||
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
|
||||
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
|
||||
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
|
||||
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
|
||||
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
|
||||
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
|
||||
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
|
||||
- Use only components in the generated component reference below.
|
||||
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
|
||||
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
|
||||
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
|
||||
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
|
||||
|
||||
`;
|
||||
## Named request shape
|
||||
\`\`\`ts
|
||||
interface CustomJsxRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
pathTemplate: \`/\${string}\`;
|
||||
parameters: Record<string, "string" | "number" | "boolean">;
|
||||
bodyTemplate?: unknown;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth: "inherit" | "none";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
const PROMPT_RULES = `
|
||||
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
|
||||
|
||||
## Key Rules
|
||||
- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly
|
||||
- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`)
|
||||
- The \`$schema\` field should be \`"homarr-custom-widget-v2"\`
|
||||
- Do NOT include secrets/passwords in the output — those are configured separately in the UI
|
||||
- \`url\` must be a full URL including protocol (e.g. \`https://...\`)
|
||||
- For arrays of items, inspect the API response to find the correct paths
|
||||
## SubFetch rendering
|
||||
Use a render child when a named query needs its own layout:
|
||||
|
||||
## Display Type Guide
|
||||
- \`singleValue\`: One prominent number/text
|
||||
- \`keyValue\`: Labeled pairs — use \`mappings\` array
|
||||
- \`table\`: Tabular data — \`tablePath\` + \`columns\`
|
||||
- \`statGrid\`: Grid of stat cards with colors
|
||||
- \`progressBars\`: Visual progress bars with value/max
|
||||
- \`statusIndicator\`: Green/red dots based on value matching
|
||||
- \`countGrid\`: Simple count grid
|
||||
- \`raw\`: Raw JSON display
|
||||
- \`actionButton\`: Button that triggers the API call on click
|
||||
- \`customJsx\`: **Full Mantine v9 JSX** — complete creative control. Set \`displayConfig.template\` to a JSX string.
|
||||
|
||||
## Custom JSX (\`customJsx\`) — Component Reference
|
||||
|
||||
All Mantine v9 components listed below are available. For full Mantine prop documentation, refer to https://mantine.dev/llms.txt or use Context7/web search for any component's detailed API.
|
||||
|
||||
### Layout
|
||||
Box, Stack, Group, Flex, Grid + Grid.Col, SimpleGrid, Center, Space, Container, AspectRatio, Overlay, ScrollArea
|
||||
|
||||
### Typography
|
||||
Text, Title, Code, Highlight, Mark, Kbd, Blockquote, Anchor (href sanitized), NumberFormatter, Marquee, RollingNumber
|
||||
|
||||
### Data Display
|
||||
Badge, Card + Card.Section, Paper, Alert, ThemeIcon, ColorSwatch, Table (+ Thead/Tbody/Tfoot/Caption/Tr/Th/Td), List + List.Item, Timeline + Timeline.Item, Accordion (+ Item/Control/Panel), Indicator, Pill, Spoiler, Progress + Progress.Section, RingProgress, SemiCircleProgress, Skeleton, Loader, Image, Avatar + Avatar.Group, BackgroundImage, Tooltip, Divider, DataList (+ Item/ItemLabel/ItemValue), EmptyState (+ Indicator/Title/Description/Actions), Fieldset, Notification (visual), Rating (read-only)
|
||||
|
||||
### Navigation
|
||||
Breadcrumbs, NavLink (href sanitized), Stepper + Stepper.Step, Tabs + Tabs.List + Tabs.Tab + Tabs.Panel, Tree
|
||||
|
||||
### Interactive Display (visual only — no event handlers)
|
||||
Button, ActionIcon, Burger, CloseButton, Chip + Chip.Group, Pagination, SegmentedControl, Slider (read-only), Switch (read-only)
|
||||
|
||||
### Hover Overlays & Menus
|
||||
HoverCard + HoverCard.Target + HoverCard.Dropdown, Menu + Menu.Target + Menu.Dropdown + Menu.Item + Menu.Label + Menu.Divider
|
||||
|
||||
### Utility
|
||||
CopyButton (click-to-copy, props: \`value\`), Collapse (animate show/hide, props: \`in\`), Transition (animate, props: \`mounted\`, \`transition\`)
|
||||
|
||||
### Charts (@mantine/charts — ALL)
|
||||
AreaChart, BarChart, LineChart, DonutChart, PieChart, RadarChart, RadialBarChart, Sparkline, BubbleChart, CompositeChart, FunnelChart, Heatmap, ScatterChart, SankeyChart, Treemap, BarsList
|
||||
|
||||
### Dates (@mantine/dates)
|
||||
Calendar (static), MiniCalendar (static), DatePicker (static/read-only), TimeValue
|
||||
|
||||
### SubFetch — In-Widget HTTP Requests (server-proxied)
|
||||
These components make HTTP requests through the Homarr server proxy (same-origin as widget URL, inherits widget auth). HTTP method warnings are shown in the widget UI.
|
||||
|
||||
**SubFetch** — Fetch data from a sub-endpoint and display results.
|
||||
Props: \`url\` (relative or absolute), \`method\` (GET|POST|PUT|DELETE|PATCH), \`body\` (JSON string), \`headers\` (JSON string), \`trigger\` ("auto"|"manual"), \`label\`, \`color\`, \`display\` ("json"|"text"), \`path\` (dot-path for display="text")
|
||||
\`\`\`jsx
|
||||
<SubFetch url="/api/v1/status" trigger="auto">
|
||||
<SubData path="name" as="Title" order={3} />
|
||||
<SubData path="status" as="Badge" color="green" />
|
||||
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>{detail.name}</Text>
|
||||
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
|
||||
</Group>
|
||||
{detail.metrics.map((metric) =>
|
||||
<Group key={metric.name} justify="space-between">
|
||||
<Text size="sm">{metric.name}</Text>
|
||||
<Text fw={600}>{metric.value}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
**SubData** — Reads data from parent SubFetch context.
|
||||
Props: \`path\` (dot-notation), \`as\` ("Text"|"Title"|"Badge"|"Code"), \`size\`, \`color\`, \`fw\`, \`c\`, \`order\`
|
||||
|
||||
**ActionButton** — Button that fires an HTTP request on click.
|
||||
Props: \`url\`, \`method\` (default POST), \`body\`, \`headers\`, \`label\`, \`color\`, \`variant\`, \`size\`, \`confirmMessage\`, \`successMessage\`, \`icon\` (play|check|refresh|power|trash), \`fullWidth\`, \`disabled\`
|
||||
\`\`\`jsx
|
||||
<ActionButton url="/api/lights/1/toggle" method="POST" label="Toggle Light" color="yellow" icon="power" confirmMessage="Toggle the light?" />
|
||||
\`\`\`
|
||||
|
||||
**ToggleSwitch** — Switch that sends different payloads for on/off states.
|
||||
Props: \`url\`, \`method\` (default POST), \`onBody\` (JSON), \`offBody\` (JSON), \`initialValue\`, \`label\`, \`color\`, \`size\`, \`disabled\`
|
||||
\`\`\`jsx
|
||||
<ToggleSwitch url="/api/lights/1" onBody='{"state":"on"}' offBody='{"state":"off"}' label="Living Room" color="yellow" />
|
||||
\`\`\`
|
||||
|
||||
**RefreshButton** — Re-fetches the parent widget data.
|
||||
Props: \`label\`, \`color\`, \`variant\`, \`size\`
|
||||
|
||||
### Custom Interactive Components (built-in state)
|
||||
**PaginatedList** — Paginates children. Props: \`pageSize\` (default 6)
|
||||
**TabsContainer + TabPanel** — Tabbed UI. TabsContainer: \`defaultTab\`. TabPanel: \`value\`, \`label\`
|
||||
**Collapsible** — Expand/collapse. Props: \`title\`, \`defaultOpen\`
|
||||
**StatBar** — Horizontal stat bar. Props: \`value\`, \`max\`, \`label\`, \`color\`
|
||||
**TypeBadge** — Colored type badge (auto-maps: fire→red, water→blue, etc.). Props: \`type\`, \`size\`
|
||||
|
||||
### Bindings
|
||||
- \`data\` — full API response
|
||||
- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\`, \`parseInt(v)\`, \`parseFloat(v)\`
|
||||
- \`Math.round/floor/ceil/abs/min/max/pow/sqrt/PI\`
|
||||
- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Array.from(v)\`, \`Object.keys/values/entries(v)\`
|
||||
- \`Date.now()\`, \`Date.create(v)\`, \`Date.toISOString(v)\`, \`Date.toLocaleDateString(v, locale?)\`, \`Date.toLocaleTimeString(v, locale?)\`, \`Date.getTime(v)\`, \`Date.getYear(v)\`, \`Date.getMonth(v)\`, \`Date.getDay(v)\`
|
||||
- \`encodeURIComponent(v)\`, \`decodeURIComponent(v)\`, \`isNaN(v)\`, \`isFinite(v)\`
|
||||
- \`.map()\`, \`.filter()\`, \`.slice()\`, ternaries for conditionals
|
||||
|
||||
**FORBIDDEN keywords:** constructor, __proto__, eval, Function, import, require, globalThis, window, document
|
||||
|
||||
### Chart Data Formats
|
||||
- BarChart/LineChart/AreaChart: \`data={items}\` + \`dataKey="x"\` + \`series={[{name:"y",color:"blue"}]}\`
|
||||
- DonutChart/PieChart: \`data={[{name,value,color}]}\`
|
||||
- ScatterChart: \`data={items}\` + \`dataKey={xKey}\` + \`series={[{name, color}]}\`
|
||||
- CompositeChart: same as Bar/Line/Area + \`composedChart\` with type per series
|
||||
- Sparkline: \`data={[numbers]}\`
|
||||
- Heatmap: \`data={items}\` + \`dataKey\` + \`series\`
|
||||
|
||||
### Pokédex Example (75 pokemon)
|
||||
API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="xs">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<Badge size="lg" color="red">{data.count} total</Badge>
|
||||
</Group>
|
||||
<PaginatedList pageSize={10}>
|
||||
{data.results.map((pokemon, i) =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size="lg" radius="sm" />
|
||||
<Stack gap={0} style={{flex: 1}}>
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<Text size="xs" c="dimmed">#{String(i + 1).padStart(3, "0")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Pokédex with Detail Fetch (SubFetch for sub-resources)
|
||||
API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="xs">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<PaginatedList pageSize={5}>
|
||||
{data.results.map((pokemon, i) =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size={60} radius="sm" />
|
||||
<Stack gap={2} style={{flex: 1}}>
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<SubFetch url={pokemon.url} trigger="auto">
|
||||
<Group gap="xs">
|
||||
<SubData path="types.0.type.name" as="Badge" color="red" />
|
||||
<SubData path="stats.0.base_stat" as="Text" size="xs" />
|
||||
</Group>
|
||||
</SubFetch>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Stocks Dashboard (Charts + Live Data)
|
||||
API: \`https://your-stocks-api.com/portfolio\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Portfolio</Title>
|
||||
<RefreshButton label="Refresh" size="xs" />
|
||||
</Group>
|
||||
<SimpleGrid cols={3}>
|
||||
{data.holdings.map(stock =>
|
||||
<Card withBorder p="xs">
|
||||
<Text fw={700}>{stock.symbol}</Text>
|
||||
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
|
||||
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<LineChart h={200} data={data.timeline} dataKey="date" series={[{name: "value", color: "blue"}]} />
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Navidrome Mini-Player (Buttons + State)
|
||||
API: \`https://your-navidrome.local/api/nowplaying\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="sm">
|
||||
<Group wrap="nowrap">
|
||||
<Image src={data.albumArt} w={80} h={80} radius="sm" />
|
||||
<Stack gap={2} style={{flex: 1}}>
|
||||
<Text fw={700} lineClamp={1}>{data.title}</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
|
||||
<Progress value={data.progress} size="xs" color="red" />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group justify="center" gap="sm">
|
||||
<ActionButton url="/api/player/previous" method="POST" label="Prev" size="xs" variant="subtle" />
|
||||
<ActionButton url="/api/player/toggle" method="POST" label={data.playing ? "Pause" : "Play"} icon="play" color="red" />
|
||||
<ActionButton url="/api/player/next" method="POST" label="Next" size="xs" variant="subtle" />
|
||||
</Group>
|
||||
<Group justify="center">
|
||||
<ToggleSwitch url="/api/player/shuffle" method="POST" onBody='{"shuffle":true}' offBody='{"shuffle":false}' initialValue={data.shuffle} label="Shuffle" size="xs" />
|
||||
<ToggleSwitch url="/api/player/repeat" method="POST" onBody='{"repeat":true}' offBody='{"repeat":false}' initialValue={data.repeat} label="Repeat" size="xs" />
|
||||
</Group>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Smart Home Light Switch
|
||||
API: \`https://your-ha.local/api/states/light.living_room\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Living Room</Title>
|
||||
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
|
||||
</Group>
|
||||
<ToggleSwitch url="/api/services/light/toggle" method="POST" onBody='{"entity_id":"light.living_room"}' offBody='{"entity_id":"light.living_room"}' initialValue={data.state === "on"} label="Power" color="yellow" />
|
||||
<ActionButton url="/api/services/light/turn_off" method="POST" body='{"entity_id":"light.living_room"}' label="Force Off" color="red" icon="power" confirmMessage="Turn off?" />
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
## API Response
|
||||
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below:
|
||||
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_YOUR_API_RESPONSE_HERE
|
||||
PASTE_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const PROMPT_FOOTER = `
|
||||
## Your Request
|
||||
Describe what you want the widget to show:
|
||||
function buildComponentReference() {
|
||||
const categories = new Map<string, string[]>();
|
||||
for (const component of enabledCustomJsxComponents) {
|
||||
const names = categories.get(component.category) ?? [];
|
||||
names.push(component.name);
|
||||
categories.set(component.category, names);
|
||||
}
|
||||
|
||||
`;
|
||||
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
|
||||
}
|
||||
|
||||
function buildExamples() {
|
||||
return customJsxExamples
|
||||
.map(
|
||||
(example) => `### ${example.title}
|
||||
${example.description}
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
requests: example.requests,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
\`\`\`jsx
|
||||
${example.template}
|
||||
\`\`\``,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const schemaStr = JSON.stringify(jsonSchema, null, 2);
|
||||
const responseSection = rawResponse
|
||||
? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: PROMPT_NO_RESPONSE;
|
||||
|
||||
const configSection = currentConfig
|
||||
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
|
||||
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
|
||||
const currentDisplayConfig = currentConfig?.displayConfig;
|
||||
const currentTemplate =
|
||||
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
|
||||
? currentDisplayConfig.template
|
||||
: null;
|
||||
const readableCurrentConfig = currentConfig
|
||||
? {
|
||||
...currentConfig,
|
||||
...(currentTemplate && typeof currentTemplate === "string"
|
||||
? {
|
||||
displayConfig: {
|
||||
...(currentDisplayConfig as Record<string, unknown>),
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const configSection = readableCurrentConfig
|
||||
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
|
||||
: "";
|
||||
|
||||
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
|
||||
return `${PROMPT_HEADER}
|
||||
## Import schema
|
||||
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(jsonSchema, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Generated component reference
|
||||
${buildComponentReference()}
|
||||
|
||||
## Tested examples
|
||||
${buildExamples()}
|
||||
|
||||
${responseSection}
|
||||
${configSection}
|
||||
## Requested widget
|
||||
Describe what the widget should show and which interactions it needs:
|
||||
`;
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
@@ -285,9 +187,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
await navigator.clipboard.writeText(buildAiPrompt(schema, rawResponse, currentConfig));
|
||||
close();
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
} catch {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconToggleLeft,
|
||||
IconToggleRight,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
@@ -19,6 +25,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { getImportReview, parseCustomWidgetClipboard } from "./_custom-widget-import-utils";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -33,28 +40,8 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleEnabledMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
@@ -103,7 +90,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
@@ -123,14 +110,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleToggleEnabled}
|
||||
leftSection={widget.enabled ? <IconToggleLeft {...iconProps} /> : <IconToggleRight {...iconProps} />}
|
||||
disabled={toggleEnabledMutation.isPending}
|
||||
>
|
||||
{widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
@@ -158,9 +137,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<Record<string, unknown> | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
@@ -169,23 +152,45 @@ export const ImportCustomWidgetButton = () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
},
|
||||
});
|
||||
const review = useMemo(() => getImportReview(pendingImport), [pendingImport]);
|
||||
const queueImport = useCallback(
|
||||
(value: Record<string, unknown>) => {
|
||||
setPendingImport(value);
|
||||
openReview();
|
||||
},
|
||||
[openReview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches("input, textarea, [contenteditable='true']")) return;
|
||||
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
|
||||
if (!widget) return;
|
||||
event.preventDefault();
|
||||
queueImport(widget);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [queueImport]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
reader.addEventListener("load", (e) => {
|
||||
try {
|
||||
const json = JSON.parse(e.target?.result as string);
|
||||
importMutation.mutate(json);
|
||||
const widget = parseCustomWidgetClipboard(e.target?.result as string);
|
||||
if (!widget) throw new Error("Invalid import");
|
||||
queueImport(widget);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
};
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
@@ -197,17 +202,92 @@ export const ImportCustomWidgetButton = () => {
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label="Import custom widget"
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<Modal opened={reviewOpened} onClose={closeReview} title={t("importReview.title")} centered size="lg">
|
||||
{pendingImport && review && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("importReview.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={t("importReview.name")} value={review.name} />
|
||||
<ImportFact label={t("importReview.origin")} value={review.origin} />
|
||||
<ImportFact label={t("importReview.authentication")} value={review.authType} />
|
||||
<ImportFact label={t("importReview.networkScope")} value={review.networkScope} />
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.methods")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.permissions")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{t(`preview.request.permission.${permission}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("importReview.actionWarning.title")}
|
||||
</Text>
|
||||
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeReview} disabled={importMutation.isPending}>
|
||||
{t("importReview.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => importMutation.mutate(pendingImport as never)} loading={importMutation.isPending}>
|
||||
{t("importReview.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Code>{value || "—"}</Code>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
.form {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: var(--mantine-spacing-xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.paneSwitcher {
|
||||
position: sticky;
|
||||
top: var(--mantine-spacing-xs);
|
||||
z-index: 10;
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@media (max-width: 74.9375em) {
|
||||
.workbench[data-mobile-pane="configure"] .previewPane,
|
||||
.workbench[data-mobile-pane="preview"] .configuration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.configuration,
|
||||
.previewPane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
position: sticky;
|
||||
bottom: var(--mantine-spacing-sm);
|
||||
z-index: 10;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.mobileSaveBar {
|
||||
position: sticky;
|
||||
bottom: var(--mantine-spacing-sm);
|
||||
z-index: 10;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.displayTypeOption {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
min-height: 7rem;
|
||||
padding: var(--mantine-spacing-md);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.displayTypeOption:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.displayTypeOption[data-selected] {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.displayTypeOption:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.displayTypeCopy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.displayTypeOption {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 75em) {
|
||||
.paneSwitcher {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileSaveBar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
top: 5rem;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 100em) {
|
||||
.workbench {
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+44
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCustomWidgetClipboard } from "./_custom-widget-import-utils";
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v3",
|
||||
name: "Example",
|
||||
url: "https://example.com/api",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
requests: [],
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
};
|
||||
|
||||
describe("parseCustomWidgetClipboard", () => {
|
||||
it("keeps supporting raw JSON imports", () => {
|
||||
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
|
||||
expect(result?.name).toBe("Example");
|
||||
});
|
||||
|
||||
it("combines a JSON metadata block with a readable JSX block", () => {
|
||||
const result = parseCustomWidgetClipboard(`Here is the widget:
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(widget, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm">
|
||||
<Text>{data.name}</Text>
|
||||
</Stack>
|
||||
\`\`\``);
|
||||
|
||||
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe(
|
||||
'<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
export interface ImportReview {
|
||||
name: string;
|
||||
origin: string;
|
||||
authType: string;
|
||||
networkScope: string;
|
||||
methods: string[];
|
||||
permissions: string[];
|
||||
hasActions: boolean;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/iu)?.[1];
|
||||
const jsxBlock = text.match(/```(?:jsx|tsx)\s*([\s\S]*?)```/iu)?.[1]?.trim();
|
||||
const value: unknown = JSON.parse(jsonBlock ?? text);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const widget = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof widget.name !== "string" ||
|
||||
typeof widget.url !== "string" ||
|
||||
typeof widget.displayType !== "string" ||
|
||||
!widget.displayConfig ||
|
||||
typeof widget.displayConfig !== "object" ||
|
||||
Array.isArray(widget.displayConfig)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (jsxBlock && widget.displayType === "customJsx") {
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
|
||||
displayConfig.template = jsxBlock;
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
|
||||
if (!value) return null;
|
||||
const displayConfig = value.displayConfig as Record<string, unknown>;
|
||||
const requests = Array.isArray(displayConfig.requests)
|
||||
? displayConfig.requests.filter(
|
||||
(request): request is Record<string, unknown> =>
|
||||
request !== null && typeof request === "object" && !Array.isArray(request),
|
||||
)
|
||||
: [];
|
||||
const baseMethod = typeof value.method === "string" ? value.method : "GET";
|
||||
const methods = [
|
||||
...new Set([
|
||||
baseMethod,
|
||||
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
|
||||
]),
|
||||
];
|
||||
const permissions = [
|
||||
...new Set(
|
||||
requests
|
||||
.map((request) => request.minimumBoardPermission)
|
||||
.filter(
|
||||
(permission): permission is string =>
|
||||
permission === "view" || permission === "modify" || permission === "full",
|
||||
),
|
||||
),
|
||||
];
|
||||
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
|
||||
|
||||
let origin = value.url;
|
||||
try {
|
||||
origin = new URL(value.url as string).origin;
|
||||
} catch {
|
||||
// The server returns the localized validation error after confirmation.
|
||||
}
|
||||
|
||||
return {
|
||||
name: value.name as string,
|
||||
origin: String(origin),
|
||||
authType: typeof value.authType === "string" ? value.authType : "none",
|
||||
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
|
||||
methods,
|
||||
permissions,
|
||||
hasActions: methods.some((method) => method !== "GET"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as typeof base.columns) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template:
|
||||
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...base, ...typeOverrides[displayType]?.() };
|
||||
}
|
||||
import { customJsxExamples } from "@homarr/definitions";
|
||||
|
||||
export const CUSTOM_JSX_STARTER =
|
||||
customJsxExamples.find((example) => example.id === "simple-metric")?.template ??
|
||||
'<Stack align="center" justify="center" h="100%"><Text size="xs" c="dimmed">Connect data to begin</Text></Stack>';
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -23,8 +26,6 @@ const displayTypeBadgeColors: Record<string, string> = {
|
||||
customJsx: "pink",
|
||||
};
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface CustomWidgetListProps {
|
||||
definitions: RouterOutputs["customWidget"]["all"];
|
||||
}
|
||||
@@ -43,59 +44,99 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("table.name")}</Table.Th>
|
||||
<Table.Th>{t("table.url")}</Table.Th>
|
||||
<Table.Th>{t("table.display")}</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{definitions.map((def) => (
|
||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} />
|
||||
)}
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{def.url}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
|
||||
{t(`displayType.${def.displayType}` as never)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${def.id}`}
|
||||
variant="filled"
|
||||
color="red"
|
||||
aria-label={t("action.edit")}
|
||||
>
|
||||
<IconPencil {...iconProps} />
|
||||
</ActionIcon>
|
||||
<CustomWidgetRowActions widget={{ id: def.id, name: def.name, enabled: def.enabled }} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Stack gap="sm">
|
||||
{definitions.map((def) => (
|
||||
<CustomWidgetCard key={def.id} widget={def} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetDef = RouterOutputs["customWidget"]["all"][number];
|
||||
|
||||
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
style={{
|
||||
opacity: widget.enabled ? 1 : 0.55,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
{widget.iconUrl ? (
|
||||
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<Avatar size={36} radius="sm" color="red">
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
{widget.url && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.url}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge
|
||||
color={displayTypeBadgeColors[widget.displayType] ?? "gray"}
|
||||
size="sm"
|
||||
variant="light"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t(`displayType.${widget.displayType}` as never)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={widget.enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("action.edit")}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<CustomWidgetRowActions widget={{ id: widget.id, name: widget.name, enabled: widget.enabled }} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconExternalLink, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Component as ReactComponent, useEffect, useMemo, useState } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Code,
|
||||
Collapse,
|
||||
CopyButton,
|
||||
getTreeExpandedState,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
Tree,
|
||||
useComputedColorScheme,
|
||||
useTree,
|
||||
} from "@mantine/core";
|
||||
import type { RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClipboard,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconExternalLink,
|
||||
IconInfoCircle,
|
||||
IconNetwork,
|
||||
IconPlayerPlay,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
|
||||
import { extractHttpMethods } from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
import { displayComponents } from "@homarr/widgets/custom-api/component";
|
||||
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
|
||||
|
||||
import { analyzeJsxTemplate } from "./_code-editor";
|
||||
|
||||
const JsonEditor = dynamic(() => import("@uiw/react-codemirror").then((m) => m.default), {
|
||||
ssr: false,
|
||||
loading: () => <Loader size="sm" />,
|
||||
});
|
||||
|
||||
interface PreviewInput {
|
||||
url: string;
|
||||
@@ -22,11 +72,20 @@ interface PreviewInput {
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
interface NamedRequestSummary {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
minimumBoardPermission?: string;
|
||||
}
|
||||
|
||||
export interface PreviewFetchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
responseInfo: { status: number; statusText: string } | null;
|
||||
rawResponse: string | null;
|
||||
previewSession?: { id: string; expiresAt: number; liveActions: boolean } | null;
|
||||
}
|
||||
|
||||
interface CustomWidgetPreviewProps {
|
||||
@@ -36,19 +95,142 @@ interface CustomWidgetPreviewProps {
|
||||
cachedJson: unknown;
|
||||
onTest: () => void;
|
||||
isTesting: boolean;
|
||||
isSampleStale?: boolean;
|
||||
testError?: string | null;
|
||||
onInsertDataPath?: (path: string) => void;
|
||||
onSetPreviewLiveActions?: (enabled: boolean) => void;
|
||||
isUpdatingPreviewActions?: boolean;
|
||||
onSampleDataChange?: (value: unknown) => void;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreview({
|
||||
const METHOD_COLORS: Record<string, string> = { DELETE: "red", GET: "blue" };
|
||||
const methodColor = (method: string) => METHOD_COLORS[method] ?? "orange";
|
||||
|
||||
function journalEntryColor(entry: { simulated: boolean; status: number | null }): string {
|
||||
if (entry.simulated) return "yellow";
|
||||
if (entry.status !== null && entry.status >= 200 && entry.status < 400) return "green";
|
||||
return "red";
|
||||
}
|
||||
|
||||
const redactUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
for (const key of url.searchParams.keys()) url.searchParams.set(key, "redacted");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getNamedRequests = (displayConfig: Record<string, unknown>): NamedRequestSummary[] => {
|
||||
if (!Array.isArray(displayConfig.requests)) return [];
|
||||
return displayConfig.requests.filter(
|
||||
(request): request is NamedRequestSummary =>
|
||||
request !== null &&
|
||||
typeof request === "object" &&
|
||||
"id" in request &&
|
||||
typeof request.id === "string" &&
|
||||
"kind" in request &&
|
||||
(request.kind === "query" || request.kind === "action") &&
|
||||
"method" in request &&
|
||||
typeof request.method === "string" &&
|
||||
"pathTemplate" in request &&
|
||||
typeof request.pathTemplate === "string",
|
||||
);
|
||||
};
|
||||
|
||||
const getLegacyMethods = (template: string) => {
|
||||
const methods = new Set<string>();
|
||||
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
|
||||
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
|
||||
const defaultMethod = match[0].startsWith("<SubFetch") ? "GET" : "POST";
|
||||
methods.add(method?.toUpperCase() ?? defaultMethod);
|
||||
}
|
||||
return [...methods];
|
||||
};
|
||||
|
||||
interface PreviewErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
retryLabel: string;
|
||||
}
|
||||
|
||||
interface PreviewErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class PreviewErrorBoundary extends ReactComponent<PreviewErrorBoundaryProps, PreviewErrorBoundaryState> {
|
||||
state: PreviewErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): PreviewErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(_error: Error, _info: ErrorInfo) {
|
||||
// The renderer exposes redacted diagnostics. Avoid logging imported template data here.
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<Card p="xl" role="alert">
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<IconAlertTriangle size={28} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fw={600} ta="center">
|
||||
{this.props.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={480}>
|
||||
{this.props.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="light" onClick={() => this.setState({ hasError: false })}>
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomWidgetPreview(props: CustomWidgetPreviewProps) {
|
||||
const t = useScopedI18n("customWidget.preview");
|
||||
return (
|
||||
<PreviewErrorBoundary
|
||||
title={t("errorBoundary.title")}
|
||||
description={t("errorBoundary.description")}
|
||||
retryLabel={t("errorBoundary.retry")}
|
||||
>
|
||||
<CustomWidgetPreviewContent {...props} />
|
||||
</PreviewErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomWidgetPreviewContent({
|
||||
getFormValues,
|
||||
formValues,
|
||||
fetchResult,
|
||||
cachedJson,
|
||||
onTest,
|
||||
isTesting,
|
||||
isSampleStale = false,
|
||||
testError,
|
||||
onInsertDataPath,
|
||||
onSetPreviewLiveActions,
|
||||
isUpdatingPreviewActions = false,
|
||||
onSampleDataChange,
|
||||
}: CustomWidgetPreviewProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const editorT = useScopedI18n("customWidget.editor");
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [sampleEditorOpen, setSampleEditorOpen] = useState(false);
|
||||
const [sampleDraft, setSampleDraft] = useState("");
|
||||
const previewSessionId = fetchResult?.previewSession?.id ?? "";
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: previewSessionId },
|
||||
{ enabled: previewSessionId.length > 0, retry: false, refetchInterval: 1_000 },
|
||||
);
|
||||
|
||||
const displayData = useMemo((): Record<string, unknown> | null => {
|
||||
const { displayType, displayConfig } = formValues;
|
||||
@@ -56,63 +238,153 @@ export function CustomWidgetPreview({
|
||||
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
|
||||
}
|
||||
if (!fetchResult?.success || cachedJson == null) return null;
|
||||
return extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
|
||||
}, [fetchResult?.success, cachedJson, formValues]);
|
||||
const extracted = extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
|
||||
return displayType === "customJsx" && fetchResult.previewSession
|
||||
? {
|
||||
...extracted,
|
||||
previewSessionId: fetchResult.previewSession.id,
|
||||
previewLiveActions: fetchResult.previewSession.liveActions,
|
||||
}
|
||||
: extracted;
|
||||
}, [fetchResult, cachedJson, formValues]);
|
||||
|
||||
const namedRequests = useMemo(() => getNamedRequests(formValues.displayConfig), [formValues.displayConfig]);
|
||||
const httpMethods = useMemo(() => {
|
||||
if (formValues.displayType !== "customJsx") return [];
|
||||
const template = (formValues.displayConfig.template as string) ?? "";
|
||||
return extractHttpMethods(template);
|
||||
}, [formValues.displayType, formValues.displayConfig]);
|
||||
if (namedRequests.length > 0) return [...new Set(namedRequests.map((request) => request.method))];
|
||||
return getLegacyMethods((formValues.displayConfig.template as string) ?? "");
|
||||
}, [formValues.displayType, formValues.displayConfig.template, namedRequests]);
|
||||
const hasNamedActions = namedRequests.some((request) => request.kind === "action");
|
||||
const templateDiagnostics = useMemo(
|
||||
() =>
|
||||
formValues.displayType === "customJsx"
|
||||
? analyzeJsxTemplate((formValues.displayConfig.template as string) ?? "", {
|
||||
apiVersion: formValues.displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestIds: namedRequests.map((request) => request.id),
|
||||
})
|
||||
: [],
|
||||
[formValues.displayConfig, formValues.displayType, namedRequests],
|
||||
);
|
||||
|
||||
const handleTest = () => {
|
||||
const values = getFormValues();
|
||||
if (!values.url) return;
|
||||
if (!values.url || values.method !== "GET") return;
|
||||
onTest();
|
||||
};
|
||||
|
||||
const PREVIEW_HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
|
||||
const previewHeight = PREVIEW_HEIGHTS[previewSize] ?? 360;
|
||||
const responseJson = cachedJson == null ? "" : JSON.stringify(cachedJson, null, 2);
|
||||
const sampleError = useMemo(() => {
|
||||
if (!sampleDraft.trim()) return null;
|
||||
try {
|
||||
JSON.parse(sampleDraft);
|
||||
return null;
|
||||
} catch {
|
||||
return t("preview.response.invalidSample");
|
||||
}
|
||||
}, [sampleDraft, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sampleEditorOpen) setSampleDraft(responseJson);
|
||||
}, [responseJson, sampleEditorOpen]);
|
||||
|
||||
const openSampleEditor = () => {
|
||||
setSampleDraft(responseJson || JSON.stringify({ name: "Sample service", status: "online", value: 42 }, null, 2));
|
||||
setSampleEditorOpen(true);
|
||||
};
|
||||
|
||||
const applySampleData = () => {
|
||||
if (!sampleDraft.trim() || sampleError) return;
|
||||
onSampleDataChange?.(JSON.parse(sampleDraft) as unknown);
|
||||
setSampleEditorOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={5}>{t("preview.title")}</Title>
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Title order={5}>{t("preview.title")}</Title>
|
||||
{httpMethods.length > 0 && (
|
||||
<Popover width={300} position="bottom" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" color="gray" leftSection={<IconNetwork size={14} />}>
|
||||
{t("preview.capabilities.interactive")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("preview.capabilities.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.capabilities.description")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{httpMethods.map((method) => (
|
||||
<Badge key={method} size="xs" color={methodColor(method)} variant="light">
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{hasNamedActions && (
|
||||
<Stack gap={4} mt="xs">
|
||||
<Switch
|
||||
label={t("preview.capabilities.liveActions")}
|
||||
description={
|
||||
fetchResult?.previewSession
|
||||
? t("preview.capabilities.liveActionsDescription")
|
||||
: t("preview.capabilities.runTestFirst")
|
||||
}
|
||||
checked={fetchResult?.previewSession?.liveActions ?? false}
|
||||
disabled={
|
||||
!fetchResult?.previewSession || !onSetPreviewLiveActions || isUpdatingPreviewActions
|
||||
}
|
||||
onChange={(event) => onSetPreviewLiveActions?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{!fetchResult?.previewSession?.liveActions && (
|
||||
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
|
||||
{t("preview.capabilities.simulated")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
disabled={formValues.method !== "GET" || !formValues.url}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{httpMethods.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconNetwork size={14} />}>
|
||||
<Group gap={4} align="center">
|
||||
<Text size="xs" fw={500}>
|
||||
Widget makes HTTP requests:
|
||||
</Text>
|
||||
{httpMethods.map((m) => (
|
||||
<Badge
|
||||
key={m}
|
||||
size="xs"
|
||||
color={m === "DELETE" ? "red" : m === "GET" ? "blue" : "orange"}
|
||||
variant="filled"
|
||||
>
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{formValues.method !== "GET" && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconInfoCircle size={15} />}>
|
||||
<Text size="xs">{t("preview.mutationDisabled")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{isSampleStale && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconAlertTriangle size={15} />}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.stale.title")}
|
||||
</Text>
|
||||
<Text size="xs">{t("preview.stale.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fetchResult && !fetchResult.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
@@ -126,79 +398,468 @@ export function CustomWidgetPreview({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) && (
|
||||
<Stack gap="xs">
|
||||
{fetchResult?.success && (
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{fetchResult.responseInfo?.status} {fetchResult.responseInfo?.statusText}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
<Tabs defaultValue="widget" keepMounted={false}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{t("preview.tab.widget")}</Tabs.Tab>
|
||||
<Tabs.Tab value="response">{t("preview.tab.response")}</Tabs.Tab>
|
||||
<Tabs.Tab value="request">{t("preview.tab.request")}</Tabs.Tab>
|
||||
<Tabs.Tab value="diagnostics">{t("preview.tab.diagnostics")}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mah={320} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Card>
|
||||
|
||||
{fetchResult?.success && fetchResult.rawResponse && (
|
||||
<Button
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], {
|
||||
type: "application/json",
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}}
|
||||
>
|
||||
{t("preview.rawResponse")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
fullWidth
|
||||
value={previewSize}
|
||||
onChange={setPreviewSize}
|
||||
data={(["compact", "standard", "wide"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`preview.size.${value}` as never),
|
||||
}))}
|
||||
/>
|
||||
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) ? (
|
||||
<>
|
||||
{fetchResult?.success && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={fetchResult.responseInfo ? "green" : "gray"}
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{fetchResult.responseInfo
|
||||
? `${fetchResult.responseInfo.status} ${fetchResult.responseInfo.statusText}`
|
||||
: t("preview.response.localSample")}
|
||||
</Badge>
|
||||
)}
|
||||
<Paper withBorder p="xs" h={previewHeight} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Center h={previewHeight}>
|
||||
<Stack gap={4} align="center" maw={280}>
|
||||
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("preview.empty.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{!displayData && !testError && !isTesting && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
)}
|
||||
<Tabs.Panel value="response" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{responseJson ? t("preview.response.sampleHint") : t("preview.response.empty")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{onSampleDataChange && (
|
||||
<Button size="compact-xs" variant="light" onClick={openSampleEditor}>
|
||||
{responseJson ? t("preview.response.editSample") : t("preview.response.addSample")}
|
||||
</Button>
|
||||
)}
|
||||
{responseJson && (
|
||||
<CopyButton value={responseJson}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("preview.response.copied") : t("preview.response.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse expanded={sampleEditorOpen}>
|
||||
<Paper p="sm" bg="var(--mantine-color-default-hover)">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label={t("preview.response.sampleLabel")}
|
||||
description={t("preview.response.sampleDescription")}
|
||||
value={sampleDraft}
|
||||
onChange={(event) => setSampleDraft(event.currentTarget.value)}
|
||||
error={sampleError}
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={18}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="xs" onClick={() => setSampleEditorOpen(false)}>
|
||||
{t("preview.response.cancelSample")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={applySampleData}
|
||||
disabled={!sampleDraft.trim() || Boolean(sampleError)}
|
||||
>
|
||||
{t("preview.response.applySample")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
{responseJson ? (
|
||||
<>
|
||||
<ScrollArea h={360} type="auto">
|
||||
<ResponseTree value={cachedJson} onInsertDataPath={onInsertDataPath} />
|
||||
</ScrollArea>
|
||||
<ResponseJsonViewer json={responseJson} label={t("preview.response.raw")} />
|
||||
{fetchResult?.rawResponse && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], { type: "application/json" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}}
|
||||
>
|
||||
{t("preview.rawResponse")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyPreview icon={<IconCode size={22} />} text={t("preview.response.empty")} />
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="request" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact label={t("preview.request.method")} value={formValues.method} />
|
||||
<RequestFact label={t("preview.request.authentication")} value={formValues.authType} />
|
||||
<RequestFact label={t("preview.request.endpoint")} value={redactUrl(formValues.url)} />
|
||||
<RequestFact
|
||||
label={t("preview.request.status")}
|
||||
value={
|
||||
fetchResult?.responseInfo ? String(fetchResult.responseInfo.status) : t("preview.request.notRun")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{namedRequests.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.named", { count: namedRequests.length })}
|
||||
</Text>
|
||||
{namedRequests.map((request) => (
|
||||
<Paper key={request.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{request.id}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
|
||||
{request.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={methodColor(request.method)}>
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t(`preview.request.permission.${request.minimumBoardPermission ?? "view"}` as never)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{journalQuery.data && journalQuery.data.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.journal")}
|
||||
</Text>
|
||||
{journalQuery.data.map((entry) => (
|
||||
<Paper key={entry.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{entry.requestId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" truncate>
|
||||
{entry.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColor(entry.method)} variant="light">
|
||||
{entry.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color={journalEntryColor(entry)}>
|
||||
{entry.simulated ? t("preview.request.simulated") : (entry.status ?? "—")}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" w={56} ta="right">
|
||||
{t("preview.request.duration", { duration: entry.durationMs })}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.request.journalEmpty")}
|
||||
</Text>
|
||||
)}
|
||||
<Alert color="blue" variant="light" p="xs" icon={<IconClipboard size={15} />}>
|
||||
<Text size="xs">{t("preview.request.redacted")}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.templateSize")}
|
||||
value={t("preview.diagnostics.characters", {
|
||||
count: (formValues.displayConfig.template as string | undefined)?.length ?? 0,
|
||||
})}
|
||||
/>
|
||||
<RequestFact label={t("preview.diagnostics.namedRequests")} value={String(namedRequests.length)} />
|
||||
<RequestFact label={t("preview.diagnostics.methods")} value={httpMethods.join(", ") || "GET"} />
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.networkScope")}
|
||||
value={
|
||||
(formValues.displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{templateDiagnostics.length === 0 ? (
|
||||
<Alert color="green" variant="light" p="xs" icon={<IconCheck size={15} />}>
|
||||
<Text size="xs">{t("preview.diagnostics.ready")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
templateDiagnostics.map((diagnostic, index) => (
|
||||
<Alert
|
||||
key={`${diagnostic.code}-${index}`}
|
||||
color={{ error: "red", warning: "yellow" }[diagnostic.severity] ?? "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{editorT(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: unknown }) {
|
||||
const typed = data as Record<string, unknown>;
|
||||
if (!typed || typeof typed !== "object") {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
No data
|
||||
function ResponseTree({ value, onInsertDataPath }: { value: unknown; onInsertDataPath?: (path: string) => void }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const data = useMemo(() => [createResponseTreeNode(value, "data", "data")], [value]);
|
||||
const tree = useTree({ initialExpandedState: getTreeExpandedState(data, ["data"]) });
|
||||
return (
|
||||
<Tree
|
||||
data={data}
|
||||
tree={tree}
|
||||
levelOffset={14}
|
||||
expandOnClick
|
||||
selectOnClick={false}
|
||||
renderNode={(payload) => (
|
||||
<ResponseTreeNode
|
||||
{...payload}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
copyLabel={t("preview.response.copyPath")}
|
||||
copiedLabel={t("preview.response.pathCopied")}
|
||||
insertLabel={t("preview.response.insertPath")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponseTreeData extends TreeNodeData {
|
||||
path: string;
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
function collectionDisplayValue(value: unknown, count: number): string {
|
||||
if (Array.isArray(value)) return `[${count}]`;
|
||||
return `{${count}}`;
|
||||
}
|
||||
|
||||
function createResponseTreeNode(value: unknown, path: string, label: string): ResponseTreeData {
|
||||
const isCollection = value !== null && typeof value === "object";
|
||||
const entries = isCollection ? Object.entries(value as Record<string, unknown>) : [];
|
||||
return {
|
||||
value: path,
|
||||
label,
|
||||
path,
|
||||
displayValue: isCollection ? collectionDisplayValue(value, entries.length) : formatTreeValue(value),
|
||||
children: entries.map(([key, childValue]) =>
|
||||
createResponseTreeNode(childValue, appendDataPath(path, key, Array.isArray(value)), key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function ResponseTreeNode({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
onInsertDataPath,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
insertLabel,
|
||||
}: RenderTreeNodePayload & {
|
||||
onInsertDataPath?: (path: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
insertLabel: string;
|
||||
}) {
|
||||
const responseNode = node as ResponseTreeData;
|
||||
return (
|
||||
<Group {...elementProps} gap={4} wrap="nowrap" mih={28} className="response-tree-row">
|
||||
<div style={{ width: 16, flexShrink: 0 }}>
|
||||
{hasChildren && (expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />)}
|
||||
</div>
|
||||
<Code style={{ flex: "0 1 auto", overflow: "hidden", textOverflow: "ellipsis", fontSize: 11 }}>
|
||||
{responseNode.label}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||
{responseNode.displayValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
<Group gap={2} wrap="nowrap" className="response-tree-actions">
|
||||
<CopyButton value={responseNode.path}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={copied ? "green" : "gray"}
|
||||
size={24}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
{onInsertDataPath && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size={24}
|
||||
aria-label={insertLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onInsertDataPath(responseNode.path);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function appendDataPath(parent: string, key: string, isArray: boolean) {
|
||||
if (isArray) return `${parent}[${key}]`;
|
||||
return /^[A-Za-z_$][\w$]*$/u.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
function formatTreeValue(value: unknown) {
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (value === undefined) return "undefined";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const JSON_EXTENSIONS = [json()];
|
||||
|
||||
function ResponseJsonViewer({ json: jsonStr, label }: { json: string; label: string }) {
|
||||
const colorScheme = useComputedColorScheme();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Button size="compact-xs" variant="subtle" onClick={() => setExpanded((v) => !v)}>
|
||||
{label}
|
||||
</Button>
|
||||
{expanded && (
|
||||
<JsonEditor
|
||||
value={jsonStr}
|
||||
readOnly
|
||||
editable={false}
|
||||
extensions={JSON_EXTENSIONS}
|
||||
height="280px"
|
||||
theme={colorScheme === "dark" ? "dark" : "light"}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: false }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPreview({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<Center h={240}>
|
||||
<Stack align="center" gap="xs" c="dimmed">
|
||||
{icon}
|
||||
<Text size="xs" ta="center">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} lineClamp={2}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: unknown }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const typed = data as Record<string, unknown>;
|
||||
if (!typed || typeof typed !== "object") return null;
|
||||
|
||||
const dataType = typed.type as string | undefined;
|
||||
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm">
|
||||
<Center p="sm" h="100%">
|
||||
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
|
||||
{(typed.buttonLabel as string) ?? "Execute"}
|
||||
{(typed.buttonLabel as string) ?? t("preview.execute")}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) {
|
||||
return <Component data={typed} />;
|
||||
}
|
||||
if (Component) return <Component data={typed} />;
|
||||
|
||||
return (
|
||||
<ScrollArea h={100}>
|
||||
<ScrollArea h="100%">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
|
||||
@@ -62,6 +62,9 @@ function buildDisplayInitialValues(displayType: string, config: Record<string, u
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: "",
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
@@ -137,6 +140,9 @@ function buildDisplayInitialValues(displayType: string, config: Record<string, u
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template: (config.template as string) ?? "",
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? def.url}
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"superjson": "catalog:",
|
||||
"trpc-to-mcp": "catalog:",
|
||||
"trpc-to-openapi": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
downloads: downloadsRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
export const AUTH_HANDLERS: Record<string, (headers: Headers, url: URL, apiKey: string, headerName?: string) => void> =
|
||||
{
|
||||
bearer: (headers, _url, apiKey) => {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
},
|
||||
apiKeyHeader: (headers, _url, apiKey, headerName) => {
|
||||
headers.set(headerName ?? "X-API-Key", apiKey);
|
||||
},
|
||||
apiKeyQuery: (_headers, url, apiKey, headerName) => {
|
||||
url.searchParams.set(headerName ?? "api_key", apiKey);
|
||||
},
|
||||
};
|
||||
type HeaderSetter = Pick<Headers, "set">;
|
||||
|
||||
export const AUTH_HANDLERS: Record<
|
||||
string,
|
||||
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
|
||||
> = {
|
||||
bearer: (headers, _url, apiKey) => {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
},
|
||||
apiKeyHeader: (headers, _url, apiKey, headerName) => {
|
||||
headers.set(headerName ?? "X-API-Key", apiKey);
|
||||
},
|
||||
apiKeyQuery: (_headers, url, apiKey, headerName) => {
|
||||
url.searchParams.set(headerName ?? "api_key", apiKey);
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -20,7 +24,7 @@ export function buildSecretMap(secrets: Array<{ kind: string; value: string }>):
|
||||
}
|
||||
|
||||
export function applyAuth(
|
||||
headers: Headers,
|
||||
headers: HeaderSetter,
|
||||
url: URL,
|
||||
authType: string,
|
||||
secrets: Array<{ kind: string; value: string }>,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -122,6 +122,16 @@ const extractors: Record<string, Extractor> = {
|
||||
type: "customJsx" as const,
|
||||
template: config.template as string,
|
||||
data: json,
|
||||
jsxApiVersion: config.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestCapabilities:
|
||||
config.jsxApiVersion === 2 && Array.isArray(config.requests)
|
||||
? (config.requests as Array<Record<string, unknown>>).map((request) => ({
|
||||
id: request.id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
minimumBoardPermission: request.minimumBoardPermission,
|
||||
}))
|
||||
: [],
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import {
|
||||
customJsxNetworkScopes,
|
||||
customJsxRequestSchema,
|
||||
customWidgetAuthTypes,
|
||||
customWidgetSecretKinds,
|
||||
} from "@homarr/validation/custom-widget";
|
||||
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "@homarr/validation/custom-widget";
|
||||
|
||||
const PREVIEW_SESSION_TTL_MS = 5 * 60_000;
|
||||
const PREVIEW_SESSION_PREFIX = "custom-widget:preview-session:";
|
||||
const PREVIEW_JOURNAL_PREFIX = "custom-widget:preview-journal:";
|
||||
const MAX_PREVIEW_JOURNAL_ENTRIES = 50;
|
||||
|
||||
const storedPreviewSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.number(),
|
||||
baseUrl: z.string(),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().nullable(),
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
|
||||
networkScope: z.enum(customJsxNetworkScopes),
|
||||
requests: z.array(customJsxRequestSchema),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof storedPreviewSessionSchema>;
|
||||
|
||||
const previewJournalEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
requestId: z.string(),
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
pathTemplate: z.string(),
|
||||
status: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
simulated: z.boolean(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type CustomWidgetPreviewJournalEntry = z.infer<typeof previewJournalEntrySchema>;
|
||||
|
||||
export interface CreatePreviewSessionInput {
|
||||
userId: string;
|
||||
baseUrl: string;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
secrets: Array<{ kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
requests: CustomJsxRequest[];
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const localSessions = new Map<string, CustomWidgetPreviewSession>();
|
||||
const localJournals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
|
||||
|
||||
const getRedisClient = () => {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
|
||||
redisClient ??= createRedisClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
const sessionKey = (id: string) => `${PREVIEW_SESSION_PREFIX}${id}`;
|
||||
const journalKey = (id: string) => `${PREVIEW_JOURNAL_PREFIX}${id}`;
|
||||
|
||||
const saveSession = async (session: CustomWidgetPreviewSession) => {
|
||||
const ttlMs = Math.max(0, session.expiresAt - Date.now());
|
||||
if (ttlMs === 0) throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
localSessions.set(session.id, session);
|
||||
return;
|
||||
}
|
||||
await redis.set(sessionKey(session.id), JSON.stringify(session), "PX", ttlMs);
|
||||
};
|
||||
|
||||
export const createPreviewSession = async (input: CreatePreviewSessionInput) => {
|
||||
const session: CustomWidgetPreviewSession = {
|
||||
id: createId(),
|
||||
userId: input.userId,
|
||||
expiresAt: Date.now() + PREVIEW_SESSION_TTL_MS,
|
||||
baseUrl: input.baseUrl,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName ?? null,
|
||||
secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: encryptSecret(secret.value) })),
|
||||
networkScope: input.networkScope,
|
||||
requests: input.requests,
|
||||
definitionId: input.definitionId,
|
||||
liveActions: false,
|
||||
};
|
||||
await saveSession(session);
|
||||
return { id: session.id, expiresAt: session.expiresAt, liveActions: false as const };
|
||||
};
|
||||
|
||||
export const getPreviewSession = async (id: string, userId: string): Promise<CustomWidgetPreviewSession> => {
|
||||
const redis = getRedisClient();
|
||||
const raw = redis ? await redis.get(sessionKey(id)) : localSessions.get(id);
|
||||
let candidate: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
candidate = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
candidate = null;
|
||||
}
|
||||
}
|
||||
const parsed = storedPreviewSessionSchema.safeParse(candidate);
|
||||
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= Date.now()) {
|
||||
if (!redis) localSessions.delete(id);
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export const setPreviewSessionLiveActions = async (id: string, userId: string, enabled: boolean) => {
|
||||
const session = await getPreviewSession(id, userId);
|
||||
const updated = { ...session, liveActions: enabled };
|
||||
await saveSession(updated);
|
||||
return { id, expiresAt: updated.expiresAt, liveActions: enabled };
|
||||
};
|
||||
|
||||
export const appendPreviewJournal = async (
|
||||
session: CustomWidgetPreviewSession,
|
||||
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
||||
) => {
|
||||
const value = previewJournalEntrySchema.parse({ ...entry, id: createId(), timestamp: Date.now() });
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
localJournals.set(
|
||||
session.id,
|
||||
[value, ...(localJournals.get(session.id) ?? [])].slice(0, MAX_PREVIEW_JOURNAL_ENTRIES),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ttlMs = Math.max(1, session.expiresAt - Date.now());
|
||||
await redis
|
||||
.multi()
|
||||
.lpush(journalKey(session.id), JSON.stringify(value))
|
||||
.ltrim(journalKey(session.id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
|
||||
.pexpire(journalKey(session.id), ttlMs)
|
||||
.exec();
|
||||
};
|
||||
|
||||
export const getPreviewJournal = async (id: string, userId: string) => {
|
||||
await getPreviewSession(id, userId);
|
||||
const redis = getRedisClient();
|
||||
const entries = redis
|
||||
? await redis.lrange(journalKey(id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
|
||||
: (localJournals.get(id) ?? []);
|
||||
return entries.flatMap((entry) => {
|
||||
let candidate: unknown = entry;
|
||||
if (typeof entry === "string") {
|
||||
try {
|
||||
candidate = JSON.parse(entry) as unknown;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const parsed = previewJournalEntrySchema.safeParse(candidate);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
});
|
||||
};
|
||||
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) =>
|
||||
session.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: decryptSecret(secret.value as `${string}.${string}`),
|
||||
}));
|
||||
@@ -0,0 +1,429 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Agent, fetch, Headers } from "undici";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import type { Response } from "undici";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { applyAuth } from "./auth";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:http" });
|
||||
|
||||
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
||||
export const MAX_RESPONSE_JSON_DEPTH = 32;
|
||||
export const MAX_RESPONSE_JSON_NODES = 50_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
|
||||
const RESERVED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
]);
|
||||
|
||||
const alwaysBlockedAddresses = new BlockList();
|
||||
alwaysBlockedAddresses.addSubnet("0.0.0.0", 8, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("100.64.0.0", 10, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.0.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.2.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.18.0.0", 15, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.51.100.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("203.0.113.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("224.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("240.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addAddress("::", "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("64:ff9b::", 96, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("100::", 64, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("2001:db8::", 32, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("fe80::", 10, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("ff00::", 8, "ipv6");
|
||||
alwaysBlockedAddresses.addAddress("fd00:ec2::254", "ipv6");
|
||||
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
|
||||
const loopbackAddresses = new BlockList();
|
||||
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
loopbackAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
type AddressFamily = 4 | 6;
|
||||
type ResolvedAddress = { address: string; family: AddressFamily };
|
||||
type AddressClass = "public" | "private" | "loopback" | "blocked";
|
||||
|
||||
export interface CustomWidgetAuthConfig {
|
||||
type: string;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
headerName?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
baseUrl: string;
|
||||
targetUrl?: string | URL;
|
||||
method: CustomWidgetMethod;
|
||||
body?: string;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const responseCache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||
const inFlightRequests = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
|
||||
|
||||
const pruneResponseCache = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of responseCache) {
|
||||
if (entry.expiresAt <= now) responseCache.delete(key);
|
||||
}
|
||||
while (responseCache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
|
||||
const oldestKey = responseCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
responseCache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHostname = (hostname: string) =>
|
||||
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
|
||||
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
|
||||
|
||||
export const classifyAddress = (address: string): AddressClass => {
|
||||
const normalized = normalizeHostname(address).toLowerCase();
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
if (family === 0) return "blocked";
|
||||
|
||||
if (normalized.includes("::ffff:")) return "blocked";
|
||||
if (alwaysBlockedAddresses.check(normalized, familyName(family))) return "blocked";
|
||||
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
|
||||
if (privateAddresses.check(normalized, familyName(family))) return "private";
|
||||
return "public";
|
||||
};
|
||||
|
||||
const isAddressAllowed = (classification: AddressClass, scope: CustomJsxNetworkScope) => {
|
||||
if (classification === "blocked") return false;
|
||||
if (classification === "public") return true;
|
||||
if (classification === "private") return scope === "private" || scope === "loopback";
|
||||
return scope === "loopback";
|
||||
};
|
||||
|
||||
export const resolveAndValidateHost = async (
|
||||
hostname: string,
|
||||
scope: CustomJsxNetworkScope,
|
||||
): Promise<ResolvedAddress[]> => {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const literalFamily = isIP(normalized) as AddressFamily | 0;
|
||||
const addresses = literalFamily
|
||||
? [{ address: normalized, family: literalFamily }]
|
||||
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
|
||||
}
|
||||
|
||||
for (const resolved of addresses) {
|
||||
const classification = classifyAddress(resolved.address);
|
||||
if (!isAddressAllowed(classification, scope)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Target address is not allowed by the ${scope} network scope`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
};
|
||||
|
||||
export const validateCustomWidgetUrl = (value: string | URL): URL => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = value instanceof URL ? new URL(value) : new URL(value);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
|
||||
}
|
||||
if (url.hash) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const resolveSameOriginTarget = (baseUrlValue: string, targetUrlValue?: string | URL): URL => {
|
||||
const baseUrl = validateCustomWidgetUrl(baseUrlValue);
|
||||
const targetUrl = validateCustomWidgetUrl(targetUrlValue ?? baseUrl);
|
||||
if (targetUrl.origin !== baseUrl.origin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Named requests must stay on the widget's origin" });
|
||||
}
|
||||
return targetUrl;
|
||||
};
|
||||
|
||||
export const assertSafeStaticHeaders = (headers: Record<string, string> | undefined): void => {
|
||||
for (const name of Object.keys(headers ?? {})) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (
|
||||
RESERVED_HEADERS.has(normalized) ||
|
||||
normalized.startsWith("proxy-") ||
|
||||
normalized.startsWith("sec-") ||
|
||||
normalized.startsWith("x-forwarded-")
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createPinnedAgent = (addresses: ResolvedAddress[]) => {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = requestedFamily ? addresses.filter((address) => address.family === requestedFamily) : addresses;
|
||||
const selected = candidates[0];
|
||||
if (!selected) {
|
||||
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
return;
|
||||
}
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
|
||||
return new Agent({
|
||||
connect: { lookup: customLookup },
|
||||
connectTimeout: REQUEST_TIMEOUT_MS,
|
||||
headersTimeout: REQUEST_TIMEOUT_MS,
|
||||
bodyTimeout: REQUEST_TIMEOUT_MS,
|
||||
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
||||
});
|
||||
};
|
||||
|
||||
const readLimitedBody = async (response: Response): Promise<string> => {
|
||||
const contentLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
totalBytes += chunk.value.byteLength;
|
||||
if (totalBytes > MAX_RESPONSE_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
};
|
||||
|
||||
export const assertJsonBudget = (value: unknown): void => {
|
||||
let nodes = 0;
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
nodes += 1;
|
||||
if (nodes > MAX_RESPONSE_JSON_NODES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
|
||||
}
|
||||
if (depth > MAX_RESPONSE_JSON_DEPTH) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (const entry of current) visit(entry, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (current !== null && typeof current === "object") {
|
||||
for (const entry of Object.values(current)) visit(entry, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, 0);
|
||||
};
|
||||
|
||||
const parseResponseBody = async (response: Response): Promise<unknown> => {
|
||||
const text = await readLimitedBody(response);
|
||||
if (text === "") return null;
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.includes("json")) {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text) as unknown;
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const performRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
if (input.kind === "query" && input.method !== "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Queries must use GET" });
|
||||
}
|
||||
if (input.kind === "action" && input.method === "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
|
||||
}
|
||||
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
|
||||
}
|
||||
assertSafeStaticHeaders(input.staticHeaders);
|
||||
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
const initialOrigin = baseUrl.origin;
|
||||
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
||||
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
const addresses = await resolveAndValidateHost(currentUrl.hostname, input.networkScope);
|
||||
const dispatcher = createPinnedAgent(addresses);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
|
||||
if (input.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (input.auth) {
|
||||
if (input.auth.type === "apiKeyHeader") {
|
||||
assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||
}
|
||||
applyAuth(headers, currentUrl, input.auth.type, input.auth.secrets, input.auth.headerName);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(currentUrl, {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method === "GET" ? undefined : input.body,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget request failed", {
|
||||
origin: currentUrl.origin,
|
||||
method: input.method,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: error instanceof Error ? error.message : "External request failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const isRedirect = [301, 302, 303, 307, 308].includes(response.status);
|
||||
if (!isRedirect) {
|
||||
try {
|
||||
const data = await parseResponseBody(response);
|
||||
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
await response.body?.cancel();
|
||||
await dispatcher.close();
|
||||
if (redirectCount >= maxRedirects) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
|
||||
}
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
|
||||
}
|
||||
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
|
||||
if (redirected.origin !== initialOrigin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
|
||||
}
|
||||
currentUrl = redirected;
|
||||
}
|
||||
};
|
||||
|
||||
export const executeCustomWidgetRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
const cacheKey = input.method === "GET" ? input.cacheKey : undefined;
|
||||
if (!cacheKey) return performRequest(input);
|
||||
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.response;
|
||||
if (cached) responseCache.delete(cacheKey);
|
||||
|
||||
const inFlight = inFlightRequests.get(cacheKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = performRequest(input)
|
||||
.then((response) => {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
|
||||
pruneResponseCache();
|
||||
responseCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000,
|
||||
response,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.finally(() => inFlightRequests.delete(cacheKey));
|
||||
inFlightRequests.set(cacheKey, promise);
|
||||
return promise;
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:limits" });
|
||||
const WINDOW_MS = 60_000;
|
||||
const CONCURRENCY_TTL_MS = 30_000;
|
||||
|
||||
const categoryLimits = {
|
||||
query: 60,
|
||||
action: 10,
|
||||
delete: 3,
|
||||
} as const;
|
||||
|
||||
type RequestCategory = keyof typeof categoryLimits;
|
||||
|
||||
export interface RequestLimitInput {
|
||||
category: RequestCategory;
|
||||
userId?: string;
|
||||
itemId: string;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
class RequestLimitError extends FlattenError {
|
||||
constructor(message: string, retryAfterMs: number) {
|
||||
super(message, { retryAfterMs });
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const getRedisClient = () => {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
|
||||
redisClient ??= createRedisClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
|
||||
|
||||
const rateScript = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
return { count, redis.call('PTTL', KEYS[1]) }
|
||||
`;
|
||||
|
||||
const concurrencyScript = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
||||
if count > tonumber(ARGV[1]) then
|
||||
redis.call('DECR', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
return count
|
||||
`;
|
||||
|
||||
const releaseConcurrencyScript = `
|
||||
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if count <= 1 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
return redis.call('DECR', KEYS[1])
|
||||
`;
|
||||
|
||||
const localRateBuckets = new Map<string, number[]>();
|
||||
const localConcurrency = new Map<string, number>();
|
||||
|
||||
const throwLimitExceeded = (retryAfterMs: number): never => {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Custom widget request limit exceeded",
|
||||
cause: new RequestLimitError("Custom widget request limit exceeded", Math.max(1, retryAfterMs)),
|
||||
});
|
||||
};
|
||||
|
||||
const enforceLocalRateLimit = (key: string, limit: number) => {
|
||||
const now = Date.now();
|
||||
const recent = (localRateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
|
||||
if (recent.length >= limit) {
|
||||
throwLimitExceeded(WINDOW_MS - (now - (recent[0] ?? now)));
|
||||
}
|
||||
recent.push(now);
|
||||
localRateBuckets.set(key, recent);
|
||||
};
|
||||
|
||||
const acquireLocalConcurrency = (key: string, limit: number) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current >= limit) throwLimitExceeded(1_000);
|
||||
localConcurrency.set(key, current + 1);
|
||||
};
|
||||
|
||||
const releaseLocalConcurrency = (key: string) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current <= 1) localConcurrency.delete(key);
|
||||
else localConcurrency.set(key, current - 1);
|
||||
};
|
||||
|
||||
const enforceRedisRateLimit = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const result = (await redis.eval(rateScript, 1, key, WINDOW_MS)) as [number, number];
|
||||
const [count, ttl] = result.map(Number) as [number, number];
|
||||
if (count > limit) throwLimitExceeded(ttl > 0 ? ttl : WINDOW_MS);
|
||||
};
|
||||
|
||||
const acquireRedisConcurrency = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const count = Number(await redis.eval(concurrencyScript, 1, key, limit, CONCURRENCY_TTL_MS));
|
||||
if (count === 0) throwLimitExceeded(1_000);
|
||||
};
|
||||
|
||||
const releaseRedisConcurrency = async (redis: RedisClient, key: string) => {
|
||||
await redis.eval(releaseConcurrencyScript, 1, key);
|
||||
};
|
||||
|
||||
export const acquireCustomWidgetRequestLimit = async (input: RequestLimitInput): Promise<() => Promise<void>> => {
|
||||
const identity = input.userId ?? "anonymous";
|
||||
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
|
||||
const userConcurrencyKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionConcurrencyKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
|
||||
const redis = getRedisClient();
|
||||
|
||||
if (!redis) {
|
||||
enforceLocalRateLimit(rateKey, categoryLimits[input.category]);
|
||||
acquireLocalConcurrency(userConcurrencyKey, 4);
|
||||
try {
|
||||
acquireLocalConcurrency(definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
releaseLocalConcurrency(definitionConcurrencyKey);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await enforceRedisRateLimit(redis, rateKey, categoryLimits[input.category]);
|
||||
await acquireRedisConcurrency(redis, userConcurrencyKey, 4);
|
||||
try {
|
||||
await acquireRedisConcurrency(redis, definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
await releaseRedisConcurrency(redis, userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget rate limiter is unavailable", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Request limiter is unavailable" });
|
||||
}
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
releaseRedisConcurrency(redis, userConcurrencyKey),
|
||||
releaseRedisConcurrency(redis, definitionConcurrencyKey),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error("Failed to release custom widget request capacity", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
|
||||
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
|
||||
|
||||
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
const declaredNames = Object.keys(request.parameters).toSorted();
|
||||
const suppliedNames = Object.keys(params).toSorted();
|
||||
if (
|
||||
declaredNames.length !== suppliedNames.length ||
|
||||
declaredNames.some((name, index) => name !== suppliedNames[index])
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
|
||||
}
|
||||
for (const [name, expectedType] of Object.entries(request.parameters)) {
|
||||
if (typeof params[name] !== expectedType) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Request parameter '${name}' must be ${expectedType}` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const renderRequestBody = (
|
||||
template: CustomJsxRequest["bodyTemplate"],
|
||||
params: CustomJsxRuntimeParams,
|
||||
): string | undefined => {
|
||||
if (template === undefined) return undefined;
|
||||
const substitute = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(substitute);
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
const parameter = params[entries[0][1]];
|
||||
if (parameter === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown body parameter '${entries[0][1]}'` });
|
||||
}
|
||||
return parameter;
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(substitute(template));
|
||||
};
|
||||
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
validateRuntimeParams(request, params);
|
||||
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
|
||||
const value = params[name];
|
||||
if (value === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
|
||||
}
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
if (/[{}]/.test(path)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Path template contains an invalid placeholder" });
|
||||
}
|
||||
return resolveSameOriginTarget(baseUrl, new URL(path, baseUrl));
|
||||
};
|
||||
|
||||
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
|
||||
const stableParams = Object.fromEntries(
|
||||
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
createPreviewSession,
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "../../custom-widget/preview-sessions";
|
||||
|
||||
const queryRequest = {
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{id}",
|
||||
parameters: { id: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
} as const;
|
||||
|
||||
describe("custom widget preview sessions", () => {
|
||||
test("stores encrypted credentials in a short-lived user-bound manifest", async () => {
|
||||
const created = await createPreviewSession({
|
||||
userId: "admin-1",
|
||||
baseUrl: "https://example.com/api",
|
||||
authType: "bearer",
|
||||
secrets: [{ kind: "apiKey", value: "plain-secret" }],
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
});
|
||||
|
||||
const session = await getPreviewSession(created.id, "admin-1");
|
||||
|
||||
expect(session.secrets[0]?.value).not.toContain("plain-secret");
|
||||
expect(getPreviewSessionSecrets(session)).toEqual([{ kind: "apiKey", value: "plain-secret" }]);
|
||||
await expect(getPreviewSession(created.id, "admin-2")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
test("keeps actions simulated until explicitly enabled", async () => {
|
||||
const created = await createPreviewSession({
|
||||
userId: "admin-1",
|
||||
baseUrl: "https://example.com/api",
|
||||
authType: "none",
|
||||
secrets: [],
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
});
|
||||
|
||||
expect((await getPreviewSession(created.id, "admin-1")).liveActions).toBe(false);
|
||||
await setPreviewSessionLiveActions(created.id, "admin-1", true);
|
||||
const session = await getPreviewSession(created.id, "admin-1");
|
||||
expect(session.liveActions).toBe(true);
|
||||
await appendPreviewJournal(session, {
|
||||
requestId: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{id}",
|
||||
status: 200,
|
||||
durationMs: 12,
|
||||
simulated: false,
|
||||
});
|
||||
expect(await getPreviewJournal(created.id, "admin-1")).toEqual([
|
||||
expect.objectContaining({ requestId: "status", pathTemplate: "/status/{id}", durationMs: 12 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
assertJsonBudget,
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "../../custom-widget/request-executor";
|
||||
|
||||
describe("custom widget request executor", () => {
|
||||
test.each([
|
||||
["8.8.8.8", "public"],
|
||||
["10.0.0.1", "private"],
|
||||
["127.0.0.1", "loopback"],
|
||||
["169.254.169.254", "blocked"],
|
||||
["224.0.0.1", "blocked"],
|
||||
["::ffff:127.0.0.1", "blocked"],
|
||||
["fe80::1", "blocked"],
|
||||
] as const)("classifies %s as %s", (address, expected) => {
|
||||
expect(classifyAddress(address)).toBe(expected);
|
||||
});
|
||||
|
||||
test("enforces configured address scopes", async () => {
|
||||
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
|
||||
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
|
||||
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows only credential-free HTTP(S) URLs without fragments", () => {
|
||||
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
|
||||
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("Only HTTP and HTTPS");
|
||||
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
|
||||
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
|
||||
});
|
||||
|
||||
test("rejects cross-origin named request targets", () => {
|
||||
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
|
||||
expect(() => resolveSameOriginTarget("https://example.com/base", "https://attacker.example/status")).toThrow(
|
||||
"origin",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["Authorization", "Cookie", "Host", "Proxy-Authorization", "Sec-Fetch-Site", "X-Forwarded-For"])(
|
||||
"rejects the reserved %s header",
|
||||
(header) => {
|
||||
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects response JSON that exceeds the depth budget", () => {
|
||||
let nested: Record<string, unknown> = {};
|
||||
const root = nested;
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
nested.child = {};
|
||||
nested = nested.child as Record<string, unknown>;
|
||||
}
|
||||
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import { acquireCustomWidgetRequestLimit } from "../../custom-widget/request-limits";
|
||||
|
||||
test("enforces the server-side DELETE rate limit with retry metadata", async () => {
|
||||
const input = { category: "delete" as const, userId: createId(), itemId: createId(), definitionId: createId() };
|
||||
for (let count = 0; count < 3; count += 1) {
|
||||
const release = await acquireCustomWidgetRequestLimit(input);
|
||||
await release();
|
||||
}
|
||||
|
||||
try {
|
||||
await acquireCustomWidgetRequestLimit(input);
|
||||
expect.unreachable("the fourth DELETE request should be rate limited");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
||||
const cause = (error as { cause?: { flatten?: () => Record<string, unknown> } }).cause;
|
||||
expect(cause?.flatten?.()).toMatchObject({ retryAfterMs: expect.any(Number) });
|
||||
}
|
||||
});
|
||||
|
||||
test("enforces four concurrent requests per user and item", async () => {
|
||||
const input = { category: "query" as const, userId: createId(), itemId: createId(), definitionId: createId() };
|
||||
const releases = await Promise.all(Array.from({ length: 4 }, () => acquireCustomWidgetRequestLimit(input)));
|
||||
|
||||
await expect(acquireCustomWidgetRequestLimit(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
||||
await Promise.all(releases.map((release) => release()));
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-executor", () => ({
|
||||
executeCustomWidgetRequest: mocks.executeRequest,
|
||||
resolveSameOriginTarget: (_baseUrl: string, targetUrl: string | URL) => new URL(targetUrl),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { customApiRouter } from "../../widgets/custom-api";
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
const queryRequest = {
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
} as const;
|
||||
|
||||
const actionRequest = {
|
||||
id: "toggle",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/toggle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
} as const;
|
||||
|
||||
const deleteRequest = {
|
||||
id: "delete",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/resource",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "full",
|
||||
} as const;
|
||||
|
||||
const setup = async ({
|
||||
isPublic = true,
|
||||
method = "GET",
|
||||
requests = [queryRequest, actionRequest, deleteRequest],
|
||||
}: {
|
||||
isPublic?: boolean;
|
||||
method?: "GET" | "POST";
|
||||
requests?: readonly Record<string, unknown>[];
|
||||
} = {}) => {
|
||||
const db = createDb();
|
||||
const ownerId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
const definitionId = createId();
|
||||
await db.insert(users).values({ id: ownerId });
|
||||
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: definitionId,
|
||||
name: "Custom JSX",
|
||||
url: "https://example.com/api",
|
||||
method,
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text>{data.value}</Text>",
|
||||
networkScope: "public",
|
||||
requests,
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringifySuperJson({ definitionId, refreshInterval: 30 }),
|
||||
});
|
||||
return { db, ownerId, boardId, itemId, definitionId };
|
||||
};
|
||||
|
||||
describe("customApiRouter v2", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("does not expose the removed raw subFetch procedure", () => {
|
||||
expect(Object.hasOwn(customApiRouter._def.procedures, "subFetch")).toBe(false);
|
||||
});
|
||||
|
||||
test("allows anonymous queries only through a public board placement", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
const result = await caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: 200, data: { value: 42 } });
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("does not expose a placed definition through an inaccessible board", async () => {
|
||||
const { db, itemId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not execute non-GET base data requests automatically", async () => {
|
||||
const { db, itemId } = await setup({ method: "POST" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).resolves.toEqual({ type: "networkAccessNeedsReview" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot select a request from a definition that is not attached to the item", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ requests: [] });
|
||||
const otherDefinitionId = createId();
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: otherDefinitionId,
|
||||
name: "Other",
|
||||
url: "https://example.com/api",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text />",
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(
|
||||
caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects mismatched request kinds and typed parameters", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects disabled definitions before any network request", async () => {
|
||||
const { db, itemId, definitionId } = await setup();
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, definitionId));
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires modify permission for named actions", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const viewerId = createId();
|
||||
await db.insert(users).values({ id: viewerId });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(viewerId) });
|
||||
|
||||
await expect(
|
||||
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("executes an authorized named action once with typed body substitution", async () => {
|
||||
const { db, itemId, boardId } = await setup({ isPublic: false });
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
|
||||
const result = await caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
expect(mocks.executeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires explicit confirmation before DELETE", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(caller.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,213 +1,271 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
import { applyAuth } from "../custom-widget/auth";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { customJsxDisplayConfigV2Schema, displayConfigSchema } from "@homarr/validation/custom-widget";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
|
||||
import { executeCustomWidgetRequest } from "../custom-widget/request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
|
||||
|
||||
const logger = createLogger({ module: "widget:customApi" });
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_BODY_SIZE = 10_000;
|
||||
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
||||
const itemInputSchema = z.object({ itemId: z.string().min(1) });
|
||||
const namedRequestInputSchema = itemInputSchema.extend({
|
||||
requestId: z.string().min(1).max(64),
|
||||
params: runtimeParamsSchema.default({}),
|
||||
});
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
|
||||
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
|
||||
|
||||
const validateUrl = (urlString: string): URL => new URL(urlString);
|
||||
const parseItemOptions = (raw: string): { definitionId: string } => {
|
||||
try {
|
||||
const options = parseSuperJson(raw) as Record<string, unknown>;
|
||||
if (typeof options.definitionId !== "string" || options.definitionId.length === 0) throw new Error();
|
||||
return { definitionId: options.definitionId };
|
||||
} catch {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTargetUrl = (definitionUrl: string, targetUrl: string): URL =>
|
||||
new URL(targetUrl, validateUrl(definitionUrl));
|
||||
|
||||
const assertSameOrigin = (definitionUrl: string, targetUrl: URL): void => {
|
||||
const definitionOrigin = validateUrl(definitionUrl).origin;
|
||||
if (targetUrl.origin !== definitionOrigin) {
|
||||
const parseDisplayConfig = (raw: string): DisplayConfig => {
|
||||
try {
|
||||
return displayConfigSchema.parse(parseSuperJson(raw));
|
||||
} catch (error) {
|
||||
logger.error("Invalid custom widget display configuration", { error });
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "URL must be on the same domain as the widget definition",
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "Custom widget network access needs review",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const parseResponseData = async (response: Response): Promise<unknown> => {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json();
|
||||
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: eq(items.id, itemId),
|
||||
columns: { id: true, boardId: true, kind: true, options: true },
|
||||
});
|
||||
if (!item || item.kind !== "customApi") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
|
||||
}
|
||||
const text = await response.text();
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
|
||||
const { definitionId } = parseItemOptions(item.options);
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
if (!definition.enabled) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
}
|
||||
|
||||
return { item, definition, displayConfig: parseDisplayConfig(definition.displayConfig) };
|
||||
}
|
||||
|
||||
const getDecryptedSecrets = (resolved: ResolvedDefinition) =>
|
||||
resolved.definition.secrets.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.value) }));
|
||||
|
||||
const getAuth = (resolved: ResolvedDefinition, mode: "inherit" | "none" = "inherit") =>
|
||||
mode === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: resolved.definition.authType,
|
||||
secrets: getDecryptedSecrets(resolved),
|
||||
headerName: resolved.definition.headerName,
|
||||
};
|
||||
|
||||
const getNetworkScope = (displayConfig: DisplayConfig) =>
|
||||
displayConfig.type === "customJsx" && "jsxApiVersion" in displayConfig && displayConfig.jsxApiVersion === 2
|
||||
? displayConfig.networkScope
|
||||
: ("private" as const);
|
||||
|
||||
const getV2Config = (resolved: ResolvedDefinition) => {
|
||||
const parsed = customJsxDisplayConfigV2Schema.safeParse(resolved.displayConfig);
|
||||
if (!parsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "This template must be reviewed and migrated to named requests",
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const findNamedRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
|
||||
const request = getV2Config(resolved).requests.find((candidate) => candidate.id === requestId);
|
||||
if (!request || request.kind !== kind) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
|
||||
}
|
||||
return request;
|
||||
};
|
||||
|
||||
const getCacheKey = (
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
const digest = hashRuntimeParams(params);
|
||||
return `custom-jsx:${resolved.item.id}:${request.id}:${digest}`;
|
||||
};
|
||||
|
||||
const assertUpstreamSuccess = (response: { ok: boolean; status: number; statusText: string }) => {
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: `API returned ${response.status}: ${response.statusText}` });
|
||||
}
|
||||
};
|
||||
|
||||
const withRequestLimit = async <T>(
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
category: "query" | "action" | "delete",
|
||||
callback: () => Promise<T>,
|
||||
) => {
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category,
|
||||
userId: ctx.session?.user.id,
|
||||
itemId: resolved.item.id,
|
||||
definitionId: resolved.definition.id,
|
||||
});
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
return await callback();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
};
|
||||
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
if (!definition.enabled) {
|
||||
return { type: "disabled" };
|
||||
}
|
||||
|
||||
let displayConfig: Record<string, unknown>;
|
||||
try {
|
||||
displayConfig = superjson.parse(definition.displayConfig) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
|
||||
}
|
||||
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const { definition, displayConfig } = resolved;
|
||||
|
||||
if (definition.displayType === "actionButton") {
|
||||
return extractActionButtonDisplay(displayConfig);
|
||||
return {
|
||||
...(extractActionButtonDisplay(displayConfig) as Record<string, unknown>),
|
||||
requiresConfirmation: definition.method === "DELETE",
|
||||
};
|
||||
}
|
||||
if (definition.method !== "GET") {
|
||||
return { type: "networkAccessNeedsReview" as const };
|
||||
}
|
||||
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
|
||||
const url = validateUrl(definition.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
|
||||
if (definition.method !== "GET" && definition.requestBody) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: definition.method,
|
||||
headers,
|
||||
body: definition.method !== "GET" ? definition.requestBody : undefined,
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `API returned ${response.status}: ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
|
||||
const json: unknown = await response.json();
|
||||
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Failed to fetch custom API data", { definitionId: input.definitionId, error });
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : "Failed to fetch data from external API",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const response = await withRequestLimit(ctx, resolved, "query", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: definition.url,
|
||||
method: "GET",
|
||||
auth: getAuth(resolved),
|
||||
networkScope: getNetworkScope(displayConfig),
|
||||
kind: "query",
|
||||
cacheKey: `custom-widget:base:${resolved.item.id}`,
|
||||
}),
|
||||
);
|
||||
assertUpstreamSuccess(response);
|
||||
return extractDisplayDataWithFallback(response.data, definition.displayType, displayConfig);
|
||||
}),
|
||||
|
||||
subFetch: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Proxy an HTTP request from a custom JSX widget to an external API on the same domain as the widget definition. Applies the definition's auth credentials. Requires definitionId from the widget configuration.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
definitionId: z.string(),
|
||||
url: z.string().min(1).max(2048),
|
||||
method: z.enum(HTTP_METHODS).default("GET"),
|
||||
body: z.string().max(MAX_BODY_SIZE).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const request = findNamedRequest(resolved, input.requestId, "query");
|
||||
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
|
||||
const response = await withRequestLimit(ctx, resolved, "query", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: resolved.definition.url,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, request.auth),
|
||||
networkScope: getV2Config(resolved).networkScope,
|
||||
kind: "query",
|
||||
cacheKey: getCacheKey(resolved, request, input.params),
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
}),
|
||||
)
|
||||
);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}),
|
||||
|
||||
executeBaseAction: protectedProcedure
|
||||
.input(itemInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.body && input.body.length > MAX_BODY_SIZE) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Request body exceeds 10KB limit" });
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const { definition } = resolved;
|
||||
if (definition.displayType !== "actionButton") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can use this action" });
|
||||
}
|
||||
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const permission: BoardPermission = definition.method === "DELETE" ? "full" : "modify";
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), permission);
|
||||
if (definition.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
|
||||
if (!definition.enabled) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
const response = await withRequestLimit(ctx, resolved, definition.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: definition.url,
|
||||
method: definition.method,
|
||||
body: definition.method === "GET" ? undefined : (definition.requestBody ?? undefined),
|
||||
auth: getAuth(resolved),
|
||||
networkScope: getNetworkScope(resolved.displayConfig),
|
||||
kind: definition.method === "GET" ? "query" : "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo: { status: response.status, statusText: response.statusText },
|
||||
};
|
||||
}),
|
||||
|
||||
executeAction: protectedProcedure
|
||||
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const request = findNamedRequest(resolved, input.requestId, "action");
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
|
||||
const targetUrl = resolveTargetUrl(definition.url, input.url);
|
||||
assertSameOrigin(definition.url, targetUrl);
|
||||
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (input.headers) {
|
||||
for (const [key, value] of Object.entries(input.headers)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.method !== "GET" && input.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
applyAuth(headers, targetUrl, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl.toString(), {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method !== "GET" ? input.body : undefined,
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await parseResponseData(response);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: response.status,
|
||||
data,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true as const, status: response.status, data };
|
||||
} catch (error) {
|
||||
logger.error("SubFetch proxy request failed", { definitionId: input.definitionId, url: input.url, error });
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 0,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, resolved.item.boardId),
|
||||
request.minimumBoardPermission as BoardPermission,
|
||||
);
|
||||
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
|
||||
const response = await withRequestLimit(ctx, resolved, request.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: resolved.definition.url,
|
||||
targetUrl,
|
||||
method: request.method as CustomWidgetMethod,
|
||||
body: renderRequestBody(request.bodyTemplate, input.params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, request.auth),
|
||||
networkScope: getV2Config(resolved).networkScope,
|
||||
kind: "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
|
||||
import { infoRouter } from "../router/info";
|
||||
import { inviteRouter } from "../router/invite";
|
||||
import { serverSettingsRouter } from "../router/serverSettings";
|
||||
@@ -13,6 +14,7 @@ vi.mock("@homarr/auth", () => ({}));
|
||||
const mcpTestRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
info: infoRouter,
|
||||
invite: inviteRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
@@ -34,6 +36,19 @@ test("MCP tools should contain expected procedures", () => {
|
||||
expect(toolNames).toContain("app_create");
|
||||
expect(toolNames).toContain("board_savePartialBoardSettings");
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("customWidget_schema");
|
||||
expect(toolNames).toContain("customWidget_validate");
|
||||
expect(toolNames).toContain("customWidget_all");
|
||||
expect(toolNames).toContain("customWidget_byId");
|
||||
expect(toolNames).toContain("customWidget_readTemplate");
|
||||
expect(toolNames).toContain("customWidget_writeTemplate");
|
||||
expect(toolNames).toContain("customWidget_patchTemplate");
|
||||
expect(toolNames).toContain("customWidget_create");
|
||||
expect(toolNames).toContain("customWidget_import");
|
||||
expect(toolNames).toContain("customWidget_update");
|
||||
expect(toolNames).toContain("customWidget_preview");
|
||||
expect(toolNames).toContain("customWidget_previewQuery");
|
||||
expect(toolNames).toContain("customWidget_simulatePreviewAction");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
expect(toolNames).toContain("serverSettings_getBoardSettings");
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
export interface CustomJsxExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
template: string;
|
||||
requests: readonly Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export const customJsxExamples: readonly CustomJsxExample[] = [
|
||||
{
|
||||
id: "simple-metric",
|
||||
title: "Simple metric",
|
||||
description: "A compact status and metric display with no nested requests.",
|
||||
template: `<Stack gap="xs" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{data.name}</Text>
|
||||
<Badge color={data.status === "online" ? "green" : "red"}>{data.status}</Badge>
|
||||
</Group>
|
||||
<Title order={2}>{data.value}</Title>
|
||||
</Stack>`,
|
||||
requests: [],
|
||||
},
|
||||
{
|
||||
id: "pokedex-detail",
|
||||
title: "Pokédex detail list",
|
||||
description: "Paginates the list and fetches details only for visible Pokémon.",
|
||||
template: `<Stack gap="sm" p="xs">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<PaginatedList pageSize={5}>
|
||||
{data.results.map((pokemon) =>
|
||||
<Card key={pokemon.name} withBorder p="xs" mb="xs">
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
{detail.types.map((entry) => <Badge key={entry.type.name} color="red">{entry.type.name}</Badge>)}
|
||||
</Group>
|
||||
<Text size="xs">HP {detail.stats[0].base_stat} · HTTP {meta.status}</Text>
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "pokemon-detail",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/pokemon/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
cacheTtlSeconds: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stocks-dashboard",
|
||||
title: "Stocks dashboard",
|
||||
description: "Responsive portfolio values and chart with parent refresh.",
|
||||
template: `<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Portfolio</Title>
|
||||
<RefreshButton label="Refresh" size="xs" />
|
||||
</Group>
|
||||
<SimpleGrid cols={3}>
|
||||
{data.holdings.map((stock) =>
|
||||
<Card key={stock.symbol} withBorder p="xs">
|
||||
<Text fw={700}>{stock.symbol}</Text>
|
||||
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
|
||||
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<LineChart h={200} data={data.timeline} dataKey="date" series={[{ name: "value", color: "blue" }]} />
|
||||
</Stack>`,
|
||||
requests: [],
|
||||
},
|
||||
{
|
||||
id: "navidrome-player",
|
||||
title: "Navidrome mini-player",
|
||||
description: "Media controls backed by named actions with explicit permissions.",
|
||||
template: `<Stack gap="sm" p="sm">
|
||||
<Group wrap="nowrap">
|
||||
<Image src={data.albumArt} alt={data.album} w={80} h={80} radius="sm" />
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} lineClamp={1}>{data.title}</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
|
||||
<Progress value={data.progress} size="xs" color="red" />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group justify="center" gap="sm">
|
||||
<ActionButton requestId="previous" label="Previous" size="xs" variant="subtle" />
|
||||
<ActionButton requestId="toggle-playback" label={data.playing ? "Pause" : "Play"} icon="play" color="red" invalidate={["parent"]} />
|
||||
<ActionButton requestId="next" label="Next" size="xs" variant="subtle" />
|
||||
</Group>
|
||||
<ToggleSwitch requestId="shuffle" onParams={{ enabled: true }} offParams={{ enabled: false }} initialValue={data.shuffle} label="Shuffle" size="xs" />
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "previous",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/player/previous",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "toggle-playback",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/player/toggle",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "next",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/player/next",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "shuffle",
|
||||
kind: "action",
|
||||
method: "PUT",
|
||||
pathTemplate: "/api/player/shuffle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pokedex-interactive",
|
||||
title: "Interactive Pokédex",
|
||||
description:
|
||||
"A full-featured Pokédex with paginated list, detail SubFetch, type badges, stats with progress bars, moves grid, and evolution chain. Exercises filter(Boolean), pop(), find, reduce, sort, replaceAll, padStart, arithmetic, and nested SubFetch.",
|
||||
template: `<Stack gap="sm" p="sm" style={{ minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="lg" radius="xl" variant="light" color="red">P</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<Text size="xs" c="dimmed"><NumberFormatter value={data.count} thousandSeparator /> Pokémon discovered</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<RefreshButton label="Refresh" size="xs" />
|
||||
</Group>
|
||||
|
||||
<PaginatedList pageSize={5}>
|
||||
{data.results.map((pokemon, index) =>
|
||||
<Accordion key={pokemon.name} variant="separated" radius="md">
|
||||
<Accordion.Item value={pokemon.name}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Avatar size="sm" radius="md" src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + (index + 1) + ".png"} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed">#{String(index + 1).padStart(4, "0")}</Text>
|
||||
<Text fw={700} tt="capitalize" lineClamp={1}>{pokemon.name.replaceAll("-", " ")}</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge size="xs" variant="outline">Details</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="sm">
|
||||
<Grid gutter="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 5 }}>
|
||||
<Center>
|
||||
<Image src={detail.sprites.front_default} alt={detail.name} fit="contain" h={120} />
|
||||
</Center>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 7 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
{detail.types.map((entry) =>
|
||||
<Badge key={entry.type.name} tt="capitalize">{entry.type.name}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<Box><Text size="xs" c="dimmed">Height</Text><Text fw={700}><NumberFormatter value={detail.height / 10} decimalScale={1} suffix=" m" /></Text></Box>
|
||||
<Box><Text size="xs" c="dimmed">Weight</Text><Text fw={700}><NumberFormatter value={detail.weight / 10} decimalScale={1} suffix=" kg" /></Text></Box>
|
||||
<Box><Text size="xs" c="dimmed">Base XP</Text><Text fw={700}>{detail.base_experience || "—"}</Text></Box>
|
||||
</SimpleGrid>
|
||||
<Group gap="xs">
|
||||
{detail.abilities.map((entry) =>
|
||||
<Badge key={entry.ability.name} variant="light" size="xs" tt="capitalize">
|
||||
{entry.ability.name.replaceAll("-", " ")}{entry.is_hidden ? " ·H" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Tabs defaultValue="stats" variant="outline" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="stats">Stats</Tabs.Tab>
|
||||
<Tabs.Tab value="moves">Moves</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="stats" pt="sm">
|
||||
<Stack gap="xs">
|
||||
{detail.stats.map((entry) =>
|
||||
<Box key={entry.stat.name}>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Text size="xs" tt="capitalize">{entry.stat.name.replaceAll("-", " ")}</Text>
|
||||
<Text size="xs" fw={700}>{entry.base_stat}</Text>
|
||||
</Group>
|
||||
<Progress value={Math.min(100, entry.base_stat / 2.55)} size="sm" radius="xl" />
|
||||
</Box>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={700}>Total</Text>
|
||||
<Badge>{detail.stats.reduce((sum, s) => sum + s.base_stat, 0)}</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="moves" pt="sm">
|
||||
<ScrollArea h={120} type="auto">
|
||||
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="xs">
|
||||
{detail.moves.slice(0, 30).map((entry) =>
|
||||
<Badge key={entry.move.name} variant="light" size="xs" tt="capitalize" style={{ whiteSpace: "normal" }}>
|
||||
{entry.move.name.replaceAll("-", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Text size="xs" c="dimmed">Status: {meta.status}</Text>
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "pokemon-detail",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/pokemon/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
cacheTtlSeconds: 3600,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "smart-home-control",
|
||||
title: "Smart-home control",
|
||||
description: "A stateful light control with an explicit destructive confirmation.",
|
||||
template: `<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Living room</Title>
|
||||
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
|
||||
</Group>
|
||||
<ToggleSwitch requestId="set-light" onParams={{ state: "on" }} offParams={{ state: "off" }} initialValue={data.state === "on"} label="Power" color="yellow" />
|
||||
<ActionButton requestId="delete-scene" label="Delete scene" color="red" icon="trash" confirmMessage="Delete this scene?" />
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "set-light",
|
||||
kind: "action",
|
||||
method: "PUT",
|
||||
pathTemplate: "/api/lights/living-room",
|
||||
parameters: { state: "string" },
|
||||
bodyTemplate: { state: { $param: "state" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "delete-scene",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/api/scenes/living-room",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "full",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,735 @@
|
||||
export type CustomJsxComponentPackage = "@mantine/core" | "@mantine/charts" | "@mantine/dates" | "@homarr/widgets";
|
||||
|
||||
export type CustomJsxComponentSafety = "allowed" | "wrapped" | "denied";
|
||||
|
||||
export type CustomJsxComponentCategory =
|
||||
| "layout"
|
||||
| "content"
|
||||
| "feedback"
|
||||
| "navigation"
|
||||
| "charts"
|
||||
| "dates"
|
||||
| "interaction"
|
||||
| "network"
|
||||
| "blocked";
|
||||
|
||||
export interface CustomJsxComponentDescriptor {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
supportedProps: readonly string[];
|
||||
subcomponents: readonly string[];
|
||||
accessibilityRequirements: readonly string[];
|
||||
documentationUrl: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const commonSafeProps = [
|
||||
"key",
|
||||
"id",
|
||||
"style",
|
||||
"title",
|
||||
"role",
|
||||
"aria-label",
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"tabIndex",
|
||||
"w",
|
||||
"h",
|
||||
"miw",
|
||||
"maw",
|
||||
"mih",
|
||||
"mah",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mr",
|
||||
"mb",
|
||||
"ml",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pr",
|
||||
"pb",
|
||||
"pl",
|
||||
"bg",
|
||||
"c",
|
||||
"opacity",
|
||||
"display",
|
||||
"hiddenFrom",
|
||||
"visibleFrom",
|
||||
"bd",
|
||||
"bdrs",
|
||||
"flex",
|
||||
"fz",
|
||||
"fw",
|
||||
"lh",
|
||||
"lts",
|
||||
"ta",
|
||||
"td",
|
||||
"tt",
|
||||
"radius",
|
||||
"shadow",
|
||||
"variant",
|
||||
"color",
|
||||
"size",
|
||||
"withBorder",
|
||||
] as const;
|
||||
|
||||
const categorySafeProps: Record<CustomJsxComponentCategory, readonly string[]> = {
|
||||
layout: [
|
||||
"gap",
|
||||
"align",
|
||||
"justify",
|
||||
"wrap",
|
||||
"direction",
|
||||
"grow",
|
||||
"cols",
|
||||
"spacing",
|
||||
"verticalSpacing",
|
||||
"gutter",
|
||||
"span",
|
||||
"offset",
|
||||
"order",
|
||||
"type",
|
||||
"orientation",
|
||||
"withBorder",
|
||||
"shadow",
|
||||
"radius",
|
||||
],
|
||||
content: [
|
||||
"alt",
|
||||
"src",
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
"size",
|
||||
"fw",
|
||||
"fs",
|
||||
"td",
|
||||
"tt",
|
||||
"ta",
|
||||
"lts",
|
||||
"lh",
|
||||
"lineClamp",
|
||||
"truncate",
|
||||
"inherit",
|
||||
"gradient",
|
||||
"variant",
|
||||
"color",
|
||||
"value",
|
||||
"prefix",
|
||||
"suffix",
|
||||
"decimalScale",
|
||||
"thousandSeparator",
|
||||
"striped",
|
||||
"highlightOnHover",
|
||||
"withTableBorder",
|
||||
"withColumnBorders",
|
||||
"type",
|
||||
"icon",
|
||||
"image",
|
||||
"order",
|
||||
"fit",
|
||||
"fallbackSrc",
|
||||
"loading",
|
||||
"referrerPolicy",
|
||||
"crossOrigin",
|
||||
"decoding",
|
||||
"draggable",
|
||||
"center",
|
||||
"spacing",
|
||||
"listStyleType",
|
||||
],
|
||||
feedback: ["color", "variant", "radius", "size", "value", "sections", "label", "animated", "striped"],
|
||||
navigation: [
|
||||
"value",
|
||||
"defaultValue",
|
||||
"active",
|
||||
"label",
|
||||
"description",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"opened",
|
||||
"disabled",
|
||||
"multiple",
|
||||
"chevronPosition",
|
||||
"orientation",
|
||||
"keepMounted",
|
||||
"transitionDuration",
|
||||
],
|
||||
charts: [
|
||||
"data",
|
||||
"dataKey",
|
||||
"series",
|
||||
"type",
|
||||
"curveType",
|
||||
"color",
|
||||
"colors",
|
||||
"withLegend",
|
||||
"withTooltip",
|
||||
"withXAxis",
|
||||
"withYAxis",
|
||||
"withDots",
|
||||
"gridAxis",
|
||||
"unit",
|
||||
"valueFormatter",
|
||||
"strokeWidth",
|
||||
"fillOpacity",
|
||||
"connectNulls",
|
||||
"tooltipAnimationDuration",
|
||||
],
|
||||
dates: [
|
||||
"date",
|
||||
"defaultDate",
|
||||
"value",
|
||||
"minDate",
|
||||
"maxDate",
|
||||
"locale",
|
||||
"firstDayOfWeek",
|
||||
"weekendDays",
|
||||
"hideOutsideDates",
|
||||
"hideWeekdays",
|
||||
"showWeekNumbers",
|
||||
"numberOfColumns",
|
||||
"level",
|
||||
"size",
|
||||
],
|
||||
interaction: [
|
||||
"label",
|
||||
"value",
|
||||
"values",
|
||||
"data",
|
||||
"checked",
|
||||
"defaultChecked",
|
||||
"disabled",
|
||||
"readOnly",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"loading",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"total",
|
||||
"page",
|
||||
"pageSize",
|
||||
"defaultTab",
|
||||
"title",
|
||||
"defaultOpen",
|
||||
"type",
|
||||
"fullWidth",
|
||||
],
|
||||
network: [
|
||||
"requestId",
|
||||
"params",
|
||||
"onParams",
|
||||
"offParams",
|
||||
"initialValue",
|
||||
"label",
|
||||
"loadingLabel",
|
||||
"successMessage",
|
||||
"errorMessage",
|
||||
"confirmMessage",
|
||||
"invalidate",
|
||||
"refreshInterval",
|
||||
"trigger",
|
||||
"render",
|
||||
"path",
|
||||
"as",
|
||||
"fallback",
|
||||
"icon",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"disabled",
|
||||
],
|
||||
blocked: [],
|
||||
};
|
||||
|
||||
const subcomponentsByName: Readonly<Record<string, readonly string[]>> = {
|
||||
Accordion: ["Accordion.Item", "Accordion.Control", "Accordion.Panel"],
|
||||
Avatar: ["Avatar.Group"],
|
||||
Card: ["Card.Section"],
|
||||
Chip: ["Chip.Group"],
|
||||
DataList: ["DataList.Item", "DataList.ItemLabel", "DataList.ItemValue"],
|
||||
EmptyState: ["EmptyState.Indicator", "EmptyState.Title", "EmptyState.Description", "EmptyState.Actions"],
|
||||
Grid: ["Grid.Col"],
|
||||
List: ["List.Item"],
|
||||
Menu: ["Menu.Target", "Menu.Dropdown", "Menu.Item", "Menu.Label", "Menu.Divider"],
|
||||
Progress: ["Progress.Section"],
|
||||
Table: ["Table.Thead", "Table.Tbody", "Table.Tfoot", "Table.Caption", "Table.Tr", "Table.Th", "Table.Td"],
|
||||
Tabs: ["Tabs.List", "Tabs.Tab", "Tabs.Panel"],
|
||||
Timeline: ["Timeline.Item"],
|
||||
};
|
||||
|
||||
const accessibilityByCategory: Record<CustomJsxComponentCategory, readonly string[]> = {
|
||||
layout: ["Keep visual order consistent with reading order."],
|
||||
content: ["Provide meaningful text alternatives for non-text content."],
|
||||
feedback: ["Do not rely on color alone to communicate status."],
|
||||
navigation: ["Provide an accessible label for controls without visible text."],
|
||||
charts: ["Include a nearby text summary of the chart data."],
|
||||
dates: ["Include a textual date when the visual calendar carries meaning."],
|
||||
interaction: ["Provide a visible label or aria-label."],
|
||||
network: ["Action labels must describe their effect; destructive actions require confirmation."],
|
||||
blocked: [],
|
||||
};
|
||||
|
||||
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
|
||||
"Tabs.List": ["grow"],
|
||||
ScrollArea: ["offsetScrollbars"],
|
||||
SubData: ["alt", "fit"],
|
||||
};
|
||||
|
||||
function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
|
||||
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
|
||||
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
|
||||
return `https://mantine.dev/${section}/${name.split(".")[0]?.toLowerCase() ?? ""}/`;
|
||||
}
|
||||
|
||||
const describe = (
|
||||
packageName: CustomJsxComponentPackage,
|
||||
category: CustomJsxComponentCategory,
|
||||
safety: CustomJsxComponentSafety,
|
||||
names: readonly string[],
|
||||
reason?: string,
|
||||
): CustomJsxComponentDescriptor[] =>
|
||||
names.map((name) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
supportedProps:
|
||||
safety === "denied"
|
||||
? []
|
||||
: [
|
||||
...new Set<string>([
|
||||
...commonSafeProps,
|
||||
...categorySafeProps[category],
|
||||
...(componentSafeProps[name] ?? []),
|
||||
]),
|
||||
],
|
||||
subcomponents: subcomponentsByName[name] ?? [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
reason,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The source of truth for the Custom JSX public component surface.
|
||||
*
|
||||
* Components marked `wrapped` require a runtime wrapper that removes unsafe props
|
||||
* or supplies inert/read-only behavior. `denied` entries are deliberately kept in
|
||||
* the registry so Mantine upgrades cannot silently expand the template surface.
|
||||
*/
|
||||
export const customJsxComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
|
||||
...describe("@mantine/core", "layout", "allowed", [
|
||||
"Box",
|
||||
"Stack",
|
||||
"Group",
|
||||
"Flex",
|
||||
"Grid",
|
||||
"Grid.Col",
|
||||
"SimpleGrid",
|
||||
"Center",
|
||||
"Space",
|
||||
"Container",
|
||||
"AspectRatio",
|
||||
"ScrollArea",
|
||||
"Paper",
|
||||
"Card",
|
||||
"Card.Section",
|
||||
"Divider",
|
||||
"Fieldset",
|
||||
"VisuallyHidden",
|
||||
"Typography",
|
||||
"FloatingIndicator",
|
||||
]),
|
||||
...describe("@mantine/core", "content", "allowed", [
|
||||
"Text",
|
||||
"Title",
|
||||
"Code",
|
||||
"Highlight",
|
||||
"Mark",
|
||||
"Kbd",
|
||||
"Blockquote",
|
||||
"NumberFormatter",
|
||||
"Badge",
|
||||
"ThemeIcon",
|
||||
"ColorSwatch",
|
||||
"Table",
|
||||
"Table.Thead",
|
||||
"Table.Tbody",
|
||||
"Table.Tfoot",
|
||||
"Table.Caption",
|
||||
"Table.Tr",
|
||||
"Table.Th",
|
||||
"Table.Td",
|
||||
"List",
|
||||
"List.Item",
|
||||
"Timeline",
|
||||
"Timeline.Item",
|
||||
"Indicator",
|
||||
"Pill",
|
||||
"Image",
|
||||
"Avatar",
|
||||
"Avatar.Group",
|
||||
"BackgroundImage",
|
||||
"DataList",
|
||||
"DataList.Item",
|
||||
"DataList.ItemLabel",
|
||||
"DataList.ItemValue",
|
||||
"EmptyState",
|
||||
"EmptyState.Indicator",
|
||||
"EmptyState.Title",
|
||||
"EmptyState.Description",
|
||||
"EmptyState.Actions",
|
||||
"Marquee",
|
||||
"RollingNumber",
|
||||
]),
|
||||
...describe("@mantine/core", "feedback", "allowed", [
|
||||
"Alert",
|
||||
"Progress",
|
||||
"Progress.Section",
|
||||
"RingProgress",
|
||||
"SemiCircleProgress",
|
||||
"Skeleton",
|
||||
"Loader",
|
||||
]),
|
||||
...describe("@mantine/core", "feedback", "wrapped", ["Notification", "LoadingOverlay", "Overlay"]),
|
||||
...describe("@mantine/core", "navigation", "allowed", [
|
||||
"Accordion",
|
||||
"Accordion.Item",
|
||||
"Accordion.Control",
|
||||
"Accordion.Panel",
|
||||
"Tabs",
|
||||
"Tabs.List",
|
||||
"Tabs.Tab",
|
||||
"Tabs.Panel",
|
||||
"Stepper.Step",
|
||||
"HoverCard",
|
||||
"HoverCard.Target",
|
||||
"HoverCard.Dropdown",
|
||||
"Menu",
|
||||
"Menu.Target",
|
||||
"Menu.Dropdown",
|
||||
"Menu.Item",
|
||||
"Menu.Label",
|
||||
"Menu.Divider",
|
||||
"Tooltip",
|
||||
"Spoiler",
|
||||
"Collapse",
|
||||
"Transition",
|
||||
]),
|
||||
...describe("@mantine/core", "navigation", "wrapped", ["Anchor", "NavLink", "Breadcrumbs", "Stepper", "Tree"]),
|
||||
...describe("@mantine/core", "interaction", "wrapped", [
|
||||
"Button",
|
||||
"ActionIcon",
|
||||
"Burger",
|
||||
"CloseButton",
|
||||
"Chip",
|
||||
"Chip.Group",
|
||||
"CopyButton",
|
||||
"Pagination",
|
||||
"Rating",
|
||||
"SegmentedControl",
|
||||
"Slider",
|
||||
"Switch",
|
||||
]),
|
||||
...describe("@mantine/charts", "charts", "allowed", [
|
||||
"AreaChart",
|
||||
"BarChart",
|
||||
"BarsList",
|
||||
"BubbleChart",
|
||||
"ChartLegend",
|
||||
"ChartTooltip",
|
||||
"CompositeChart",
|
||||
"DonutChart",
|
||||
"FunnelChart",
|
||||
"Heatmap",
|
||||
"LineChart",
|
||||
"PieChart",
|
||||
"RadarChart",
|
||||
"RadialBarChart",
|
||||
"SankeyChart",
|
||||
"ScatterChart",
|
||||
"Sparkline",
|
||||
"Treemap",
|
||||
]),
|
||||
...describe("@mantine/dates", "dates", "wrapped", [
|
||||
"Calendar",
|
||||
"MiniCalendar",
|
||||
"DatePicker",
|
||||
"Day",
|
||||
"WeekdaysRow",
|
||||
"Month",
|
||||
"CalendarHeader",
|
||||
"YearPicker",
|
||||
"MonthPicker",
|
||||
"TimeGrid",
|
||||
"PickerControl",
|
||||
"YearsList",
|
||||
"MonthsList",
|
||||
"DecadeLevel",
|
||||
"YearLevel",
|
||||
"MonthLevel",
|
||||
"LevelsGroup",
|
||||
"DecadeLevelGroup",
|
||||
"YearLevelGroup",
|
||||
"MonthLevelGroup",
|
||||
]),
|
||||
...describe("@mantine/dates", "dates", "allowed", ["TimeValue"]),
|
||||
...describe("@homarr/widgets", "interaction", "allowed", [
|
||||
"PaginatedList",
|
||||
"TabsContainer",
|
||||
"TabPanel",
|
||||
"Collapsible",
|
||||
"StatBar",
|
||||
"TypeBadge",
|
||||
]),
|
||||
...describe("@homarr/widgets", "network", "wrapped", [
|
||||
"SubFetch",
|
||||
"SubData",
|
||||
"ActionButton",
|
||||
"ToggleSwitch",
|
||||
"RefreshButton",
|
||||
]),
|
||||
{
|
||||
name: "TablerIcon",
|
||||
package: "@homarr/widgets",
|
||||
category: "content",
|
||||
safety: "wrapped",
|
||||
supportedProps: ["name", "size", "color", "stroke"],
|
||||
subcomponents: [],
|
||||
accessibilityRequirements: accessibilityByCategory.content,
|
||||
documentationUrl: documentationUrl("@homarr/widgets", "TablerIcon"),
|
||||
},
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AppShell",
|
||||
"Affix",
|
||||
"ComboboxPopover",
|
||||
"Dialog",
|
||||
"Drawer",
|
||||
"FloatingWindow",
|
||||
"FocusTrap",
|
||||
"Menubar",
|
||||
"Modal",
|
||||
"ModalBase",
|
||||
"Portal",
|
||||
"Popover",
|
||||
"RemoveScroll",
|
||||
],
|
||||
"Escapes the widget layout or focus boundary",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AngleSlider",
|
||||
"Autocomplete",
|
||||
"Checkbox",
|
||||
"ColorInput",
|
||||
"ColorPicker",
|
||||
"Combobox",
|
||||
"FileButton",
|
||||
"FileInput",
|
||||
"Input",
|
||||
"InputBase",
|
||||
"JsonInput",
|
||||
"MaskInput",
|
||||
"MultiSelect",
|
||||
"NativeSelect",
|
||||
"NumberInput",
|
||||
"PasswordInput",
|
||||
"PillsInput",
|
||||
"PinInput",
|
||||
"Radio",
|
||||
"Select",
|
||||
"TagsInput",
|
||||
"Textarea",
|
||||
"TextInput",
|
||||
"TreeSelect",
|
||||
"UnstyledButton",
|
||||
],
|
||||
"Arbitrary form controls are not connected to the declarative action model",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
["OverflowList", "Scroller", "Splitter", "TableOfContents"],
|
||||
"Requires callback, document, or layout capabilities that are not exposed to templates",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AccordionChevron",
|
||||
"ActionIconGroup",
|
||||
"ActionIconGroupSection",
|
||||
"AlphaSlider",
|
||||
"AppShellAside",
|
||||
"AppShellFooter",
|
||||
"AppShellHeader",
|
||||
"AppShellMain",
|
||||
"AppShellNavbar",
|
||||
"AppShellSection",
|
||||
"ButtonGroup",
|
||||
"ButtonGroupSection",
|
||||
"CheckIcon",
|
||||
"CheckboxCard",
|
||||
"CheckboxGroup",
|
||||
"CheckboxIndicator",
|
||||
"CloseIcon",
|
||||
"ColorSchemeScript",
|
||||
"ComboboxChevron",
|
||||
"ComboboxClearButton",
|
||||
"ComboboxDropdown",
|
||||
"ComboboxDropdownTarget",
|
||||
"ComboboxEmpty",
|
||||
"ComboboxEventsTarget",
|
||||
"ComboboxFooter",
|
||||
"ComboboxGroup",
|
||||
"ComboboxHeader",
|
||||
"ComboboxHiddenInput",
|
||||
"ComboboxOption",
|
||||
"ComboboxOptions",
|
||||
"ComboboxPopoverTarget",
|
||||
"ComboboxSearch",
|
||||
"ComboboxTarget",
|
||||
"DirectionProvider",
|
||||
"DrawerBody",
|
||||
"DrawerCloseButton",
|
||||
"DrawerContent",
|
||||
"DrawerHeader",
|
||||
"DrawerOverlay",
|
||||
"DrawerRoot",
|
||||
"DrawerStack",
|
||||
"DrawerTitle",
|
||||
"FlatTreeNode",
|
||||
"FloatingArrow",
|
||||
"FocusTrapInitialFocus",
|
||||
"HeadlessMantineProvider",
|
||||
"HoverCardGroup",
|
||||
"HueSlider",
|
||||
"InlineStyles",
|
||||
"InputClearButton",
|
||||
"InputClearSection",
|
||||
"InputDescription",
|
||||
"InputError",
|
||||
"InputLabel",
|
||||
"InputPlaceholder",
|
||||
"InputSuccess",
|
||||
"InputWrapper",
|
||||
"MantineProvider",
|
||||
"MantineThemeProvider",
|
||||
"MenuCheckboxGroup",
|
||||
"MenuCheckboxItem",
|
||||
"MenuContextMenu",
|
||||
"MenuRadioGroup",
|
||||
"MenuRadioItem",
|
||||
"MenuSearch",
|
||||
"MenuSub",
|
||||
"MenuSubDropdown",
|
||||
"MenuSubItem",
|
||||
"MenuSubTarget",
|
||||
"MenubarDropdown",
|
||||
"MenubarMenu",
|
||||
"MenubarTarget",
|
||||
"ModalBaseBody",
|
||||
"ModalBaseCloseButton",
|
||||
"ModalBaseContent",
|
||||
"ModalBaseHeader",
|
||||
"ModalBaseOverlay",
|
||||
"ModalBaseTitle",
|
||||
"ModalBody",
|
||||
"ModalCloseButton",
|
||||
"ModalContent",
|
||||
"ModalHeader",
|
||||
"ModalOverlay",
|
||||
"ModalRoot",
|
||||
"ModalStack",
|
||||
"ModalTitle",
|
||||
"NativeScrollArea",
|
||||
"OptionalPortal",
|
||||
"OptionsDropdown",
|
||||
"PaginationControl",
|
||||
"PaginationDots",
|
||||
"PaginationFirst",
|
||||
"PaginationItems",
|
||||
"PaginationLabel",
|
||||
"PaginationLast",
|
||||
"PaginationNext",
|
||||
"PaginationPrevious",
|
||||
"PaginationRoot",
|
||||
"PillGroup",
|
||||
"PillsInputField",
|
||||
"PopoverContextMenu",
|
||||
"PopoverDropdown",
|
||||
"PopoverTarget",
|
||||
"ProgressLabel",
|
||||
"ProgressRoot",
|
||||
"RadioCard",
|
||||
"RadioGroup",
|
||||
"RadioIcon",
|
||||
"RadioIndicator",
|
||||
"RangeSlider",
|
||||
"ScrollAreaAutosize",
|
||||
"SplitterPane",
|
||||
"StepperCompleted",
|
||||
"SwitchGroup",
|
||||
"TableScrollContainer",
|
||||
"TooltipFloating",
|
||||
"TooltipGroup",
|
||||
"TreeSelectOption",
|
||||
],
|
||||
"Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/charts",
|
||||
"blocked",
|
||||
"denied",
|
||||
["AreaGradient"],
|
||||
"Low-level chart implementation helper that is not a standalone declarative display component",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/dates",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"DatesProvider",
|
||||
"HiddenDatesInput",
|
||||
"TimeInput",
|
||||
"TimePicker",
|
||||
"PickerInputBase",
|
||||
"DateInput",
|
||||
"DateTimePicker",
|
||||
"InlineDateTimePicker",
|
||||
"YearPickerInput",
|
||||
"MonthPickerInput",
|
||||
"DatePickerInput",
|
||||
],
|
||||
"Input and provider components are outside the read-only template surface",
|
||||
),
|
||||
];
|
||||
|
||||
export const enabledCustomJsxComponents = customJsxComponentRegistry.filter(({ safety }) => safety !== "denied");
|
||||
|
||||
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
|
||||
|
||||
export const customJsxSupportedPropsByName = new Map(
|
||||
enabledCustomJsxComponents.map((component) => [component.name, new Set(component.supportedProps)]),
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod/v4";
|
||||
import { createDocumentationLink } from "./index";
|
||||
import { integrationDocSlugs } from "./integration-doc-slugs";
|
||||
import { widgetDocSlugs } from "./widget-doc-slugs";
|
||||
import { customJsxComponentRegistry } from "../custom-jsx";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -69,8 +70,28 @@ const slugMapPaths = [
|
||||
];
|
||||
|
||||
const outputPath = path.join(__dirname, "homarr-docs-sitemap.ts");
|
||||
const customJsxRegistryOutputPath = path.join(
|
||||
__dirname,
|
||||
"../../../../apps/docs/src/generated/custom-jsx-components.json",
|
||||
);
|
||||
|
||||
const updateCustomJsxRegistryAsync = async () => {
|
||||
await fs.mkdir(dirname(customJsxRegistryOutputPath), { recursive: true });
|
||||
const documentationRegistry = customJsxComponentRegistry.map(
|
||||
({ name, package: packageName, category, safety, reason, documentationUrl }) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
reason,
|
||||
documentationUrl,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(customJsxRegistryOutputPath, `${JSON.stringify(documentationRegistry, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
await updateCustomJsxRegistryAsync();
|
||||
let paths: string[];
|
||||
try {
|
||||
const sitemapXml = await fetchSitemapAsync();
|
||||
|
||||
@@ -18,3 +18,5 @@ export * from "./hotkeys";
|
||||
export * from "./docker-integration-match";
|
||||
export * from "./integration-url-template";
|
||||
export * from "./release-provider";
|
||||
export * from "./custom-jsx";
|
||||
export * from "./custom-jsx-examples";
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customJsxComponentByName, customJsxComponentRegistry } from "../custom-jsx";
|
||||
|
||||
const mantineCore941ComponentExports = [
|
||||
"RemoveScroll",
|
||||
"Collapse",
|
||||
"ScrollArea",
|
||||
"UnstyledButton",
|
||||
"VisuallyHidden",
|
||||
"Paper",
|
||||
"Popover",
|
||||
"ActionIcon",
|
||||
"CloseButton",
|
||||
"Group",
|
||||
"Loader",
|
||||
"Overlay",
|
||||
"ModalBase",
|
||||
"Input",
|
||||
"InputBase",
|
||||
"Flex",
|
||||
"FloatingIndicator",
|
||||
"Accordion",
|
||||
"Affix",
|
||||
"Alert",
|
||||
"Anchor",
|
||||
"AngleSlider",
|
||||
"AppShell",
|
||||
"AspectRatio",
|
||||
"Autocomplete",
|
||||
"Avatar",
|
||||
"BackgroundImage",
|
||||
"Badge",
|
||||
"Blockquote",
|
||||
"Breadcrumbs",
|
||||
"Burger",
|
||||
"Button",
|
||||
"Card",
|
||||
"Center",
|
||||
"Checkbox",
|
||||
"Chip",
|
||||
"Code",
|
||||
"ColorPicker",
|
||||
"ColorInput",
|
||||
"ColorSwatch",
|
||||
"Combobox",
|
||||
"ComboboxPopover",
|
||||
"Container",
|
||||
"CopyButton",
|
||||
"DataList",
|
||||
"Dialog",
|
||||
"Divider",
|
||||
"Drawer",
|
||||
"EmptyState",
|
||||
"Fieldset",
|
||||
"FileButton",
|
||||
"FileInput",
|
||||
"FloatingWindow",
|
||||
"FocusTrap",
|
||||
"Grid",
|
||||
"Highlight",
|
||||
"HoverCard",
|
||||
"Image",
|
||||
"Indicator",
|
||||
"JsonInput",
|
||||
"Kbd",
|
||||
"List",
|
||||
"LoadingOverlay",
|
||||
"Mark",
|
||||
"Marquee",
|
||||
"MaskInput",
|
||||
"Menu",
|
||||
"Menubar",
|
||||
"Modal",
|
||||
"MultiSelect",
|
||||
"NativeSelect",
|
||||
"NavLink",
|
||||
"Notification",
|
||||
"NumberFormatter",
|
||||
"NumberInput",
|
||||
"OverflowList",
|
||||
"Pagination",
|
||||
"PasswordInput",
|
||||
"Pill",
|
||||
"PillsInput",
|
||||
"PinInput",
|
||||
"Portal",
|
||||
"Progress",
|
||||
"Radio",
|
||||
"Rating",
|
||||
"RollingNumber",
|
||||
"RingProgress",
|
||||
"Scroller",
|
||||
"SegmentedControl",
|
||||
"Select",
|
||||
"SemiCircleProgress",
|
||||
"SimpleGrid",
|
||||
"Skeleton",
|
||||
"Slider",
|
||||
"Space",
|
||||
"Splitter",
|
||||
"Spoiler",
|
||||
"Stack",
|
||||
"Stepper",
|
||||
"Switch",
|
||||
"Table",
|
||||
"TableOfContents",
|
||||
"Tabs",
|
||||
"TagsInput",
|
||||
"Text",
|
||||
"Textarea",
|
||||
"TextInput",
|
||||
"ThemeIcon",
|
||||
"Timeline",
|
||||
"Title",
|
||||
"Tooltip",
|
||||
"Transition",
|
||||
"Tree",
|
||||
"TreeSelect",
|
||||
"Typography",
|
||||
] as const;
|
||||
|
||||
const mantineCharts941ComponentExports = [
|
||||
"ChartTooltip",
|
||||
"ChartLegend",
|
||||
"AreaChart",
|
||||
"BarChart",
|
||||
"LineChart",
|
||||
"Sparkline",
|
||||
"DonutChart",
|
||||
"PieChart",
|
||||
"RadarChart",
|
||||
"ScatterChart",
|
||||
"BubbleChart",
|
||||
"CompositeChart",
|
||||
"RadialBarChart",
|
||||
"FunnelChart",
|
||||
"Heatmap",
|
||||
"BarsList",
|
||||
"Treemap",
|
||||
"SankeyChart",
|
||||
] as const;
|
||||
|
||||
const mantineDates941ComponentExports = [
|
||||
"DatesProvider",
|
||||
"HiddenDatesInput",
|
||||
"TimeInput",
|
||||
"TimePicker",
|
||||
"TimeValue",
|
||||
"Day",
|
||||
"WeekdaysRow",
|
||||
"Month",
|
||||
"PickerControl",
|
||||
"YearsList",
|
||||
"MonthsList",
|
||||
"CalendarHeader",
|
||||
"DecadeLevel",
|
||||
"YearLevel",
|
||||
"MonthLevel",
|
||||
"LevelsGroup",
|
||||
"DecadeLevelGroup",
|
||||
"YearLevelGroup",
|
||||
"MonthLevelGroup",
|
||||
"PickerInputBase",
|
||||
"Calendar",
|
||||
"YearPicker",
|
||||
"MonthPicker",
|
||||
"DatePicker",
|
||||
"DateInput",
|
||||
"DateTimePicker",
|
||||
"InlineDateTimePicker",
|
||||
"YearPickerInput",
|
||||
"MonthPickerInput",
|
||||
"DatePickerInput",
|
||||
"TimeGrid",
|
||||
"MiniCalendar",
|
||||
] as const;
|
||||
|
||||
describe("customJsxComponentRegistry", () => {
|
||||
test("classifies every component name exactly once", () => {
|
||||
const names = customJsxComponentRegistry.map(({ name }) => name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["ChartTooltip", "allowed"],
|
||||
["ChartLegend", "allowed"],
|
||||
["VisuallyHidden", "allowed"],
|
||||
["SubFetch", "wrapped"],
|
||||
["Portal", "denied"],
|
||||
["TextInput", "denied"],
|
||||
] as const)("classifies %s as %s", (name, safety) => {
|
||||
expect(customJsxComponentByName.get(name)?.safety).toBe(safety);
|
||||
});
|
||||
|
||||
test("documents why every denied component is unavailable", () => {
|
||||
for (const component of customJsxComponentRegistry) {
|
||||
if (component.safety === "denied") {
|
||||
expect(component.reason, component.name).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("publishes complete authoring metadata for every component", () => {
|
||||
for (const component of customJsxComponentRegistry) {
|
||||
expect(component.documentationUrl, component.name).toMatch(/^https:\/\//);
|
||||
expect(component.supportedProps, component.name).toBeInstanceOf(Array);
|
||||
expect(component.subcomponents, component.name).toBeInstanceOf(Array);
|
||||
expect(component.accessibilityRequirements, component.name).toBeInstanceOf(Array);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["Tabs.List", "grow"],
|
||||
["ScrollArea", "offsetScrollbars"],
|
||||
["SubData", "fit"],
|
||||
["SubData", "alt"],
|
||||
] as const)("supports the %s.%s authoring prop", (component, prop) => {
|
||||
expect(customJsxComponentByName.get(component)?.supportedProps).toContain(prop);
|
||||
});
|
||||
|
||||
test.each([
|
||||
...mantineCore941ComponentExports,
|
||||
...mantineCharts941ComponentExports,
|
||||
...mantineDates941ComponentExports,
|
||||
])("classifies the Mantine 9.4.1 export %s", (name) => {
|
||||
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -702,6 +702,7 @@
|
||||
"action": {
|
||||
"create": "Create",
|
||||
"import": "Import",
|
||||
"pasteImportHint": "Paste a full custom widget JSON with Ctrl+V or Command+V",
|
||||
"save": "Save",
|
||||
"delete": "Delete Custom Widget",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
@@ -716,6 +717,7 @@
|
||||
"addBar": "Add Bar",
|
||||
"duplicate": "Duplicate",
|
||||
"copyAiPrompt": "Copy AI prompt",
|
||||
"readyToSave": "Ready to save",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"menu": "Actions"
|
||||
@@ -765,7 +767,74 @@
|
||||
"enabled": "Enabled",
|
||||
"template": {
|
||||
"label": "JSX Template",
|
||||
"description": "Mantine JSX template. Access API response fields via data bindings (e.g. data.name). Use whitelisted components only. Forbidden: constructor, __proto__, eval, Function, window, document."
|
||||
"description": "Mantine JSX template. Read the base response from data and call named requests with SubFetch, ActionButton, or ToggleSwitch."
|
||||
},
|
||||
"jsxApiVersion": {
|
||||
"label": "JSX API version",
|
||||
"v2": "Version 2 (recommended)",
|
||||
"v1": "Version 1 (legacy)",
|
||||
"legacyWarning": "Version 1 is display-only. Inline network components are disabled until this widget is migrated to named requests."
|
||||
},
|
||||
"networkScope": {
|
||||
"label": "Network scope",
|
||||
"option": {
|
||||
"public": "Public networks",
|
||||
"private": "Private networks",
|
||||
"loopback": "Loopback"
|
||||
},
|
||||
"description": {
|
||||
"public": "Allow public HTTP and HTTPS destinations only.",
|
||||
"private": "Allow public and RFC1918 or ULA private destinations.",
|
||||
"loopback": "Allow loopback destinations for trusted local services."
|
||||
}
|
||||
},
|
||||
"requestManifest": {
|
||||
"label": "Named requests",
|
||||
"description": "JSON array of same-origin query and action capabilities. Templates reference entries by requestId and pass only declared parameters.",
|
||||
"invalid": "Named requests must be a valid JSON array."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"saveBlocked": "Resolve the editor errors before saving.",
|
||||
"saveReady": "The configuration is ready to save.",
|
||||
"language": {
|
||||
"jsx": "JSX",
|
||||
"json": "JSON"
|
||||
},
|
||||
"action": {
|
||||
"components": "Components",
|
||||
"format": "Format",
|
||||
"insertStarter": "Insert starter",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo"
|
||||
},
|
||||
"componentReference": {
|
||||
"search": "Search components",
|
||||
"empty": "No matching components",
|
||||
"count": "{count, number} components available"
|
||||
},
|
||||
"status": {
|
||||
"errors": "{count, number} errors",
|
||||
"warnings": "{count, number} warnings",
|
||||
"ready": "No issues found",
|
||||
"position": "Ln {line, number}, Col {column, number}",
|
||||
"characters": "{count, number} characters",
|
||||
"charactersWithLimit": "{count, number} / {limit, number}"
|
||||
},
|
||||
"diagnostics": {
|
||||
"title": "Template diagnostics",
|
||||
"line": "Line {line, number}",
|
||||
"ast": "{value}",
|
||||
"dangerousProperty": "Reflective property \"{value}\" is not available in templates.",
|
||||
"dynamicRequestId": "A literal requestId is required. Dynamic request IDs are not accepted.",
|
||||
"eventHandler": "Event handler \"{value}\" is not allowed. Use the declarative action components.",
|
||||
"invalidRequestManifest": "Named requests must be a valid JSON array.",
|
||||
"legacyNetworkProps": "Version 2 network components accept requestId and params, not inline URL, method, header, or body props.",
|
||||
"missingRequestId": "This network component requires a literal requestId.",
|
||||
"templateEmpty": "Enter a JSX template.",
|
||||
"templateTooLong": "The template exceeds the 50,000 character limit.",
|
||||
"unknownRequest": "No named request with ID \"{value}\" exists.",
|
||||
"unsafeTag": "The <{value}> element is not available in templates."
|
||||
}
|
||||
},
|
||||
"placeholder": {
|
||||
@@ -808,6 +877,27 @@
|
||||
"connection": "API Connection",
|
||||
"display": "Display Configuration"
|
||||
},
|
||||
"steps": {
|
||||
"format": {
|
||||
"label": "Choose format",
|
||||
"description": "Purpose and presentation",
|
||||
"help": "Choose how this widget should communicate its data. This determines which configuration options are available next."
|
||||
},
|
||||
"connection": {
|
||||
"label": "Connect data",
|
||||
"description": "Endpoint and authentication"
|
||||
},
|
||||
"configure": {
|
||||
"label": "Configure",
|
||||
"description": "Fields and behavior"
|
||||
},
|
||||
"back": "Back",
|
||||
"next": "Continue"
|
||||
},
|
||||
"workbench": {
|
||||
"configure": "Configure",
|
||||
"preview": "Preview and test"
|
||||
},
|
||||
"displayTypeDescription": {
|
||||
"singleValue": "Show one extracted value with a label and unit",
|
||||
"keyValue": "Show multiple labeled values side by side",
|
||||
@@ -854,9 +944,90 @@
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"errorBoundary": {
|
||||
"title": "The preview could not be rendered",
|
||||
"description": "Your form is still safe. Reset the preview, then review the Checks tab if the problem returns.",
|
||||
"retry": "Reset preview"
|
||||
},
|
||||
"test": "Test",
|
||||
"execute": "Execute",
|
||||
"hint": "Click Test to preview the API response with your current configuration.",
|
||||
"rawResponse": "Raw JSON response"
|
||||
"rawResponse": "Open raw JSON",
|
||||
"mutationDisabled": "Preview only runs GET requests. Named actions are executed from an explicit widget control after the widget is saved.",
|
||||
"tab": {
|
||||
"widget": "Widget",
|
||||
"response": "Response",
|
||||
"request": "Request",
|
||||
"diagnostics": "Checks"
|
||||
},
|
||||
"size": {
|
||||
"compact": "Compact",
|
||||
"standard": "Standard",
|
||||
"wide": "Wide"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No preview data yet"
|
||||
},
|
||||
"stale": {
|
||||
"title": "Sample data is out of date",
|
||||
"description": "The connection or display configuration changed after the last test. Test again before relying on this preview."
|
||||
},
|
||||
"capabilities": {
|
||||
"interactive": "Interactive",
|
||||
"title": "Network capabilities",
|
||||
"description": "This widget declares the following HTTP methods. Actions still require board permission and an explicit user gesture.",
|
||||
"liveActions": "Enable live preview actions",
|
||||
"liveActionsDescription": "Off runs action controls as simulations. On sends real requests for this short-lived preview session.",
|
||||
"runTestFirst": "Run a GET test to create a short-lived preview session.",
|
||||
"simulated": "Actions simulated"
|
||||
},
|
||||
"response": {
|
||||
"copy": "Copy response",
|
||||
"copied": "Copied",
|
||||
"addSample": "Add sample JSON",
|
||||
"editSample": "Edit sample JSON",
|
||||
"sampleLabel": "Sample response",
|
||||
"sampleDescription": "Use local JSON to design and test the display without calling the API.",
|
||||
"sampleHint": "Inspect the response or replace it with local sample data.",
|
||||
"applySample": "Apply sample data",
|
||||
"cancelSample": "Cancel",
|
||||
"invalidSample": "Enter valid JSON before applying the sample.",
|
||||
"localSample": "Local sample data",
|
||||
"raw": "Raw JSON",
|
||||
"copyPath": "Copy data path",
|
||||
"pathCopied": "Data path copied",
|
||||
"insertPath": "Insert data path into the JSX template",
|
||||
"expand": "Expand value",
|
||||
"collapse": "Collapse value",
|
||||
"empty": "Run a GET test to inspect the sanitized JSON response."
|
||||
},
|
||||
"request": {
|
||||
"method": "Base method",
|
||||
"authentication": "Authentication",
|
||||
"endpoint": "Endpoint",
|
||||
"status": "Last status",
|
||||
"notRun": "Not tested",
|
||||
"named": "{count, number} named requests",
|
||||
"journal": "Redacted request journal",
|
||||
"journalEmpty": "Run the preview or use an interactive control to populate the request journal.",
|
||||
"duration": "{duration, number} ms",
|
||||
"simulated": "Simulated",
|
||||
"redacted": "Secret values and query parameter values are hidden from this summary.",
|
||||
"permission": {
|
||||
"view": "View",
|
||||
"modify": "Modify",
|
||||
"full": "Full"
|
||||
}
|
||||
},
|
||||
"diagnostics": {
|
||||
"templateSize": "Template size",
|
||||
"characters": "{count, number} characters",
|
||||
"namedRequests": "Named requests",
|
||||
"methods": "HTTP methods",
|
||||
"networkScope": "Network scope",
|
||||
"legacy": "Legacy",
|
||||
"ready": "The editor checks did not find any blocking template issues."
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"created": "Widget \"{name}\" created successfully.",
|
||||
@@ -877,9 +1048,31 @@
|
||||
"toggleError": "Failed to toggle widget status.",
|
||||
"deleteError": "Failed to delete widget.",
|
||||
"previewError": "Failed to fetch preview. Check the URL and try again.",
|
||||
"previewActionsError": "Failed to update live preview actions. Run the GET test again if the session expired.",
|
||||
"pastedForReplacement": "The pasted widget configuration is ready to review and save.",
|
||||
"executed": "Action executed successfully.",
|
||||
"executeError": "Action failed: {error}"
|
||||
},
|
||||
"importReview": {
|
||||
"title": "Review custom widget import",
|
||||
"description": "Check where this widget connects and what board access its controls require before importing it.",
|
||||
"replaceTitle": "Replace with pasted widget",
|
||||
"replaceDescription": "Replace this form with \"{name}\" from {origin}? Stored credentials are preserved only when the authentication method is unchanged.",
|
||||
"replaceConfirm": "Replace form",
|
||||
"fileLabel": "Choose a custom widget JSON file",
|
||||
"name": "Name",
|
||||
"origin": "Base origin",
|
||||
"authentication": "Authentication",
|
||||
"networkScope": "Network scope",
|
||||
"methods": "HTTP methods",
|
||||
"permissions": "Required board permissions",
|
||||
"actionWarning": {
|
||||
"title": "This widget can change remote data",
|
||||
"description": "Review every named action, inherited credential, and destructive confirmation after import. Secrets are not included in the file."
|
||||
},
|
||||
"cancel": "Cancel import",
|
||||
"confirm": "Import widget"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"url": "URL",
|
||||
@@ -3831,6 +4024,29 @@
|
||||
"customApi": {
|
||||
"name": "Custom API",
|
||||
"description": "Display data from any external API with custom configuration.",
|
||||
"customJsx": {
|
||||
"noTemplate": "No JSX template is configured.",
|
||||
"interactive": "Interactive",
|
||||
"templateWarnings": "{count} template warnings:",
|
||||
"migrationRequired": "This template must be migrated to Custom JSX version 2 before it can use network requests.",
|
||||
"unsavedPreview": "Run a GET test in the editor before testing named requests.",
|
||||
"invalidParams": "The request parameters are invalid.",
|
||||
"loading": "Loading request...",
|
||||
"loadRequest": "Load request",
|
||||
"requestFailed": "The request failed.",
|
||||
"retry": "Retry request",
|
||||
"actionCompleted": "Action completed successfully.",
|
||||
"actionSimulated": "Action simulated; no request was sent.",
|
||||
"actionsDisabledEditMode": "Actions are disabled while the board is in edit mode.",
|
||||
"widgetItemUnavailable": "This widget item is not available.",
|
||||
"toggle": "Toggle",
|
||||
"refresh": "Refresh",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"networkCapabilities": "Network capabilities",
|
||||
"confirmDelete": "Confirm this destructive action.",
|
||||
"networkAccessNeedsReview": "Network access needs review before this template can run."
|
||||
},
|
||||
"option": {
|
||||
"definitionId": {
|
||||
"label": "Definition ID"
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"dependencies": {
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"acorn": "catalog:",
|
||||
"acorn-jsx": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-form-data": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import { customJsxComponentByName, customJsxSupportedPropsByName } from "@homarr/definitions";
|
||||
|
||||
interface AstNode {
|
||||
type: string;
|
||||
start?: number;
|
||||
loc?: { start?: { line?: number; column?: number } };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CustomJsxTemplateDiagnostic {
|
||||
severity: "error" | "warning";
|
||||
message: string;
|
||||
index: number;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
const MAX_AST_DEPTH = 64;
|
||||
const MAX_OPERATIONS = 25_000;
|
||||
|
||||
const rootBindings = new Set([
|
||||
"Array",
|
||||
"Boolean",
|
||||
"Date",
|
||||
"Infinity",
|
||||
"JSON",
|
||||
"Math",
|
||||
"NaN",
|
||||
"Number",
|
||||
"Object",
|
||||
"String",
|
||||
"data",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
"isFinite",
|
||||
"isNaN",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
"undefined",
|
||||
]);
|
||||
|
||||
const blockedProperties = new Set([
|
||||
"__proto__",
|
||||
"apply",
|
||||
"arguments",
|
||||
"bind",
|
||||
"call",
|
||||
"callee",
|
||||
"caller",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
const blockedProps = new Set([
|
||||
"children",
|
||||
"className",
|
||||
"classNames",
|
||||
"classes",
|
||||
"component",
|
||||
"dangerouslySetInnerHTML",
|
||||
"innerRef",
|
||||
"portalProps",
|
||||
"ref",
|
||||
"renderRoot",
|
||||
"styles",
|
||||
"unstyled",
|
||||
"withinPortal",
|
||||
]);
|
||||
|
||||
const nodeOf = (value: unknown): AstNode | null =>
|
||||
value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
|
||||
? (value as AstNode)
|
||||
: null;
|
||||
|
||||
const nodesOf = (value: unknown): AstNode[] =>
|
||||
Array.isArray(value) ? value.map(nodeOf).filter((node): node is AstNode => node !== null) : [];
|
||||
|
||||
const normalizedProperty = (value: unknown): string =>
|
||||
String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase();
|
||||
|
||||
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
|
||||
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
|
||||
let operations = 0;
|
||||
|
||||
const add = (node: AstNode, message: string, severity: "error" | "warning" = "error") => {
|
||||
const line = node.loc?.start?.line ?? 1;
|
||||
diagnostics.push({
|
||||
severity,
|
||||
message,
|
||||
index: Math.max(0, (node.start ?? 2) - 2),
|
||||
line,
|
||||
column: Math.max(1, (node.loc?.start?.column ?? 0) + 1 - (line === 1 ? 2 : 0)),
|
||||
});
|
||||
};
|
||||
|
||||
const checkBudget = (node: AstNode, depth: number) => {
|
||||
operations += 1;
|
||||
if (depth > MAX_AST_DEPTH) add(node, `Template exceeds the AST depth limit (${MAX_AST_DEPTH})`);
|
||||
if (operations === MAX_OPERATIONS + 1) {
|
||||
add(node, `Template exceeds the operation limit (${MAX_OPERATIONS})`);
|
||||
}
|
||||
return depth <= MAX_AST_DEPTH && operations <= MAX_OPERATIONS;
|
||||
};
|
||||
|
||||
const tagName = (node: AstNode): string | null => {
|
||||
if (node.type === "JSXIdentifier") return String(node.name ?? "");
|
||||
if (node.type !== "JSXMemberExpression") return null;
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
const left = object ? tagName(object) : null;
|
||||
const right = property ? tagName(property) : null;
|
||||
return left && right ? `${left}.${right}` : null;
|
||||
};
|
||||
|
||||
const visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
|
||||
if (!checkBudget(node, depth)) return;
|
||||
|
||||
switch (node.type) {
|
||||
case "Program":
|
||||
nodesOf(node.body).forEach((child) => visit(child, depth + 1, bindings));
|
||||
return;
|
||||
case "ExpressionStatement": {
|
||||
const expression = nodeOf(node.expression);
|
||||
if (expression) visit(expression, depth + 1, bindings);
|
||||
return;
|
||||
}
|
||||
case "JSXFragment":
|
||||
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
|
||||
return;
|
||||
case "JSXElement": {
|
||||
const opening = nodeOf(node.openingElement);
|
||||
if (!opening) {
|
||||
add(node, "Invalid JSX element");
|
||||
return;
|
||||
}
|
||||
const nameNode = nodeOf(opening.name);
|
||||
const name = nameNode ? tagName(nameNode) : null;
|
||||
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
|
||||
if (!descriptor || descriptor.safety === "denied") {
|
||||
add(opening, name ? `Component '${name}' is not available` : "Invalid JSX component name");
|
||||
}
|
||||
|
||||
for (const attribute of nodesOf(opening.attributes)) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const argument = nodeOf(attribute.argument);
|
||||
if (argument) visit(argument, depth + 1, bindings);
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") {
|
||||
add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
|
||||
continue;
|
||||
}
|
||||
const attributeNameNode = nodeOf(attribute.name);
|
||||
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
|
||||
const supportedProps = name ? customJsxSupportedPropsByName.get(name) : undefined;
|
||||
if ((/^on/i.test(attributeName) && !supportedProps?.has(attributeName)) || blockedProps.has(attributeName)) {
|
||||
add(attribute, `Prop '${attributeName}' is not allowed`);
|
||||
} else if (name && !supportedProps?.has(attributeName)) {
|
||||
add(attribute, `Prop '${attributeName}' is not supported by ${name} and will be ignored`, "warning");
|
||||
}
|
||||
const value = nodeOf(attribute.value);
|
||||
if (value?.type === "JSXExpressionContainer") {
|
||||
const expression = nodeOf(value.expression);
|
||||
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
|
||||
}
|
||||
}
|
||||
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
|
||||
return;
|
||||
}
|
||||
case "JSXText":
|
||||
case "JSXEmptyExpression":
|
||||
case "TemplateElement":
|
||||
return;
|
||||
case "JSXExpressionContainer": {
|
||||
const expression = nodeOf(node.expression);
|
||||
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
|
||||
return;
|
||||
}
|
||||
case "Literal":
|
||||
if (node.regex !== undefined || typeof node.value === "bigint") {
|
||||
add(node, "Regular expressions and bigint literals are not supported");
|
||||
}
|
||||
return;
|
||||
case "Identifier": {
|
||||
const name = String(node.name ?? "");
|
||||
if (!bindings.has(name)) add(node, `Unknown binding '${name}'`);
|
||||
return;
|
||||
}
|
||||
case "ArrayExpression":
|
||||
nodesOf(node.elements).forEach((child) => {
|
||||
const argument = child.type === "SpreadElement" ? nodeOf(child.argument) : child;
|
||||
if (argument) visit(argument, depth + 1, bindings);
|
||||
});
|
||||
return;
|
||||
case "ObjectExpression":
|
||||
nodesOf(node.properties).forEach((property) => {
|
||||
if (property.type === "SpreadElement") {
|
||||
const argument = nodeOf(property.argument);
|
||||
if (argument) visit(argument, depth + 1, bindings);
|
||||
return;
|
||||
}
|
||||
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
|
||||
add(property, "Only explicit object properties are supported");
|
||||
return;
|
||||
}
|
||||
const key = nodeOf(property.key);
|
||||
if (property.computed && key) visit(key, depth + 1, bindings);
|
||||
if (!property.computed && key && blockedProperties.has(normalizedProperty(key.name ?? key.value))) {
|
||||
add(key, "Reflective object properties are not allowed");
|
||||
}
|
||||
const value = nodeOf(property.value);
|
||||
if (value) visit(value, depth + 1, bindings);
|
||||
});
|
||||
return;
|
||||
case "UnaryExpression":
|
||||
if (!["!", "+", "-", "typeof"].includes(String(node.operator))) {
|
||||
add(node, `Unary operator '${String(node.operator)}' is not supported`);
|
||||
}
|
||||
if (nodeOf(node.argument)) visit(node.argument as AstNode, depth + 1, bindings);
|
||||
return;
|
||||
case "BinaryExpression":
|
||||
case "LogicalExpression": {
|
||||
const left = nodeOf(node.left);
|
||||
const right = nodeOf(node.right);
|
||||
if (left) visit(left, depth + 1, bindings);
|
||||
if (right) visit(right, depth + 1, bindings);
|
||||
return;
|
||||
}
|
||||
case "ConditionalExpression": {
|
||||
[node.test, node.consequent, node.alternate].map(nodeOf).forEach((child) => {
|
||||
if (child) visit(child, depth + 1, bindings);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "MemberExpression": {
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
if (object) visit(object, depth + 1, bindings);
|
||||
if (node.computed && property) visit(property, depth + 1, bindings);
|
||||
if (property && blockedProperties.has(normalizedProperty(property.name ?? property.value))) {
|
||||
add(property, "Reflective property access is not allowed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "CallExpression": {
|
||||
const callee = nodeOf(node.callee);
|
||||
if (callee) visit(callee, depth + 1, bindings);
|
||||
nodesOf(node.arguments).forEach((argument) => {
|
||||
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
|
||||
else visit(argument, depth + 1, bindings);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "ArrowFunctionExpression": {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
add(node, "Only synchronous expression-bodied arrow callbacks are supported");
|
||||
return;
|
||||
}
|
||||
const callbackBindings = new Set(bindings);
|
||||
for (const parameter of nodesOf(node.params)) {
|
||||
if (parameter.type !== "Identifier") add(parameter, "Callback parameters must be identifiers");
|
||||
else callbackBindings.add(String(parameter.name));
|
||||
}
|
||||
const body = nodeOf(node.body);
|
||||
if (body) visit(body, depth + 1, callbackBindings);
|
||||
return;
|
||||
}
|
||||
case "TemplateLiteral":
|
||||
nodesOf(node.expressions).forEach((expression) => visit(expression, depth + 1, bindings));
|
||||
return;
|
||||
case "ChainExpression": {
|
||||
const expression = nodeOf(node.expression);
|
||||
if (expression) visit(expression, depth + 1, bindings);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
add(node, `Unsupported expression '${node.type}'`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
locations: true,
|
||||
}) as unknown as AstNode;
|
||||
visit(program, 0, rootBindings);
|
||||
} catch (error) {
|
||||
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: parseError.message,
|
||||
index: Math.max(0, (parseError.pos ?? 2) - 2),
|
||||
line: parseError.loc?.line ?? 1,
|
||||
column: Math.max(1, (parseError.loc?.column ?? 2) - 1),
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customJsxExamples } from "@homarr/definitions";
|
||||
|
||||
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "./custom-widget";
|
||||
import { validateCustomJsxTemplate } from "./custom-jsx-template";
|
||||
|
||||
describe("custom JSX v2 validation", () => {
|
||||
test.each(customJsxExamples)("validates the $id shared example", (example) => {
|
||||
expect(() =>
|
||||
customJsxDisplayConfigV2Schema.parse({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: example.template,
|
||||
networkScope: "public",
|
||||
requests: [...example.requests],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("retains display-only v1 compatibility", () => {
|
||||
expect(displayConfigSchema.parse({ type: "customJsx", template: "<Text>{data.value}</Text>" })).toEqual({
|
||||
type: "customJsx",
|
||||
template: "<Text>{data.value}</Text>",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts benign text containing interpreter-reserved words", () => {
|
||||
expect(() =>
|
||||
customJsxDisplayConfigV2Schema.parse({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text>constructor fetch prototype</Text>",
|
||||
networkScope: "public",
|
||||
requests: [],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts supported Tabs, ScrollArea, and SubData image props without warnings", () => {
|
||||
const diagnostics = validateCustomJsxTemplate(
|
||||
'<Tabs.List grow><ScrollArea offsetScrollbars><SubData as="Image" fit="contain" alt="Artwork" /></ScrollArea></Tabs.List>',
|
||||
);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects malformed JSX and direct global fetch while allowing declarative onParams", () => {
|
||||
const config = {
|
||||
type: "customJsx" as const,
|
||||
jsxApiVersion: 2 as const,
|
||||
networkScope: "public" as const,
|
||||
requests: [],
|
||||
};
|
||||
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "<Text>" }).success).toBe(false);
|
||||
expect(
|
||||
customJsxDisplayConfigV2Schema.safeParse({ ...config, template: '<Text>{fetch("/private")}</Text>' }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customJsxDisplayConfigV2Schema.safeParse({
|
||||
...config,
|
||||
template: '<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />',
|
||||
requests: [
|
||||
{
|
||||
id: "toggle",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/toggle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
auth: "none",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects reserved manifest headers and mismatched request component kinds", () => {
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
staticHeaders: { Authorization: "secret" },
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
customJsxDisplayConfigV2Schema.safeParse({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
template: '<ActionButton requestId="status" label="Run" />',
|
||||
requests: [
|
||||
{
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts 50,000 characters and rejects 50,001", () => {
|
||||
const config = {
|
||||
type: "customJsx" as const,
|
||||
jsxApiVersion: 2 as const,
|
||||
networkScope: "public" as const,
|
||||
requests: [],
|
||||
};
|
||||
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_000) }).success).toBe(true);
|
||||
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_001) }).success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects duplicate request IDs", () => {
|
||||
const request = {
|
||||
id: "status",
|
||||
kind: "query" as const,
|
||||
method: "GET" as const,
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
auth: "none" as const,
|
||||
minimumBoardPermission: "view" as const,
|
||||
};
|
||||
const result = customJsxDisplayConfigV2Schema.safeParse({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text />",
|
||||
networkScope: "public",
|
||||
requests: [request, request],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects mutation-shaped queries and weak DELETE permissions", () => {
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
id: "mutating-query",
|
||||
kind: "query",
|
||||
method: "POST",
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
id: "delete",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/resource",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateCustomJsxTemplate } from "./custom-jsx-template";
|
||||
|
||||
export const customWidgetAuthTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
|
||||
export type CustomWidgetAuthType = (typeof customWidgetAuthTypes)[number];
|
||||
|
||||
@@ -257,26 +259,265 @@ const rawDisplayConfigSchema = z.object({
|
||||
.describe("Maximum height of the raw JSON display area in pixels (50–1000, default: 300)"),
|
||||
});
|
||||
|
||||
const FORBIDDEN_TEMPLATE_PATTERN =
|
||||
/\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bXMLHttpRequest\b/i;
|
||||
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
|
||||
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
|
||||
|
||||
const customJsxDisplayConfigSchema = z.object({
|
||||
export const customJsxRequestParameterTypes = ["string", "number", "boolean"] as const;
|
||||
export type CustomJsxRequestParameterType = (typeof customJsxRequestParameterTypes)[number];
|
||||
|
||||
const requestIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_-]*$/,
|
||||
"Request IDs must start with a letter and contain only lowercase letters, numbers, - or _",
|
||||
);
|
||||
|
||||
const requestParametersSchema = z
|
||||
.record(requestIdSchema, z.enum(customJsxRequestParameterTypes))
|
||||
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
|
||||
|
||||
const reservedRequestHeaders = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
]);
|
||||
|
||||
const staticHeadersSchema = z
|
||||
.record(z.string().min(1).max(128), z.string().max(8192))
|
||||
.refine((headers) => Object.keys(headers).length <= 32, "A request can declare at most 32 static headers")
|
||||
.refine(
|
||||
(headers) =>
|
||||
Object.keys(headers).every((name) => {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
!reservedRequestHeaders.has(normalized) &&
|
||||
!normalized.startsWith("proxy-") &&
|
||||
!normalized.startsWith("sec-") &&
|
||||
!normalized.startsWith("x-forwarded-")
|
||||
);
|
||||
}),
|
||||
"Static headers cannot override authentication, cookies, routing, forwarding, proxy, or hop-by-hop headers",
|
||||
);
|
||||
|
||||
const collectBodyParameterNames = (value: unknown, result = new Set<string>()): Set<string> => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectBodyParameterNames(entry, result));
|
||||
return result;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return result;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
result.add(entries[0][1]);
|
||||
return result;
|
||||
}
|
||||
entries.forEach(([, entry]) => collectBodyParameterNames(entry, result));
|
||||
return result;
|
||||
};
|
||||
|
||||
export const customJsxRequestSchema = z
|
||||
.object({
|
||||
id: requestIdSchema,
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(customWidgetMethods),
|
||||
pathTemplate: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.startsWith("/")
|
||||
.refine((path) => !path.startsWith("//"), "Path templates must be same-origin paths")
|
||||
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
|
||||
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
|
||||
parameters: requestParametersSchema,
|
||||
bodyTemplate: z.json().optional(),
|
||||
staticHeaders: staticHeadersSchema.optional(),
|
||||
auth: z.enum(["inherit", "none"]),
|
||||
minimumBoardPermission: z.enum(["view", "modify", "full"]),
|
||||
cacheTtlSeconds: z.number().int().min(0).max(3600).optional(),
|
||||
allowViewerExecution: z.literal(true).optional(),
|
||||
})
|
||||
.superRefine((request, ctx) => {
|
||||
if (request.kind === "query" && request.method !== "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["method"],
|
||||
message: "Query requests must use GET",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "action" && request.method === "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["method"],
|
||||
message: "Action requests must use POST, PUT, PATCH, or DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "query" && request.minimumBoardPermission !== "view") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["minimumBoardPermission"],
|
||||
message: "Query requests use view permission",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && request.minimumBoardPermission !== "full") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["minimumBoardPermission"],
|
||||
message: "DELETE actions require full board permission",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.kind === "action" &&
|
||||
request.method !== "DELETE" &&
|
||||
request.minimumBoardPermission === "view" &&
|
||||
request.allowViewerExecution !== true
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["allowViewerExecution"],
|
||||
message: "Viewer actions require explicit allowViewerExecution approval",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "query" && request.bodyTemplate !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["bodyTemplate"],
|
||||
message: "GET query requests cannot define a request body",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "action" && request.cacheTtlSeconds !== undefined && request.cacheTtlSeconds !== 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cacheTtlSeconds"],
|
||||
message: "Actions cannot be cached",
|
||||
});
|
||||
}
|
||||
|
||||
const declaredParameters = new Set(Object.keys(request.parameters));
|
||||
const pathParameters = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
|
||||
for (const parameter of [...pathParameters, ...collectBodyParameterNames(request.bodyTemplate)]) {
|
||||
if (!declaredParameters.has(parameter)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [request.bodyTemplate === undefined ? "pathTemplate" : "parameters"],
|
||||
message: `Placeholder '${parameter}' is not declared in request parameters`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
|
||||
|
||||
const customJsxTemplateSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50000)
|
||||
.superRefine((template, ctx) => {
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
if (diagnostic.severity === "error") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `${diagnostic.message} (line ${diagnostic.line}, column ${diagnostic.column})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.describe(
|
||||
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct network access is unavailable; v2 templates reference named requests.",
|
||||
);
|
||||
|
||||
const customJsxDisplayConfigV1Schema = z.object({
|
||||
type: z
|
||||
.literal("customJsx")
|
||||
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
|
||||
template: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50000)
|
||||
.refine((t) => !FORBIDDEN_TEMPLATE_PATTERN.test(t), {
|
||||
message:
|
||||
"Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document)",
|
||||
})
|
||||
.describe(
|
||||
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Use SubFetch for in-widget HTTP requests. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document",
|
||||
),
|
||||
template: customJsxTemplateSchema,
|
||||
jsxApiVersion: z.never().optional(),
|
||||
networkScope: z.never().optional(),
|
||||
requests: z.never().optional(),
|
||||
});
|
||||
|
||||
export const customJsxDisplayConfigV2Schema = z
|
||||
.object({
|
||||
type: z.literal("customJsx"),
|
||||
jsxApiVersion: z.literal(2),
|
||||
template: customJsxTemplateSchema,
|
||||
networkScope: z.enum(customJsxNetworkScopes),
|
||||
requests: z.array(customJsxRequestSchema).max(64),
|
||||
})
|
||||
.superRefine((config, ctx) => {
|
||||
const requestsById = new Map<string, CustomJsxRequest>();
|
||||
for (const [index, request] of config.requests.entries()) {
|
||||
if (requestsById.has(request.id)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["requests", index, "id"],
|
||||
message: `Duplicate request ID: ${request.id}`,
|
||||
});
|
||||
}
|
||||
requestsById.set(request.id, request);
|
||||
}
|
||||
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
|
||||
for (const match of config.template.matchAll(networkComponentPattern)) {
|
||||
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
|
||||
const attributes = match[2] ?? "";
|
||||
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
|
||||
const requestId = idMatch?.[1] ?? idMatch?.[2];
|
||||
if (!requestId) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} must use a literal requestId`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const request = requestsById.get(requestId);
|
||||
const expectedKind = component === "SubFetch" ? "query" : "action";
|
||||
if (!request) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} references unknown request '${requestId}'`,
|
||||
});
|
||||
} else if (request.kind !== expectedKind) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (/\b(?:url|method|headers|body)\s*=/.test(attributes)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} cannot declare inline network settings`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomJsxDisplayConfigV2 = z.infer<typeof customJsxDisplayConfigV2Schema>;
|
||||
|
||||
const actionButtonDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("actionButton")
|
||||
@@ -302,7 +543,7 @@ const actionButtonDisplayConfigSchema = z.object({
|
||||
});
|
||||
|
||||
export const displayConfigSchema = z
|
||||
.discriminatedUnion("type", [
|
||||
.union([
|
||||
singleValueDisplayConfigSchema,
|
||||
keyValueDisplayConfigSchema,
|
||||
tableDisplayConfigSchema,
|
||||
@@ -312,7 +553,8 @@ export const displayConfigSchema = z
|
||||
countGridDisplayConfigSchema,
|
||||
rawDisplayConfigSchema,
|
||||
actionButtonDisplayConfigSchema,
|
||||
customJsxDisplayConfigSchema,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
customJsxDisplayConfigV1Schema,
|
||||
])
|
||||
.describe(
|
||||
"Configuration for how API response data is displayed. The 'type' field MUST match the top-level 'displayType' field exactly. Choose the variant that matches your displayType.",
|
||||
@@ -326,13 +568,35 @@ const displayTypesMatch = (displayType?: string, configType?: string): boolean =
|
||||
return displayType === configType;
|
||||
};
|
||||
|
||||
const displayTypeMatchRefinement = (d: { displayType: string; displayConfig: { type: string } }) =>
|
||||
displayTypesMatch(d.displayType, d.displayConfig.type);
|
||||
const displayTypeMatchMessage = {
|
||||
message: "displayType must match displayConfig.type",
|
||||
path: ["displayConfig", "type"],
|
||||
};
|
||||
|
||||
const isCustomJsxV2 = (config: DisplayConfig | undefined): config is CustomJsxDisplayConfigV2 =>
|
||||
config?.type === "customJsx" && "jsxApiVersion" in config && config.jsxApiVersion === 2;
|
||||
|
||||
const validateDefinitionDisplay = (
|
||||
definition: { method?: CustomWidgetMethod; displayType?: string; displayConfig?: DisplayConfig },
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (!displayTypesMatch(definition.displayType, definition.displayConfig?.type)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: displayTypeMatchMessage.message,
|
||||
path: displayTypeMatchMessage.path,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCustomJsxV2(definition.displayConfig) && definition.method !== undefined && definition.method !== "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
|
||||
path: ["method"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const baseDefinitionSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -404,12 +668,12 @@ const secretsInputSchema = z.array(
|
||||
|
||||
export const customWidgetCreateSchema = baseDefinitionSchema
|
||||
.extend({ secrets: secretsInputSchema })
|
||||
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
export const customWidgetUpdateSchema = baseDefinitionSchema
|
||||
.partial()
|
||||
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
|
||||
.refine((d) => displayTypesMatch(d.displayType, d.displayConfig?.type), displayTypeMatchMessage);
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
const customWidgetImportFieldsSchema = z.object({
|
||||
name: z
|
||||
@@ -456,9 +720,9 @@ const customWidgetImportFieldsSchema = z.object({
|
||||
export const customWidgetImportSchema = customWidgetImportFieldsSchema
|
||||
.extend({
|
||||
$schema: z
|
||||
.literal("homarr-custom-widget-v2")
|
||||
.enum(["homarr-custom-widget-v2", "homarr-custom-widget-v3"])
|
||||
.optional()
|
||||
.describe("Schema version identifier. Should be 'homarr-custom-widget-v2' for current format."),
|
||||
.describe("Schema version identifier. New exports use 'homarr-custom-widget-v3'; v2 remains importable."),
|
||||
url: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -466,6 +730,6 @@ export const customWidgetImportSchema = customWidgetImportFieldsSchema
|
||||
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
|
||||
),
|
||||
})
|
||||
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
|
||||
@@ -75,6 +75,8 @@
|
||||
"@tiptap/extension-underline": "catalog:",
|
||||
"@tiptap/react": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"acorn": "catalog:",
|
||||
"acorn-jsx": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"jsonpath-plus": "catalog:",
|
||||
@@ -84,7 +86,6 @@
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-jsx-parser": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-quill-new": "catalog:",
|
||||
"recharts": "catalog:",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
@@ -16,18 +17,16 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
}: CommonWidgetInputProps<"customWidgetSelect">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
const { data, isLoading } = clientApi.customWidget.all.useQuery();
|
||||
const board = useOptionalBoard();
|
||||
const { data, isLoading } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: board?.id ?? "" },
|
||||
{ enabled: board !== null },
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const currentValue = form.values.options[property] as string;
|
||||
|
||||
const definitions = useMemo(
|
||||
() =>
|
||||
(data ?? [])
|
||||
.filter((def) => def.enabled || def.id === currentValue)
|
||||
.map((def) => ({ value: def.id, label: def.name })),
|
||||
[data, currentValue],
|
||||
);
|
||||
const definitions = useMemo(() => (data ?? []).map((def) => ({ value: def.id, label: def.name })), [data]);
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -333,20 +333,22 @@ function RawDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const executeMutation = clientApi.customWidget.execute.useMutation();
|
||||
const executeMutation = clientApi.widget.customApi.executeBaseAction.useMutation();
|
||||
const [lastSuccess, setLastSuccess] = useState(false);
|
||||
|
||||
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
|
||||
const buttonColor = (data.buttonColor as string) ?? "blue";
|
||||
const confirmText = (data.confirmText as string) || "";
|
||||
const requiresConfirmation = data.requiresConfirmation === true;
|
||||
const successMessage = (data.successMessage as string) || t("executeSuccess");
|
||||
const definitionId = data.widgetDefinitionId as string | undefined;
|
||||
const itemId = data.widgetItemId as string | undefined;
|
||||
const isEditMode = data.isEditMode === true;
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!definitionId) return;
|
||||
const handleExecute = async (confirmed: boolean) => {
|
||||
if (!itemId || isEditMode) return;
|
||||
setLastSuccess(false);
|
||||
try {
|
||||
const result = await executeMutation.mutateAsync({ definitionId });
|
||||
const result = await executeMutation.mutateAsync({ itemId, confirmed });
|
||||
if (result.success) {
|
||||
setLastSuccess(true);
|
||||
showSuccessNotification({ title: buttonLabel, message: successMessage });
|
||||
@@ -360,14 +362,14 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (confirmText) {
|
||||
if (confirmText || requiresConfirmation) {
|
||||
openConfirmModal({
|
||||
title: buttonLabel,
|
||||
children: confirmText,
|
||||
onConfirm: () => void handleExecute(),
|
||||
children: confirmText || t("executeConfirm"),
|
||||
onConfirm: () => void handleExecute(true),
|
||||
});
|
||||
} else {
|
||||
void handleExecute();
|
||||
void handleExecute(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,6 +382,7 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
loading={executeMutation.isPending}
|
||||
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
|
||||
variant={lastSuccess ? "light" : "filled"}
|
||||
disabled={!itemId || isEditMode}
|
||||
>
|
||||
{executeMutation.isPending ? t("executing") : buttonLabel}
|
||||
</Button>
|
||||
@@ -400,7 +403,7 @@ export const displayComponents: Record<string, ComponentType<{ data: Record<stri
|
||||
customJsx: CustomJsxDisplay,
|
||||
};
|
||||
|
||||
export default function CustomApiWidget({ options }: WidgetComponentProps<"customApi">) {
|
||||
export default function CustomApiWidget({ options, itemId, isEditMode }: WidgetComponentProps<"customApi">) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const { definitionId, refreshInterval } = options;
|
||||
|
||||
@@ -417,20 +420,45 @@ export default function CustomApiWidget({ options }: WidgetComponentProps<"custo
|
||||
);
|
||||
}
|
||||
|
||||
return <CustomApiWidgetInner definitionId={definitionId} refreshInterval={refreshInterval as number} />;
|
||||
return (
|
||||
<CustomApiWidgetInner
|
||||
definitionId={definitionId}
|
||||
itemId={itemId}
|
||||
isEditMode={isEditMode}
|
||||
refreshInterval={refreshInterval as number}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
|
||||
function CustomApiWidgetInner({
|
||||
definitionId,
|
||||
itemId,
|
||||
isEditMode,
|
||||
refreshInterval,
|
||||
}: {
|
||||
definitionId: string;
|
||||
itemId: string | undefined;
|
||||
isEditMode: boolean;
|
||||
refreshInterval: number;
|
||||
}) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const tCustomWidget = useScopedI18n("customWidget");
|
||||
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
|
||||
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
|
||||
const intervalMs = Math.max(1000, safeInterval * 1000);
|
||||
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
|
||||
{ definitionId },
|
||||
{ itemId: itemId ?? "" },
|
||||
{
|
||||
enabled: Boolean(itemId),
|
||||
refetchInterval: (query) => {
|
||||
const result = query.state.data as Record<string, unknown> | undefined;
|
||||
if (result?.type === "actionButton" || result?.type === "disabled") return false;
|
||||
if (
|
||||
result?.type === "actionButton" ||
|
||||
result?.type === "disabled" ||
|
||||
result?.type === "networkAccessNeedsReview"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return intervalMs;
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
@@ -455,7 +483,7 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color={`var(--mantine-color-${isNotFound ? "yellow" : "red"}-6)`} />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isNotFound ? t("definitionNotFound") : error.message}
|
||||
{isNotFound ? t("definitionNotFound") : tCustomJsx("requestFailed")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -477,11 +505,24 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
|
||||
);
|
||||
}
|
||||
|
||||
if (dataType === "networkAccessNeedsReview") {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{tCustomJsx("networkAccessNeedsReview")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) {
|
||||
const enrichedData =
|
||||
dataType === "actionButton" || dataType === "customJsx"
|
||||
? { ...widgetData, widgetDefinitionId: definitionId }
|
||||
? { ...widgetData, widgetDefinitionId: definitionId, widgetItemId: itemId, isEditMode }
|
||||
: widgetData;
|
||||
return <Component data={enrichedData} />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import * as MantineCharts from "@mantine/charts";
|
||||
import * as MantineCore from "@mantine/core";
|
||||
import * as MantineDates from "@mantine/dates";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
|
||||
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
|
||||
import CustomJsxDisplay from "./custom-jsx-display";
|
||||
import { SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { SAFE_BINDINGS, WHITELISTED_COMPONENTS } from "./jsx-whitelist";
|
||||
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
||||
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
|
||||
}));
|
||||
|
||||
const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
|
||||
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false;
|
||||
if (typeof value === "function") return true;
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const reactType = (value as { $$typeof?: unknown }).$$typeof;
|
||||
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
|
||||
};
|
||||
|
||||
describe("CustomJsxDisplay", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -29,8 +49,8 @@ describe("CustomJsxDisplay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root?.unmount();
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
});
|
||||
|
||||
@@ -64,6 +84,147 @@ describe("CustomJsxDisplay", () => {
|
||||
expect(container.textContent).toContain("Safe");
|
||||
});
|
||||
|
||||
it("supports bounded collection callbacks and safe string helpers", async () => {
|
||||
await renderDisplay({
|
||||
template:
|
||||
'<Stack>{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => <Text key={i}>{String(i + 1) + ". " + item.name.toUpperCase()}</Text>)}</Stack>',
|
||||
data: {
|
||||
items: [
|
||||
{ name: "alpha", enabled: true },
|
||||
{ name: "hidden", enabled: false },
|
||||
{ name: "beta", enabled: true },
|
||||
{ name: "gamma", enabled: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("1. ALPHA");
|
||||
expect(container.textContent).toContain("2. BETA");
|
||||
expect(container.textContent).not.toContain("GAMMA");
|
||||
});
|
||||
|
||||
it("renders fetched data through an interpreter-created SubFetch child", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const rendered = renderSafeJsx({
|
||||
template:
|
||||
"<SubFetch>{(detail, meta) => <Text>{detail.types.map((entry) => entry.name).join(', ')} · {meta.status}</Text>}</SubFetch>",
|
||||
components: {
|
||||
SubFetch: ((props: { render?: (data: unknown, metadata: unknown) => ReactNode }) =>
|
||||
props.render?.({ types: [{ name: "grass" }, { name: "poison" }] }, { status: 200 })) as never,
|
||||
Text: ((props: { children?: ReactNode }) => createElement("span", null, props.children)) as never,
|
||||
},
|
||||
bindings: {},
|
||||
});
|
||||
|
||||
await act(async () => root.render(rendered.node));
|
||||
|
||||
expect(container.textContent).toBe("grass, poison · 200");
|
||||
});
|
||||
|
||||
it("blocks reflective properties assembled with string concatenation", async () => {
|
||||
await renderDisplay({
|
||||
template: '<Text>{data["con" + "structor"]}</Text>',
|
||||
data: { value: 1 },
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("reflective property");
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<Text>{data["constr\\u0075ctor"]}</Text>',
|
||||
'<Text>{data["__pro" + "to__"]}</Text>',
|
||||
'<Text>{String["b" + "ind"](null)}</Text>',
|
||||
"<Text>{data.items.map.call(null, (item) => item)}</Text>",
|
||||
"<Text>{data.items.map.apply(null, [])}</Text>",
|
||||
])("blocks Unicode, computed, and call/apply reflective escapes", (template) => {
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template,
|
||||
components: { Text: (() => null) as never },
|
||||
bindings: SAFE_BINDINGS({ items: [1] }),
|
||||
}),
|
||||
).toThrow(/reflective property/);
|
||||
});
|
||||
|
||||
it("never resolves inherited data properties", () => {
|
||||
const inherited = Object.create({ secret: "leak" }) as Record<string, unknown>;
|
||||
inherited.visible = "safe";
|
||||
const bindings = SAFE_BINDINGS(inherited);
|
||||
const data = bindings.data as Record<string, unknown>;
|
||||
|
||||
expect(data.visible).toBe("safe");
|
||||
expect(data.secret).toBeUndefined();
|
||||
expect(Object.getPrototypeOf(data)).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks direct global calls but allows benign text containing fetch", async () => {
|
||||
await renderDisplay({ template: "<Text>fetch is available through controlled requests</Text>", data: {} });
|
||||
expect(container.textContent).toContain("fetch is available through controlled requests");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(CustomJsxDisplay, { data: { template: '<Text>{fetch("/private")}</Text>', data: {} } }),
|
||||
),
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("Unknown binding: fetch");
|
||||
});
|
||||
|
||||
it("strips polymorphic, callback, unsafe URL, and escaping style props", () => {
|
||||
const props = sanitizeCustomJsxProps({
|
||||
component: "iframe",
|
||||
renderRoot: "iframe",
|
||||
withinPortal: true,
|
||||
onClick: () => undefined,
|
||||
href: "javascript:alert(1)",
|
||||
style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" },
|
||||
});
|
||||
|
||||
expect(props).toEqual({ style: { flex: 1, color: "red" } });
|
||||
});
|
||||
|
||||
it("enforces collection and template length budgets", () => {
|
||||
const bindings = SAFE_BINDINGS({ items: [1, 2, 3] });
|
||||
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template: "<Stack>{data.items.map((item) => <Text>{item}</Text>)}</Stack>",
|
||||
components: {
|
||||
Text: ((props: { children?: unknown }) => props.children) as never,
|
||||
Stack: (() => null) as never,
|
||||
},
|
||||
bindings,
|
||||
budgets: { maxCollectionItems: 2 },
|
||||
}),
|
||||
).toThrow(/collection limit/);
|
||||
|
||||
expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow(
|
||||
new SafeJsxError("Template exceeds the 50000 character limit"),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template: '<Text>{"x".padStart(1000, "x")}</Text>',
|
||||
components: { Text: (() => null) as never },
|
||||
bindings: {},
|
||||
budgets: { maxStringLength: 10 },
|
||||
}),
|
||||
).toThrow(/string length limit/);
|
||||
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template: "<B/>".repeat(10_000),
|
||||
components: { B: (() => null) as never },
|
||||
bindings: {},
|
||||
}),
|
||||
).toThrow("Template exceeded the rendered node limit (10000)");
|
||||
});
|
||||
|
||||
describe("safe bindings security", () => {
|
||||
it("exposes wrapper functions, not native constructors", () => {
|
||||
const bindings = SAFE_BINDINGS({ value: 7 });
|
||||
@@ -153,6 +314,17 @@ describe("CustomJsxDisplay", () => {
|
||||
expect(math.abs(-5)).toBe(5);
|
||||
});
|
||||
|
||||
it("Date helpers only return primitives and getDay returns the weekday", () => {
|
||||
const bindings = SAFE_BINDINGS({});
|
||||
const date = bindings.Date as {
|
||||
create: (value: string) => number;
|
||||
getDay: (value: string) => number;
|
||||
};
|
||||
|
||||
expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number");
|
||||
expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0);
|
||||
});
|
||||
|
||||
it("JSON.stringify works correctly", () => {
|
||||
const bindings = SAFE_BINDINGS({});
|
||||
const json = bindings.JSON as { stringify: (v: unknown) => string };
|
||||
@@ -176,3 +348,205 @@ describe("CustomJsxDisplay", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom JSX component registry", () => {
|
||||
it.each([
|
||||
["@mantine/core", MantineCore],
|
||||
["@mantine/charts", MantineCharts],
|
||||
["@mantine/dates", MantineDates],
|
||||
] as const)("classifies every component-like %s export", (packageName, moduleExports) => {
|
||||
const classified = new Set(
|
||||
customJsxComponentRegistry
|
||||
.filter((component) => component.package === packageName)
|
||||
.flatMap(({ name }) => [name, name.replaceAll(".", "")]),
|
||||
);
|
||||
const unclassified = Object.entries(moduleExports)
|
||||
.filter(isComponentLikeExport)
|
||||
.map(([name]) => name)
|
||||
.filter((name) => !classified.has(name));
|
||||
|
||||
expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]);
|
||||
});
|
||||
|
||||
it("has a runtime implementation for every enabled registry component", () => {
|
||||
const missing = enabledCustomJsxComponents
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => !Object.hasOwn(WHITELISTED_COMPONENTS, name));
|
||||
|
||||
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("new collection/string methods", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
});
|
||||
|
||||
const renderAndGetText = async (template: string, data: Record<string, unknown> = {}) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } })));
|
||||
});
|
||||
return container.textContent ?? "";
|
||||
};
|
||||
|
||||
const render = (template: string, data: Record<string, unknown> = {}) =>
|
||||
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
|
||||
|
||||
it("find returns the first matching item", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
|
||||
items: [
|
||||
{ id: 1, name: "a" },
|
||||
{ id: 2, name: "b" },
|
||||
],
|
||||
});
|
||||
expect(text).toContain("b");
|
||||
});
|
||||
|
||||
it("findIndex returns correct index", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.findIndex((x) => x > 5)}</Text>`, { items: [1, 3, 7, 9] });
|
||||
expect(text).toContain("2");
|
||||
});
|
||||
|
||||
it("some returns boolean", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.some((x) => x > 5) ? "yes" : "no"}</Text>`, {
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
expect(text).toContain("no");
|
||||
});
|
||||
|
||||
it("every returns boolean", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.every((x) => x > 0) ? "all" : "nope"}</Text>`, {
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
expect(text).toContain("all");
|
||||
});
|
||||
|
||||
it("sort with comparator returns stable order on NaN", async () => {
|
||||
const text = await renderAndGetText(
|
||||
`<Text>{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}</Text>`,
|
||||
{ items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] },
|
||||
);
|
||||
expect(text).toContain("a,");
|
||||
expect(text).toContain("b");
|
||||
});
|
||||
|
||||
it("reduce accumulates values", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.nums.reduce((acc, n) => acc + n, 0)}</Text>`, {
|
||||
nums: [1, 2, 3],
|
||||
});
|
||||
expect(text).toContain("6");
|
||||
});
|
||||
|
||||
it("at returns element by negative index", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.at(-1)}</Text>`, { items: ["a", "b", "c"] });
|
||||
expect(text).toContain("c");
|
||||
});
|
||||
|
||||
it("flat(2) throws SafeJsxError", () => {
|
||||
expect(() => render(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
|
||||
});
|
||||
|
||||
it("flat(1) flattens one level", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.flat().join(",")}</Text>`, { items: [[1, 2], [3]] });
|
||||
expect(text).toContain("1,2,3");
|
||||
});
|
||||
|
||||
it("reverse returns reversed copy", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.reverse().join(",")}</Text>`, { items: [1, 2, 3] });
|
||||
expect(text).toContain("3,2,1");
|
||||
});
|
||||
|
||||
it("string replaceAll works", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.replaceAll("o", "0")}</Text>`, { s: "foobar" });
|
||||
expect(text).toContain("f00bar");
|
||||
});
|
||||
|
||||
it("string replaceAll with empty search is a no-op", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.replaceAll("", "x")}</Text>`, { s: "abc" });
|
||||
expect(text).toContain("abc");
|
||||
});
|
||||
|
||||
it("string repeat caps at budget", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.repeat(3)}</Text>`, { s: "hi" });
|
||||
expect(text).toContain("hihihi");
|
||||
});
|
||||
|
||||
it("string indexOf and lastIndexOf return positions", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}</Text>`, {
|
||||
s: "hello",
|
||||
});
|
||||
expect(text).toContain("2-3");
|
||||
});
|
||||
|
||||
it("string trimStart and trimEnd work", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.trimStart().trimEnd()}</Text>`, { s: " hi " });
|
||||
expect(text).toContain("hi");
|
||||
});
|
||||
|
||||
it("filter(Boolean) removes falsy values", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.filter(Boolean).join(",")}</Text>`, {
|
||||
items: ["a", "", "b", null, "c"],
|
||||
});
|
||||
expect(text).toContain("a,b,c");
|
||||
});
|
||||
|
||||
it("pop returns last element", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.url.split("/").filter(Boolean).pop()}</Text>`, {
|
||||
url: "https://pokeapi.co/api/v2/pokemon/1/",
|
||||
});
|
||||
expect(text).toContain("1");
|
||||
});
|
||||
|
||||
it("String(n).padStart works for number formatting", async () => {
|
||||
const text = await renderAndGetText(`<Text>{String(data.n + 1).padStart(4, "0")}</Text>`, { n: 5 });
|
||||
expect(text).toContain("0006");
|
||||
});
|
||||
|
||||
it("number arithmetic in string concatenation", async () => {
|
||||
const text = await renderAndGetText(`<Text>{"https://example.com/" + (data.index + 1) + ".png"}</Text>`, {
|
||||
index: 3,
|
||||
});
|
||||
expect(text).toContain("https://example.com/4.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared Custom JSX examples", () => {
|
||||
const sampleData: Record<string, unknown> = {
|
||||
name: "Server A",
|
||||
status: "online",
|
||||
value: 42,
|
||||
count: 1351,
|
||||
results: [{ name: "bulbasaur" }, { name: "ivysaur" }],
|
||||
holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }],
|
||||
timeline: [
|
||||
{ date: "2026-07-11", value: 40 },
|
||||
{ date: "2026-07-12", value: 42.5 },
|
||||
],
|
||||
albumArt: "https://example.com/cover.jpg",
|
||||
album: "Album",
|
||||
title: "Track",
|
||||
artist: "Artist",
|
||||
progress: 55,
|
||||
playing: true,
|
||||
shuffle: false,
|
||||
state: "on",
|
||||
};
|
||||
|
||||
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
|
||||
const rendered = renderSafeJsx({
|
||||
template: example.template,
|
||||
components: WHITELISTED_COMPONENTS,
|
||||
bindings: SAFE_BINDINGS(sampleData),
|
||||
});
|
||||
|
||||
expect(rendered.warnings).toEqual([]);
|
||||
expect(rendered.node).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import JsxParser from "react-jsx-parser";
|
||||
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { renderSafeJsx } from "./safe-jsx-interpreter";
|
||||
import type { CustomJsxRequestCapability } from "./widget-definition-context";
|
||||
import { WidgetDefinitionProvider } from "./widget-definition-context";
|
||||
|
||||
const MAX_PARSE_ERRORS = 5;
|
||||
@@ -16,25 +20,6 @@ function appendParseError(prev: string[], message: string): string[] {
|
||||
return [...prev, message];
|
||||
}
|
||||
|
||||
const METHOD_PATTERN = /(?:method|method)\s*=\s*["'](\w+)["']/gi;
|
||||
const SUBFETCH_PATTERN = /<(?:SubFetch|ActionButton|ToggleSwitch)\b/g;
|
||||
|
||||
export function extractHttpMethods(template: string): string[] {
|
||||
const methods = new Set<string>();
|
||||
if (SUBFETCH_PATTERN.test(template)) {
|
||||
SUBFETCH_PATTERN.lastIndex = 0;
|
||||
let match = METHOD_PATTERN.exec(template);
|
||||
while (match) {
|
||||
methods.add(match[1].toUpperCase());
|
||||
match = METHOD_PATTERN.exec(template);
|
||||
}
|
||||
if (/<ActionButton\b/.test(template) && methods.size === 0) methods.add("POST");
|
||||
if (/<ToggleSwitch\b/.test(template) && methods.size === 0) methods.add("POST");
|
||||
if (/<SubFetch\b/.test(template) && methods.size === 0) methods.add("GET");
|
||||
}
|
||||
return [...methods].toSorted();
|
||||
}
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "blue",
|
||||
POST: "orange",
|
||||
@@ -43,13 +28,90 @@ const METHOD_COLORS: Record<string, string> = {
|
||||
PATCH: "grape",
|
||||
};
|
||||
|
||||
const CAPABILITY_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
const CAPABILITY_KINDS = new Set(["query", "action"]);
|
||||
const CAPABILITY_PERMISSIONS = new Set(["view", "modify", "full"]);
|
||||
|
||||
function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.id !== "string" ||
|
||||
typeof record.kind !== "string" ||
|
||||
!CAPABILITY_KINDS.has(record.kind) ||
|
||||
typeof record.method !== "string" ||
|
||||
!CAPABILITY_METHODS.has(record.method) ||
|
||||
typeof record.minimumBoardPermission !== "string" ||
|
||||
!CAPABILITY_PERMISSIONS.has(record.minimumBoardPermission)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [record as unknown as CustomJsxRequestCapability];
|
||||
});
|
||||
}
|
||||
|
||||
interface RendererErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: (error: Error) => ReactNode;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface RendererErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class RendererErrorBoundary extends Component<RendererErrorBoundaryProps, RendererErrorBoundaryState> {
|
||||
public state: RendererErrorBoundaryState = { error: null };
|
||||
|
||||
public static getDerivedStateFromError(error: Error): RendererErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, _info: ErrorInfo): void {
|
||||
this.props.onError(error);
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
return this.state.error ? this.props.fallback(this.state.error) : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorAlert({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{error.message}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const renderErrorFallback = (error: Error) => <ErrorAlert error={error} />;
|
||||
|
||||
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const template = String(data.template ?? "");
|
||||
const apiData = data.data;
|
||||
const definitionId = String(data.widgetDefinitionId ?? "");
|
||||
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
|
||||
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
|
||||
const previewLiveActions = data.previewLiveActions === true;
|
||||
const isEditMode = data.isEditMode === true;
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
|
||||
const httpMethods = useMemo(() => extractHttpMethods(template), [template]);
|
||||
const requestCapabilities = useMemo(
|
||||
() => parseRequestCapabilities(data.requestCapabilities),
|
||||
[data.requestCapabilities],
|
||||
);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
const result = renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings });
|
||||
return { ...result, error: null };
|
||||
} catch (error) {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error));
|
||||
return { node: null, warnings: [], error: normalized };
|
||||
}
|
||||
}, [bindings, template]);
|
||||
|
||||
useEffect(() => {
|
||||
setParseErrors([]);
|
||||
@@ -59,53 +121,85 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
|
||||
setParseErrors((prev) => appendParseError(prev, error.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
rendered.warnings.forEach((warning) => handleError(new Error(warning)));
|
||||
if (rendered.error) handleError(rendered.error);
|
||||
}, [handleError, rendered]);
|
||||
|
||||
if (!template.trim()) {
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
No JSX template configured
|
||||
{t("noTemplate")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WidgetDefinitionProvider definitionId={definitionId}>
|
||||
<WidgetDefinitionProvider
|
||||
definitionId={definitionId}
|
||||
itemId={itemId}
|
||||
previewSessionId={previewSessionId}
|
||||
previewLiveActions={previewLiveActions}
|
||||
isEditMode={isEditMode}
|
||||
requestCapabilities={requestCapabilities}
|
||||
>
|
||||
<Stack gap={0} h="100%">
|
||||
{httpMethods.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p={4} mb={4} icon={<IconNetwork size={14} />}>
|
||||
<Group gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
HTTP:
|
||||
</Text>
|
||||
{httpMethods.map((m) => (
|
||||
<Badge key={m} size="xs" color={METHOD_COLORS[m] ?? "gray"} variant="filled">
|
||||
{m}
|
||||
{requestCapabilities.length > 0 && (
|
||||
<Group justify="flex-end" mb={4}>
|
||||
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
leftSection={<IconNetwork size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("interactive")}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Alert>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("networkCapabilities")}
|
||||
</Text>
|
||||
{requestCapabilities.map((capability) => (
|
||||
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{capability.id}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={METHOD_COLORS[capability.method] ?? "gray"} variant="light">
|
||||
{capability.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color="gray" variant="outline">
|
||||
{capability.minimumBoardPermission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
)}
|
||||
<JsxParser
|
||||
jsx={template}
|
||||
components={WHITELISTED_COMPONENTS as never}
|
||||
bindings={bindings}
|
||||
disableKeyGeneration
|
||||
componentsOnly
|
||||
allowUnknownElements={false}
|
||||
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
|
||||
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
|
||||
onError={handleError}
|
||||
renderError={({ error }) => (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{String(error)}</Text>
|
||||
</Alert>
|
||||
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
|
||||
{rendered.error ? (
|
||||
<ErrorAlert error={rendered.error} />
|
||||
) : (
|
||||
<RendererErrorBoundary key={template} onError={handleError} fallback={renderErrorFallback}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{parseErrors.length} template warning(s):
|
||||
{t("templateWarnings", { count: String(parseErrors.length) })}
|
||||
</Text>
|
||||
{parseErrors.map((msg) => (
|
||||
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Badge, Button, Code, Skeleton, Switch, Text, Title } from "@mantine/core";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Badge, Button, Code, Image, Skeleton, Switch, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useWidgetDefinitionId } from "./widget-definition-context";
|
||||
import { useCustomJsxRuntime } from "./widget-definition-context";
|
||||
|
||||
const MAX_CALLS_PER_MINUTE = 10;
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const callTimestamps = new Map<string, number[]>();
|
||||
type RuntimeParam = string | number | boolean;
|
||||
export type CustomJsxRuntimeParams = Record<string, RuntimeParam>;
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
interface RequestResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
data: unknown;
|
||||
error?: string;
|
||||
simulated?: boolean;
|
||||
}
|
||||
|
||||
type SubFetchResult =
|
||||
| { ok: true; status: number; data: unknown }
|
||||
| { ok: false; status: number; data: unknown; error?: string };
|
||||
export interface SubFetchMetadata {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
|
||||
play: IconPlayerPlay,
|
||||
@@ -32,16 +40,6 @@ const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
|
||||
|
||||
const SubFetchDataContext = createContext<unknown>(undefined);
|
||||
|
||||
function checkRateLimit(definitionId: string): boolean {
|
||||
const now = Date.now();
|
||||
const calls = callTimestamps.get(definitionId) ?? [];
|
||||
const recent = calls.filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (recent.length >= MAX_CALLS_PER_MINUTE) return false;
|
||||
recent.push(now);
|
||||
callTimestamps.set(definitionId, recent);
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseBool(value: unknown, defaultValue = false): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
@@ -49,32 +47,24 @@ function parseBool(value: unknown, defaultValue = false): boolean {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function parseMethod(method?: string): HttpMethod {
|
||||
const upper = (method ?? "GET").toUpperCase();
|
||||
if (HTTP_METHODS.includes(upper as HttpMethod)) return upper as HttpMethod;
|
||||
return "GET";
|
||||
}
|
||||
|
||||
function parseJsonRecord(str?: string): Record<string, string> | undefined {
|
||||
if (!str?.trim()) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(str);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return undefined;
|
||||
function normalizeParams(value: unknown): CustomJsxRuntimeParams | null {
|
||||
if (value === undefined) return {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const result: CustomJsxRuntimeParams = {};
|
||||
for (const [key, param] of Object.entries(value)) {
|
||||
if (key === "constructor" || key === "prototype" || key === "__proto__") return null;
|
||||
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
|
||||
result[key] = param;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path?: string): unknown {
|
||||
if (!path?.trim()) return obj;
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc == null || typeof acc !== "object") return undefined;
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
if (key === "constructor" || key === "prototype" || key === "__proto__") return undefined;
|
||||
return Object.hasOwn(acc, key) ? (acc as Record<string, unknown>)[key] : undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
@@ -84,145 +74,165 @@ function formatDisplayValue(value: unknown): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function useSubFetchExecutor() {
|
||||
const definitionId = useWidgetDefinitionId();
|
||||
const mutation = clientApi.widget.customApi.subFetch.useMutation();
|
||||
|
||||
const execute = useCallback(
|
||||
async (params: { url: string; method?: string; body?: string; headers?: string }): Promise<SubFetchResult> => {
|
||||
if (!definitionId) {
|
||||
return { ok: false as const, status: 0, data: null, error: "Widget definition not available" };
|
||||
}
|
||||
if (!checkRateLimit(definitionId)) {
|
||||
return { ok: false as const, status: 0, data: null, error: "Rate limit exceeded (10 calls/minute)" };
|
||||
}
|
||||
|
||||
return mutation.mutateAsync({
|
||||
definitionId,
|
||||
url: params.url,
|
||||
method: parseMethod(params.method),
|
||||
body: params.body,
|
||||
headers: parseJsonRecord(params.headers),
|
||||
});
|
||||
},
|
||||
[definitionId, mutation],
|
||||
function MigrationRequiredAlert() {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
return (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{t("migrationRequired")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return { execute, isPending: mutation.isPending };
|
||||
}
|
||||
|
||||
export interface SubFetchProps {
|
||||
url: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: string;
|
||||
trigger?: "auto" | "manual";
|
||||
label?: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
refreshInterval?: number;
|
||||
children?: ReactNode;
|
||||
loadingText?: string;
|
||||
errorText?: string;
|
||||
display?: "json" | "text";
|
||||
render?: (data: unknown, metadata: SubFetchMetadata) => ReactNode;
|
||||
loadingLabel?: string;
|
||||
errorMessage?: string;
|
||||
fallback?: ReactNode;
|
||||
path?: string;
|
||||
as?: "json" | "text";
|
||||
/** @deprecated Inline targets are inert in JSX API v2. */
|
||||
url?: string;
|
||||
/** @deprecated The method is defined by the named request manifest. */
|
||||
method?: string;
|
||||
/** @deprecated The body is defined by the named request manifest. */
|
||||
body?: string;
|
||||
/** @deprecated Headers are defined by the named request manifest. */
|
||||
headers?: string;
|
||||
/** Set to manual to wait for an explicit user gesture before the first query. */
|
||||
trigger?: "auto" | "manual";
|
||||
}
|
||||
|
||||
export function SubFetch({
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
trigger = "auto",
|
||||
label = "Fetch",
|
||||
color = "blue",
|
||||
variant = "filled",
|
||||
requestId,
|
||||
params,
|
||||
refreshInterval,
|
||||
children,
|
||||
loadingText = "Loading...",
|
||||
errorText,
|
||||
display,
|
||||
render,
|
||||
loadingLabel,
|
||||
errorMessage,
|
||||
fallback,
|
||||
path,
|
||||
as,
|
||||
trigger = "auto",
|
||||
}: SubFetchProps) {
|
||||
const { execute, isPending } = useSubFetchExecutor();
|
||||
const [subData, setSubData] = useState<unknown>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { itemId, previewSessionId } = useCustomJsxRuntime();
|
||||
const [manualRun, setManualRun] = useState(false);
|
||||
const normalizedParams = useMemo(() => normalizeParams(params), [params]);
|
||||
const requestKey = useMemo(() => JSON.stringify(normalizedParams), [normalizedParams]);
|
||||
useEffect(() => setManualRun(false), [requestId, requestKey, trigger]);
|
||||
const canRun = Boolean((itemId || previewSessionId) && requestId && normalizedParams);
|
||||
const boardQuery = clientApi.widget.customApi.queryRequest.useQuery(
|
||||
{
|
||||
itemId: itemId ?? "",
|
||||
requestId: requestId ?? "",
|
||||
params: normalizedParams ?? {},
|
||||
},
|
||||
{
|
||||
enabled: Boolean(itemId) && canRun && (trigger === "auto" || manualRun),
|
||||
retry: 2,
|
||||
refetchInterval:
|
||||
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
|
||||
? Math.max(1_000, refreshInterval * 1_000)
|
||||
: false,
|
||||
},
|
||||
);
|
||||
const previewQuery = clientApi.customWidget.previewQuery.useQuery(
|
||||
{
|
||||
sessionId: previewSessionId ?? "",
|
||||
requestId: requestId ?? "",
|
||||
params: normalizedParams ?? {},
|
||||
},
|
||||
{
|
||||
enabled: Boolean(previewSessionId && !itemId) && canRun && (trigger === "auto" || manualRun),
|
||||
retry: 2,
|
||||
refetchInterval:
|
||||
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
|
||||
? Math.max(1_000, refreshInterval * 1_000)
|
||||
: false,
|
||||
},
|
||||
);
|
||||
const query = itemId ? boardQuery : previewQuery;
|
||||
|
||||
const runFetch = useCallback(async () => {
|
||||
setError(null);
|
||||
const result = await execute({ url, method, body, headers });
|
||||
if (result.ok) {
|
||||
setSubData(result.data);
|
||||
setFetched(true);
|
||||
} else {
|
||||
setError(result.error ?? errorText ?? `Request failed (${result.status})`);
|
||||
setFetched(true);
|
||||
}
|
||||
}, [execute, url, method, body, headers, errorText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger === "auto") {
|
||||
void runFetch();
|
||||
}
|
||||
}, [trigger, runFetch]);
|
||||
|
||||
if (trigger === "manual" && !fetched && !isPending) {
|
||||
if (!requestId) return <MigrationRequiredAlert />;
|
||||
if (!itemId && !previewSessionId) {
|
||||
return (
|
||||
<Button color={color} variant={variant as never} onClick={() => void runFetch()} loading={isPending}>
|
||||
{label}
|
||||
fallback ?? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("unsavedPreview")}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!normalizedParams) {
|
||||
return (
|
||||
<Alert color="red" variant="light" p="xs">
|
||||
<Text size="xs">{t("invalidParams")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (trigger === "manual" && !manualRun) {
|
||||
return (
|
||||
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
|
||||
{t("loadRequest")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
const result = query.data as RequestResult | undefined;
|
||||
const requestError =
|
||||
(query.error ? t("requestFailed") : null) ??
|
||||
(!result?.ok ? (result?.error ?? `${t("requestFailed")} (${result?.status ?? 0})`) : null);
|
||||
|
||||
if (!result && query.isPending) {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton height={8} radius="xl" mb="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{loadingText}
|
||||
</Text>
|
||||
</div>
|
||||
fallback ?? (
|
||||
<div>
|
||||
<Skeleton height={8} radius="xl" mb="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{loadingLabel ?? t("loading")}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (requestError) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{error}</Text>
|
||||
<Text size="xs">{errorMessage ?? requestError}</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
mt={4}
|
||||
onClick={() => void query.refetch()}
|
||||
loading={query.isFetching}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const contextValue = subData;
|
||||
|
||||
if (display === "json") {
|
||||
return (
|
||||
<SubFetchDataContext.Provider value={contextValue}>
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(subData, null, 2)}
|
||||
</Code>
|
||||
</SubFetchDataContext.Provider>
|
||||
const subData = result?.data;
|
||||
let content = render
|
||||
? render(subData, { ok: result?.ok ?? false, status: result?.status ?? 0, statusText: result?.statusText })
|
||||
: children;
|
||||
if (!render && (as === "json" || (!children && as !== "text"))) {
|
||||
content = (
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(path ? getByPath(subData, path) : subData, null, 2)}
|
||||
</Code>
|
||||
);
|
||||
} else if (!render && as === "text") {
|
||||
content = <Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>;
|
||||
}
|
||||
|
||||
if (display === "text" && path) {
|
||||
return (
|
||||
<SubFetchDataContext.Provider value={contextValue}>
|
||||
<Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>
|
||||
</SubFetchDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!children) {
|
||||
return (
|
||||
<SubFetchDataContext.Provider value={contextValue}>
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(subData, null, 2)}
|
||||
</Code>
|
||||
</SubFetchDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return <SubFetchDataContext.Provider value={contextValue}>{children}</SubFetchDataContext.Provider>;
|
||||
return <SubFetchDataContext.Provider value={subData}>{content}</SubFetchDataContext.Provider>;
|
||||
}
|
||||
|
||||
export interface SubDataProps {
|
||||
@@ -234,6 +244,11 @@ export interface SubDataProps {
|
||||
variant?: string;
|
||||
fw?: number;
|
||||
c?: string;
|
||||
alt?: string;
|
||||
fit?: string;
|
||||
w?: string | number;
|
||||
h?: string | number;
|
||||
radius?: string | number;
|
||||
}
|
||||
|
||||
export function SubData({ path, as = "Text", ...props }: SubDataProps) {
|
||||
@@ -266,8 +281,14 @@ export function SubData({ path, as = "Text", ...props }: SubDataProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (as === "Code") {
|
||||
return <Code block>{displayValue}</Code>;
|
||||
if (as === "Code") return <Code block>{displayValue}</Code>;
|
||||
|
||||
if (as === "Image") {
|
||||
const src = safeDisplayImageUrl(value);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<Image src={src} alt={props.alt ?? ""} fit={props.fit as never} w={props.w} h={props.h} radius={props.radius} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -277,64 +298,158 @@ export function SubData({ path, as = "Text", ...props }: SubDataProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function safeDisplayImageUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return value;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function useActionExecutor() {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const runtime = useCustomJsxRuntime();
|
||||
const boardMutation = clientApi.widget.customApi.executeAction.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewAction.useMutation();
|
||||
|
||||
const execute = useCallback(
|
||||
async (requestId: string, params: CustomJsxRuntimeParams, confirmed: boolean): Promise<RequestResult> => {
|
||||
if (!runtime.itemId && !runtime.previewSessionId) {
|
||||
return { ok: false, status: 0, data: null, error: t("widgetItemUnavailable") };
|
||||
}
|
||||
if (runtime.isEditMode) {
|
||||
return { ok: false, status: 0, data: null, error: t("actionsDisabledEditMode") };
|
||||
}
|
||||
if (runtime.itemId) {
|
||||
return boardMutation.mutateAsync({ itemId: runtime.itemId, requestId, params, confirmed });
|
||||
}
|
||||
return previewMutation.mutateAsync({
|
||||
sessionId: runtime.previewSessionId ?? "",
|
||||
requestId,
|
||||
params,
|
||||
confirmed,
|
||||
});
|
||||
},
|
||||
[boardMutation, previewMutation, runtime.isEditMode, runtime.itemId, runtime.previewSessionId, t],
|
||||
);
|
||||
|
||||
return {
|
||||
execute,
|
||||
isPending: boardMutation.isPending || previewMutation.isPending,
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
canExecute: Boolean(runtime.itemId || runtime.previewSessionId),
|
||||
isEditMode: runtime.isEditMode,
|
||||
requestCapabilities: runtime.requestCapabilities ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function invalidateAfterAction(
|
||||
utils: ReturnType<typeof clientApi.useUtils>,
|
||||
itemId: string | undefined,
|
||||
previewSessionId: string | undefined,
|
||||
invalidate: readonly string[] | undefined,
|
||||
) {
|
||||
if (!invalidate?.length) return;
|
||||
if (previewSessionId) {
|
||||
await utils.customWidget.previewQuery.invalidate();
|
||||
return;
|
||||
}
|
||||
if (!itemId) return;
|
||||
if (invalidate.includes("parent")) await utils.widget.customApi.getData.invalidate({ itemId });
|
||||
if (invalidate.some((entry) => entry !== "parent")) await utils.widget.customApi.queryRequest.invalidate();
|
||||
}
|
||||
|
||||
export interface ActionButtonProps {
|
||||
url: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: string;
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
label: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
confirmMessage?: string;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
icon?: string;
|
||||
fullWidth?: boolean | string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
/** @deprecated Inline targets are inert in JSX API v2. */
|
||||
url?: string;
|
||||
/** @deprecated The method is defined by the named request manifest. */
|
||||
method?: string;
|
||||
/** @deprecated The body is defined by the named request manifest. */
|
||||
body?: string;
|
||||
/** @deprecated Headers are defined by the named request manifest. */
|
||||
headers?: string;
|
||||
/** @deprecated Use normal Mantine sizing props. */
|
||||
fullWidth?: boolean | string;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
url,
|
||||
method = "POST",
|
||||
body,
|
||||
headers,
|
||||
requestId,
|
||||
params,
|
||||
label,
|
||||
color = "blue",
|
||||
variant = "filled",
|
||||
size = "sm",
|
||||
confirmMessage,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
icon,
|
||||
fullWidth,
|
||||
invalidate,
|
||||
disabled,
|
||||
}: ActionButtonProps) {
|
||||
const { execute, isPending } = useSubFetchExecutor();
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode, requestCapabilities } =
|
||||
useActionExecutor();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const Icon = icon ? ICON_MAP[icon.toLowerCase()] : undefined;
|
||||
const normalizedParams = normalizeParams(params);
|
||||
const requiresConfirmation = requestCapabilities.some(
|
||||
(capability) => capability.id === requestId && capability.kind === "action" && capability.method === "DELETE",
|
||||
);
|
||||
|
||||
const runAction = async () => {
|
||||
const result = await execute({ url, method, body, headers });
|
||||
if (result.ok) {
|
||||
if (successMessage) {
|
||||
showSuccessNotification({ title: label, message: successMessage });
|
||||
const runAction = async (confirmed: boolean) => {
|
||||
if (!requestId || !normalizedParams) return;
|
||||
try {
|
||||
const result = await execute(requestId, normalizedParams, confirmed);
|
||||
if (result.ok) {
|
||||
showSuccessNotification({
|
||||
title: label,
|
||||
message: successMessage ?? (result.simulated ? t("actionSimulated") : t("actionCompleted")),
|
||||
});
|
||||
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
|
||||
} else {
|
||||
showErrorNotification({
|
||||
title: label,
|
||||
message: errorMessage ?? result.error ?? `${t("requestFailed")} (${result.status})`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showErrorNotification({ title: label, message: result.error ?? `Request failed (${result.status})` });
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: label,
|
||||
message: errorMessage ?? t("requestFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (confirmMessage) {
|
||||
if (confirmMessage || requiresConfirmation) {
|
||||
openConfirmModal({
|
||||
title: label,
|
||||
children: confirmMessage,
|
||||
onConfirm: () => void runAction(),
|
||||
children: confirmMessage ?? t("confirmDelete"),
|
||||
onConfirm: () => void runAction(true),
|
||||
});
|
||||
} else {
|
||||
void runAction();
|
||||
void runAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!requestId) return <MigrationRequiredAlert />;
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
@@ -343,8 +458,7 @@ export function ActionButton({
|
||||
onClick={handleClick}
|
||||
loading={isPending}
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
fullWidth={parseBool(fullWidth)}
|
||||
disabled={parseBool(disabled)}
|
||||
disabled={parseBool(disabled) || isEditMode || !canExecute || !normalizedParams}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
@@ -352,51 +466,81 @@ export function ActionButton({
|
||||
}
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
url: string;
|
||||
method?: string;
|
||||
onBody?: string;
|
||||
offBody?: string;
|
||||
requestId?: string;
|
||||
onParams?: CustomJsxRuntimeParams;
|
||||
offParams?: CustomJsxRuntimeParams;
|
||||
initialValue?: boolean | string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
errorMessage?: string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
/** @deprecated Inline targets are inert in JSX API v2. */
|
||||
url?: string;
|
||||
/** @deprecated The method is defined by the named request manifest. */
|
||||
method?: string;
|
||||
/** @deprecated Use onParams. */
|
||||
onBody?: string;
|
||||
/** @deprecated Use offParams. */
|
||||
offBody?: string;
|
||||
}
|
||||
|
||||
export function ToggleSwitch({
|
||||
url,
|
||||
method = "POST",
|
||||
onBody,
|
||||
offBody,
|
||||
requestId,
|
||||
onParams,
|
||||
offParams,
|
||||
initialValue = false,
|
||||
label,
|
||||
color = "blue",
|
||||
size = "sm",
|
||||
errorMessage,
|
||||
invalidate,
|
||||
disabled,
|
||||
}: ToggleSwitchProps) {
|
||||
const { execute, isPending } = useSubFetchExecutor();
|
||||
const [checked, setChecked] = useState(parseBool(initialValue));
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode } = useActionExecutor();
|
||||
const utils = clientApi.useUtils();
|
||||
const initial = parseBool(initialValue);
|
||||
const [checked, setChecked] = useState(initial);
|
||||
const pendingRef = useRef(false);
|
||||
const normalizedOnParams = normalizeParams(onParams);
|
||||
const normalizedOffParams = normalizeParams(offParams);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingRef.current) setChecked(initial);
|
||||
}, [initial]);
|
||||
|
||||
const handleChange = async (next: boolean) => {
|
||||
if (pendingRef.current) return;
|
||||
if (!requestId || pendingRef.current) return;
|
||||
const requestParams = next ? normalizedOnParams : normalizedOffParams;
|
||||
if (!requestParams) return;
|
||||
pendingRef.current = true;
|
||||
const prev = checked;
|
||||
const previous = checked;
|
||||
setChecked(next);
|
||||
|
||||
const result = await execute({
|
||||
url,
|
||||
method,
|
||||
body: next ? onBody : offBody,
|
||||
});
|
||||
|
||||
pendingRef.current = false;
|
||||
if (!result.ok) {
|
||||
setChecked(prev);
|
||||
showErrorNotification({ title: label ?? "Toggle", message: result.error ?? "Request failed" });
|
||||
try {
|
||||
const result = await execute(requestId, requestParams, false);
|
||||
if (!result.ok) {
|
||||
setChecked(previous);
|
||||
showErrorNotification({
|
||||
title: label ?? t("toggle"),
|
||||
message: errorMessage ?? result.error ?? t("requestFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
|
||||
} catch {
|
||||
setChecked(previous);
|
||||
showErrorNotification({
|
||||
title: label ?? t("toggle"),
|
||||
message: errorMessage ?? t("requestFailed"),
|
||||
});
|
||||
} finally {
|
||||
pendingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!requestId) return <MigrationRequiredAlert />;
|
||||
return (
|
||||
<Switch
|
||||
label={label}
|
||||
@@ -404,7 +548,9 @@ export function ToggleSwitch({
|
||||
size={size as never}
|
||||
checked={checked}
|
||||
onChange={(event) => void handleChange(event.currentTarget.checked)}
|
||||
disabled={parseBool(disabled) || isPending}
|
||||
disabled={
|
||||
parseBool(disabled) || isPending || isEditMode || !canExecute || !normalizedOnParams || !normalizedOffParams
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -416,21 +562,22 @@ export interface RefreshButtonProps {
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function RefreshButton({
|
||||
label = "Refresh",
|
||||
color = "blue",
|
||||
variant = "light",
|
||||
size = "sm",
|
||||
}: RefreshButtonProps) {
|
||||
const definitionId = useWidgetDefinitionId();
|
||||
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { itemId, previewSessionId, isEditMode } = useCustomJsxRuntime();
|
||||
const utils = clientApi.useUtils();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!definitionId) return;
|
||||
if (!itemId && !previewSessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await utils.widget.customApi.getData.invalidate({ definitionId });
|
||||
if (previewSessionId) await utils.customWidget.previewQuery.invalidate();
|
||||
else
|
||||
await Promise.all([
|
||||
utils.widget.customApi.getData.invalidate({ itemId: itemId ?? "" }),
|
||||
utils.widget.customApi.queryRequest.invalidate(),
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -444,8 +591,9 @@ export function RefreshButton({
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void handleRefresh()}
|
||||
loading={loading}
|
||||
disabled={(!itemId && !previewSessionId) || isEditMode}
|
||||
>
|
||||
{label}
|
||||
{label ?? t("refresh")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement, useState } from "react";
|
||||
import { createElement, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
Collapse,
|
||||
ColorSwatch,
|
||||
Container,
|
||||
CopyButton,
|
||||
DataList,
|
||||
Divider,
|
||||
EmptyState,
|
||||
Fieldset,
|
||||
Flex,
|
||||
FloatingIndicator,
|
||||
Grid,
|
||||
Group,
|
||||
Highlight,
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Kbd,
|
||||
List,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Mark,
|
||||
Marquee,
|
||||
Menu,
|
||||
@@ -71,12 +72,16 @@ import {
|
||||
Tooltip,
|
||||
Transition,
|
||||
Tree,
|
||||
Typography,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AreaChart,
|
||||
BarChart,
|
||||
BarsList,
|
||||
BubbleChart,
|
||||
ChartLegend,
|
||||
ChartTooltip,
|
||||
CompositeChart,
|
||||
DonutChart,
|
||||
FunnelChart,
|
||||
@@ -90,10 +95,139 @@ import {
|
||||
Sparkline,
|
||||
Treemap,
|
||||
} from "@mantine/charts";
|
||||
import { Calendar, DatePicker, MiniCalendar, TimeValue } from "@mantine/dates";
|
||||
import {
|
||||
IconActivity,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconApi,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBell,
|
||||
IconBluetooth,
|
||||
IconBolt,
|
||||
IconBookmark,
|
||||
IconBug,
|
||||
IconCalendar,
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClipboard,
|
||||
IconClock,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconCode,
|
||||
IconCompass,
|
||||
IconCopy,
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconDownload,
|
||||
IconDroplet,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFile,
|
||||
IconFlag,
|
||||
IconFlame,
|
||||
IconFolder,
|
||||
IconGauge,
|
||||
IconGitBranch,
|
||||
IconHeart,
|
||||
IconHistory,
|
||||
IconHome,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconLink,
|
||||
IconLoader,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconMail,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconMinus,
|
||||
IconMoon,
|
||||
IconMusic,
|
||||
IconNetwork,
|
||||
IconPhoto,
|
||||
IconPin,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconPlayerSkipBack,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconPower,
|
||||
IconProgress,
|
||||
IconHeartbeat,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconStar,
|
||||
IconSun,
|
||||
IconTerminal,
|
||||
IconThermometer,
|
||||
IconTrash,
|
||||
IconTrendingDown,
|
||||
IconTrendingUp,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconVideo,
|
||||
IconVolume,
|
||||
IconVolume2,
|
||||
IconVolumeOff,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconWorld,
|
||||
IconX,
|
||||
IconSpeedboat,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Calendar,
|
||||
CalendarHeader,
|
||||
DatePicker,
|
||||
Day,
|
||||
DecadeLevel,
|
||||
DecadeLevelGroup,
|
||||
LevelsGroup,
|
||||
MiniCalendar,
|
||||
Month,
|
||||
MonthLevel,
|
||||
MonthLevelGroup,
|
||||
MonthPicker,
|
||||
MonthsList,
|
||||
PickerControl,
|
||||
TimeGrid,
|
||||
TimeValue,
|
||||
WeekdaysRow,
|
||||
YearLevel,
|
||||
YearLevelGroup,
|
||||
YearPicker,
|
||||
YearsList,
|
||||
} from "@mantine/dates";
|
||||
|
||||
import { enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
|
||||
import { SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton } from "./jsx-sub-fetch";
|
||||
import { createSafeCallable } from "./safe-bindings";
|
||||
import { sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
|
||||
|
||||
const SAFE_URL_PATTERN = /^https?:\/\//i;
|
||||
|
||||
@@ -102,110 +236,257 @@ function isSafeUrl(url: unknown): boolean {
|
||||
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
|
||||
}
|
||||
|
||||
function stripEventHandlers(props: Record<string, unknown>): Record<string, unknown> {
|
||||
const safe: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (/^on[A-Z]/.test(key)) continue;
|
||||
safe[key] = value;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function SafeAnchor(props: Record<string, unknown>) {
|
||||
const href = props.href;
|
||||
const safeHref = href && isSafeUrl(href) ? href : undefined;
|
||||
return createElement(Anchor as never, { ...stripEventHandlers(props), href: safeHref });
|
||||
return createElement(Anchor as never, { ...sanitizeCustomJsxProps(props), href: safeHref });
|
||||
}
|
||||
|
||||
function SafeNavLink(props: Record<string, unknown>) {
|
||||
const href = props.href;
|
||||
const safeHref = href && isSafeUrl(href) ? href : undefined;
|
||||
return createElement(NavLink as never, { ...stripEventHandlers(props), href: safeHref });
|
||||
return createElement(NavLink as never, { ...sanitizeCustomJsxProps(props), href: safeHref });
|
||||
}
|
||||
|
||||
function SafeButton(props: Record<string, unknown>) {
|
||||
return createElement(Button as never, stripEventHandlers(props));
|
||||
return createElement(Button as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeActionIcon(props: Record<string, unknown>) {
|
||||
return createElement(ActionIcon as never, stripEventHandlers(props));
|
||||
return createElement(ActionIcon as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeBurger(props: Record<string, unknown>) {
|
||||
return createElement(Burger as never, stripEventHandlers(props));
|
||||
return createElement(Burger as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeCloseButton(props: Record<string, unknown>) {
|
||||
return createElement(CloseButton as never, stripEventHandlers(props));
|
||||
return createElement(CloseButton as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeChip(props: Record<string, unknown>) {
|
||||
return createElement(Chip as never, stripEventHandlers(props));
|
||||
return createElement(Chip as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeNotification(props: Record<string, unknown>) {
|
||||
return createElement(Notification as never, stripEventHandlers(props));
|
||||
return createElement(Notification as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeRating(props: Record<string, unknown>) {
|
||||
return createElement(Rating as never, { ...stripEventHandlers(props), readOnly: true });
|
||||
return createElement(Rating as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSlider(props: Record<string, unknown>) {
|
||||
return createElement(Slider as never, { ...stripEventHandlers(props), readOnly: true });
|
||||
return createElement(Slider as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSwitch(props: Record<string, unknown>) {
|
||||
return createElement(Switch as never, { ...stripEventHandlers(props), readOnly: true });
|
||||
return createElement(Switch as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSegmentedControl(props: Record<string, unknown>) {
|
||||
return createElement(SegmentedControl as never, stripEventHandlers(props));
|
||||
return createElement(SegmentedControl as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafePagination(props: Record<string, unknown>) {
|
||||
return createElement(Pagination as never, stripEventHandlers(props));
|
||||
return createElement(Pagination as never, { ...sanitizeCustomJsxProps(props), disabled: true });
|
||||
}
|
||||
|
||||
function SafeStepper(props: Record<string, unknown>) {
|
||||
return createElement(Stepper as never, stripEventHandlers(props));
|
||||
return createElement(Stepper as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeCalendar(props: Record<string, unknown>) {
|
||||
return createElement(Calendar as never, { ...stripEventHandlers(props), static: true });
|
||||
return createElement(Calendar as never, { ...sanitizeCustomJsxProps(props), static: true });
|
||||
}
|
||||
|
||||
function SafeMiniCalendar(props: Record<string, unknown>) {
|
||||
return createElement(MiniCalendar as never, { ...stripEventHandlers(props), static: true });
|
||||
return createElement(MiniCalendar as never, { ...sanitizeCustomJsxProps(props), static: true });
|
||||
}
|
||||
|
||||
function SafeTree(props: Record<string, unknown>) {
|
||||
return createElement(Tree as never, stripEventHandlers(props));
|
||||
return createElement(Tree as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeDatePicker(props: Record<string, unknown>) {
|
||||
return createElement(DatePicker as never, { ...stripEventHandlers(props), static: true });
|
||||
return createElement(DatePicker as never, { ...sanitizeCustomJsxProps(props), static: true });
|
||||
}
|
||||
|
||||
function createSafeDateComponent(component: ComponentType<never>) {
|
||||
return function SafeDateComponent(props: Record<string, unknown>) {
|
||||
return createElement(component, { ...sanitizeCustomJsxProps(props), static: true } as never);
|
||||
};
|
||||
}
|
||||
|
||||
function tablerIconExportToKey(exportName: string): string {
|
||||
return exportName
|
||||
.slice(4)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.replace(/([A-Za-z])(\d)/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const TABLER_ICON_IMPORTS = {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconPlus,
|
||||
IconMinus,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconHome,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconSearch,
|
||||
IconBell,
|
||||
IconMail,
|
||||
IconHeart,
|
||||
IconStar,
|
||||
IconBookmark,
|
||||
IconFlag,
|
||||
IconPin,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconPlayerStop,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerSkipBack,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconVolume2,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconBluetooth,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconServer,
|
||||
IconDatabase,
|
||||
IconCpu,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconFolder,
|
||||
IconFile,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconPower,
|
||||
IconBolt,
|
||||
IconFlame,
|
||||
IconDroplet,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconShield,
|
||||
IconKey,
|
||||
IconAlertTriangle,
|
||||
IconAlertCircle,
|
||||
IconInfoCircle,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconTrash,
|
||||
IconEdit,
|
||||
IconCopy,
|
||||
IconClipboard,
|
||||
IconExternalLink,
|
||||
IconLink,
|
||||
IconPhoto,
|
||||
IconCamera,
|
||||
IconMusic,
|
||||
IconVideo,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconHistory,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconCompass,
|
||||
IconTerminal,
|
||||
IconCode,
|
||||
IconBug,
|
||||
IconGitBranch,
|
||||
IconNetwork,
|
||||
IconWorld,
|
||||
IconApi,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconTrendingUp,
|
||||
IconTrendingDown,
|
||||
IconActivity,
|
||||
IconProgress,
|
||||
IconLoader,
|
||||
IconThermometer,
|
||||
IconGauge,
|
||||
IconSpeedboat,
|
||||
IconHeartbeat,
|
||||
} as const;
|
||||
|
||||
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
|
||||
tablerIconExportToKey(exportName),
|
||||
IconComponent,
|
||||
]),
|
||||
),
|
||||
pulse: IconHeartbeat,
|
||||
};
|
||||
|
||||
function isValidIconProp(value: unknown): value is string | number {
|
||||
return typeof value === "number" || typeof value === "string";
|
||||
}
|
||||
|
||||
function SafeTablerIcon(props: Record<string, unknown>) {
|
||||
const name = props.name;
|
||||
if (typeof name !== "string") return null;
|
||||
const IconComponent = TABLER_ICON_MAP[name];
|
||||
if (!IconComponent) return null;
|
||||
const iconProps: Record<string, unknown> = {};
|
||||
if (isValidIconProp(props.size)) iconProps.size = props.size;
|
||||
if (typeof props.color === "string") iconProps.color = props.color;
|
||||
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
|
||||
return createElement(IconComponent as never, iconProps);
|
||||
}
|
||||
|
||||
function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = () => {
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(resetTimer.current), []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) return;
|
||||
void navigator.clipboard.writeText(value).then(() => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
return createElement(Button as never, {
|
||||
size: "xs",
|
||||
variant: copied ? "filled" : "light",
|
||||
color: copied ? "teal" : "blue",
|
||||
onClick: handleCopy,
|
||||
children: copied ? "Copied!" : "Copy",
|
||||
});
|
||||
const COPY_STATES = {
|
||||
idle: { variant: "light", color: "blue", label: t("copy") },
|
||||
copied: { variant: "filled", color: "teal", label: t("copied") },
|
||||
} as const;
|
||||
const state = COPY_STATES[copied ? "copied" : "idle"];
|
||||
return createElement(
|
||||
Button as never,
|
||||
{ size: "xs", variant: state.variant, color: state.color, onClick: () => void handleCopy() },
|
||||
state.label,
|
||||
);
|
||||
}
|
||||
|
||||
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
const RUNTIME_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
// Layout
|
||||
Box,
|
||||
Stack,
|
||||
@@ -220,6 +501,9 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
AspectRatio,
|
||||
Overlay,
|
||||
ScrollArea,
|
||||
VisuallyHidden,
|
||||
Typography,
|
||||
FloatingIndicator,
|
||||
|
||||
// Typography & text
|
||||
Text,
|
||||
@@ -284,6 +568,7 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
"EmptyState.Actions": EmptyState.Actions,
|
||||
Fieldset,
|
||||
Notification: SafeNotification,
|
||||
LoadingOverlay,
|
||||
Rating: SafeRating,
|
||||
|
||||
// Navigation & structure
|
||||
@@ -342,11 +627,30 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
SankeyChart,
|
||||
Treemap,
|
||||
BarsList,
|
||||
ChartLegend,
|
||||
ChartTooltip,
|
||||
|
||||
// Dates
|
||||
Calendar: SafeCalendar,
|
||||
MiniCalendar: SafeMiniCalendar,
|
||||
DatePicker: SafeDatePicker,
|
||||
Day: createSafeDateComponent(Day),
|
||||
WeekdaysRow: createSafeDateComponent(WeekdaysRow),
|
||||
Month: createSafeDateComponent(Month),
|
||||
CalendarHeader: createSafeDateComponent(CalendarHeader),
|
||||
YearPicker: createSafeDateComponent(YearPicker),
|
||||
MonthPicker: createSafeDateComponent(MonthPicker),
|
||||
TimeGrid: createSafeDateComponent(TimeGrid),
|
||||
PickerControl: createSafeDateComponent(PickerControl),
|
||||
YearsList: createSafeDateComponent(YearsList),
|
||||
MonthsList: createSafeDateComponent(MonthsList),
|
||||
DecadeLevel: createSafeDateComponent(DecadeLevel),
|
||||
YearLevel: createSafeDateComponent(YearLevel),
|
||||
MonthLevel: createSafeDateComponent(MonthLevel),
|
||||
LevelsGroup: createSafeDateComponent(LevelsGroup),
|
||||
DecadeLevelGroup: createSafeDateComponent(DecadeLevelGroup),
|
||||
YearLevelGroup: createSafeDateComponent(YearLevelGroup),
|
||||
MonthLevelGroup: createSafeDateComponent(MonthLevelGroup),
|
||||
TimeValue,
|
||||
|
||||
// Custom interactive components (own state management)
|
||||
@@ -356,6 +660,7 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
Collapsible,
|
||||
StatBar,
|
||||
TypeBadge,
|
||||
TablerIcon: SafeTablerIcon,
|
||||
|
||||
// SubFetch — server-proxied HTTP from within templates
|
||||
SubFetch,
|
||||
@@ -365,6 +670,12 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
RefreshButton,
|
||||
};
|
||||
|
||||
const enabledComponentNames = new Set(enabledCustomJsxComponents.map(({ name }) => name));
|
||||
|
||||
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = Object.fromEntries(
|
||||
Object.entries(RUNTIME_COMPONENTS).filter(([name]) => enabledComponentNames.has(name)),
|
||||
);
|
||||
|
||||
function sanitizeData(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj !== "object") return obj;
|
||||
@@ -379,60 +690,63 @@ function sanitizeData(obj: unknown): unknown {
|
||||
}
|
||||
|
||||
const safeMath: Record<string, unknown> = Object.create(null);
|
||||
safeMath.round = (v: number) => Math.round(v);
|
||||
safeMath.floor = (v: number) => Math.floor(v);
|
||||
safeMath.ceil = (v: number) => Math.ceil(v);
|
||||
safeMath.abs = (v: number) => Math.abs(v);
|
||||
safeMath.min = (...args: number[]) => Math.min(...args);
|
||||
safeMath.max = (...args: number[]) => Math.max(...args);
|
||||
safeMath.pow = (b: number, e: number) => Math.pow(b, e);
|
||||
safeMath.sqrt = (v: number) => Math.sqrt(v);
|
||||
safeMath.round = createSafeCallable((v: number) => Math.round(v));
|
||||
safeMath.floor = createSafeCallable((v: number) => Math.floor(v));
|
||||
safeMath.ceil = createSafeCallable((v: number) => Math.ceil(v));
|
||||
safeMath.abs = createSafeCallable((v: number) => Math.abs(v));
|
||||
safeMath.min = createSafeCallable((...args: number[]) => Math.min(...args));
|
||||
safeMath.max = createSafeCallable((...args: number[]) => Math.max(...args));
|
||||
safeMath.pow = createSafeCallable((b: number, e: number) => Math.pow(b, e));
|
||||
safeMath.sqrt = createSafeCallable((v: number) => Math.sqrt(v));
|
||||
safeMath.PI = Math.PI;
|
||||
Object.freeze(safeMath);
|
||||
|
||||
const safeJSON: Record<string, unknown> = Object.create(null);
|
||||
safeJSON.stringify = (v: unknown) => JSON.stringify(v);
|
||||
safeJSON.stringify = createSafeCallable((v: unknown) => JSON.stringify(v));
|
||||
Object.freeze(safeJSON);
|
||||
|
||||
const safeArray: Record<string, unknown> = Object.create(null);
|
||||
safeArray.isArray = (v: unknown) => Array.isArray(v);
|
||||
safeArray.from = <T>(v: ArrayLike<T> | Iterable<T>) => Array.from(v);
|
||||
safeArray.isArray = createSafeCallable((v: unknown) => Array.isArray(v));
|
||||
safeArray.from = createSafeCallable((v: ArrayLike<unknown> | Iterable<unknown>) => Array.from(v).slice(0, 2_000));
|
||||
Object.freeze(safeArray);
|
||||
|
||||
const safeObject: Record<string, unknown> = Object.create(null);
|
||||
safeObject.keys = (v: object) => Object.keys(v);
|
||||
safeObject.values = (v: object) => Object.values(v);
|
||||
safeObject.entries = (v: object) => Object.entries(v);
|
||||
safeObject.keys = createSafeCallable((v: object) => Object.keys(v));
|
||||
safeObject.values = createSafeCallable((v: object) => Object.values(v));
|
||||
safeObject.entries = createSafeCallable((v: object) => Object.entries(v));
|
||||
Object.freeze(safeObject);
|
||||
|
||||
const safeDate: Record<string, unknown> = Object.create(null);
|
||||
safeDate.now = () => Date.now();
|
||||
safeDate.create = (v?: string | number) => (v != null ? new Date(v) : new Date());
|
||||
safeDate.toISOString = (v: string | number) => new Date(v).toISOString();
|
||||
safeDate.toLocaleDateString = (v: string | number, locale?: string) =>
|
||||
new Date(v).toLocaleDateString(locale ?? "en-US");
|
||||
safeDate.toLocaleTimeString = (v: string | number, locale?: string) =>
|
||||
new Date(v).toLocaleTimeString(locale ?? "en-US");
|
||||
safeDate.getTime = (v: string | number) => new Date(v).getTime();
|
||||
safeDate.getYear = (v: string | number) => new Date(v).getFullYear();
|
||||
safeDate.getMonth = (v: string | number) => new Date(v).getMonth();
|
||||
safeDate.getDay = (v: string | number) => new Date(v).getDate();
|
||||
safeDate.now = createSafeCallable(() => Date.now());
|
||||
// Date helpers return primitives only; native Date instances never enter the interpreter.
|
||||
safeDate.create = createSafeCallable((v?: string | number) => (v != null ? new Date(v).getTime() : Date.now()));
|
||||
safeDate.toISOString = createSafeCallable((v: string | number) => new Date(v).toISOString());
|
||||
safeDate.toLocaleDateString = createSafeCallable((v: string | number, locale?: string) =>
|
||||
new Date(v).toLocaleDateString(locale ?? "en-US"),
|
||||
);
|
||||
safeDate.toLocaleTimeString = createSafeCallable((v: string | number, locale?: string) =>
|
||||
new Date(v).toLocaleTimeString(locale ?? "en-US"),
|
||||
);
|
||||
safeDate.getTime = createSafeCallable((v: string | number) => new Date(v).getTime());
|
||||
safeDate.getYear = createSafeCallable((v: string | number) => new Date(v).getFullYear());
|
||||
safeDate.getMonth = createSafeCallable((v: string | number) => new Date(v).getMonth());
|
||||
safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getDay());
|
||||
Object.freeze(safeDate);
|
||||
|
||||
export const SAFE_BINDINGS = (apiData: unknown) => ({
|
||||
data: sanitizeData(apiData),
|
||||
String: (v: unknown) => String(v),
|
||||
Number: (v: unknown) => Number(v),
|
||||
Boolean: (v: unknown) => Boolean(v),
|
||||
String: createSafeCallable((v: unknown) => String(v)),
|
||||
Number: createSafeCallable((v: unknown) => Number(v)),
|
||||
Boolean: createSafeCallable((v: unknown) => Boolean(v)),
|
||||
Math: safeMath,
|
||||
JSON: safeJSON,
|
||||
Array: safeArray,
|
||||
Object: safeObject,
|
||||
Date: safeDate,
|
||||
parseInt: (v: string, radix?: number) => parseInt(v, radix),
|
||||
parseFloat: (v: string) => parseFloat(v),
|
||||
encodeURIComponent: (v: string) => encodeURIComponent(v),
|
||||
decodeURIComponent: (v: string) => decodeURIComponent(v),
|
||||
isNaN: (v: unknown) => Number.isNaN(Number(v)),
|
||||
isFinite: (v: unknown) => Number.isFinite(Number(v)),
|
||||
parseInt: createSafeCallable((v: string, radix?: number) => parseInt(v, radix)),
|
||||
parseFloat: createSafeCallable((v: string) => parseFloat(v)),
|
||||
encodeURIComponent: createSafeCallable((v: string) => encodeURIComponent(v)),
|
||||
decodeURIComponent: createSafeCallable((v: string) => decodeURIComponent(v)),
|
||||
isNaN: createSafeCallable((v: unknown) => Number.isNaN(Number(v))),
|
||||
isFinite: createSafeCallable((v: unknown) => Number.isFinite(Number(v))),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
const safeCallables = new WeakSet<(...args: never[]) => unknown>();
|
||||
|
||||
/**
|
||||
* Marks a host function as callable by the Custom JSX interpreter.
|
||||
*
|
||||
* The interpreter never calls an arbitrary function obtained from data or a
|
||||
* prototype. Host helpers must opt in through this module instead.
|
||||
*/
|
||||
export function createSafeCallable<T extends (...args: never[]) => unknown>(callable: T): T {
|
||||
safeCallables.add(callable);
|
||||
return callable;
|
||||
}
|
||||
|
||||
export function isSafeCallable(value: unknown): value is (...args: never[]) => unknown {
|
||||
return typeof value === "function" && safeCallables.has(value as (...args: never[]) => unknown);
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement, Fragment } from "react";
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import { customJsxSupportedPropsByName } from "@homarr/definitions";
|
||||
|
||||
import { isSafeCallable } from "./safe-bindings";
|
||||
|
||||
interface AstNode {
|
||||
type: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface InterpreterCallback {
|
||||
readonly kind: typeof INTERPRETER_CALLBACK;
|
||||
readonly params: readonly string[];
|
||||
readonly body: AstNode;
|
||||
readonly environment: Environment;
|
||||
}
|
||||
|
||||
interface EvaluationBudgets {
|
||||
maxAstDepth: number;
|
||||
maxOperations: number;
|
||||
maxCollectionItems: number;
|
||||
maxRenderedNodes: number;
|
||||
maxStringLength: number;
|
||||
}
|
||||
|
||||
export interface SafeJsxBudgets extends Partial<EvaluationBudgets> {}
|
||||
|
||||
export interface RenderSafeJsxOptions {
|
||||
template: string;
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
bindings: Readonly<Record<string, unknown>>;
|
||||
budgets?: SafeJsxBudgets;
|
||||
}
|
||||
|
||||
export interface SafeJsxRenderResult {
|
||||
node: ReactNode;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
const INTERPRETER_CALLBACK = Symbol("custom-jsx-interpreter-callback");
|
||||
const MAX_TEMPLATE_LENGTH = 50_000;
|
||||
|
||||
const DEFAULT_BUDGETS: EvaluationBudgets = {
|
||||
maxAstDepth: 64,
|
||||
maxOperations: 25_000,
|
||||
maxCollectionItems: 2_000,
|
||||
maxRenderedNodes: 10_000,
|
||||
maxStringLength: 200_000,
|
||||
};
|
||||
|
||||
const BLOCKED_PROPERTIES = new Set([
|
||||
"__proto__",
|
||||
"arguments",
|
||||
"bind",
|
||||
"call",
|
||||
"callee",
|
||||
"caller",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"apply",
|
||||
]);
|
||||
|
||||
const BLOCKED_TAGS = new Set(["base", "embed", "form", "iframe", "link", "meta", "object", "script", "style"]);
|
||||
|
||||
const BLOCKED_PROPS = new Set([
|
||||
"children",
|
||||
"className",
|
||||
"classNames",
|
||||
"classes",
|
||||
"component",
|
||||
"dangerouslySetInnerHTML",
|
||||
"innerRef",
|
||||
"portalProps",
|
||||
"ref",
|
||||
"renderRoot",
|
||||
"styles",
|
||||
"unstyled",
|
||||
"withinPortal",
|
||||
]);
|
||||
|
||||
const URL_PROPS = new Set(["backgroundImage", "href", "src"]);
|
||||
const BLOCKED_STYLE_KEYS = new Set([
|
||||
"backdropFilter",
|
||||
"behavior",
|
||||
"bottom",
|
||||
"clipPath",
|
||||
"content",
|
||||
"filter",
|
||||
"inset",
|
||||
"left",
|
||||
"mask",
|
||||
"pointerEvents",
|
||||
"position",
|
||||
"right",
|
||||
"top",
|
||||
"zIndex",
|
||||
]);
|
||||
|
||||
export class SafeJsxError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SafeJsxError";
|
||||
}
|
||||
}
|
||||
|
||||
class Environment {
|
||||
public constructor(
|
||||
private readonly values: Readonly<Record<string, unknown>>,
|
||||
private readonly parent?: Environment,
|
||||
) {}
|
||||
|
||||
public get(name: string): unknown {
|
||||
if (Object.hasOwn(this.values, name)) return this.values[name];
|
||||
if (this.parent) return this.parent.get(name);
|
||||
if (name === "undefined") return undefined;
|
||||
if (name === "NaN") return Number.NaN;
|
||||
if (name === "Infinity") return Number.POSITIVE_INFINITY;
|
||||
throw new SafeJsxError(`Unknown binding: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
class Budget {
|
||||
private operations = 0;
|
||||
private collectionItems = 0;
|
||||
private renderedNodes = 0;
|
||||
|
||||
public constructor(private readonly limits: EvaluationBudgets) {}
|
||||
|
||||
public operation(depth: number): void {
|
||||
if (depth > this.limits.maxAstDepth) {
|
||||
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
|
||||
}
|
||||
this.operations += 1;
|
||||
if (this.operations > this.limits.maxOperations) {
|
||||
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
|
||||
}
|
||||
}
|
||||
|
||||
public collection(count = 1): void {
|
||||
this.collectionItems += count;
|
||||
if (this.collectionItems > this.limits.maxCollectionItems) {
|
||||
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
|
||||
}
|
||||
}
|
||||
|
||||
public rendered(): void {
|
||||
this.renderedNodes += 1;
|
||||
if (this.renderedNodes > this.limits.maxRenderedNodes) {
|
||||
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
|
||||
}
|
||||
}
|
||||
|
||||
public string(value: string): string {
|
||||
if (value.length > this.limits.maxStringLength) {
|
||||
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function asNode(value: unknown, context: string): AstNode {
|
||||
if (!value || typeof value !== "object" || typeof (value as { type?: unknown }).type !== "string") {
|
||||
throw new SafeJsxError(`Invalid ${context} node`);
|
||||
}
|
||||
return value as AstNode;
|
||||
}
|
||||
|
||||
function asNodeArray(value: unknown, context: string): AstNode[] {
|
||||
if (!Array.isArray(value)) throw new SafeJsxError(`Invalid ${context}`);
|
||||
return value.map((item) => asNode(item, context));
|
||||
}
|
||||
|
||||
function normalizedProperty(value: unknown): string {
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
throw new SafeJsxError("Property names must be strings or numbers");
|
||||
}
|
||||
const property = String(value);
|
||||
const normalized = property.normalize("NFKC").toLowerCase();
|
||||
if (BLOCKED_PROPERTIES.has(normalized)) {
|
||||
throw new SafeJsxError(`Access to reflective property '${property}' is not allowed`);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
function isArrayIndex(property: string): boolean {
|
||||
if (!/^(?:0|[1-9]\d*)$/.test(property)) return false;
|
||||
const index = Number(property);
|
||||
return Number.isSafeInteger(index) && index >= 0;
|
||||
}
|
||||
|
||||
function ownProperty(object: unknown, propertyValue: unknown): unknown {
|
||||
const property = normalizedProperty(propertyValue);
|
||||
if (object == null) return undefined;
|
||||
|
||||
if (Array.isArray(object)) {
|
||||
if (property === "length") return object.length;
|
||||
if (!isArrayIndex(property)) return undefined;
|
||||
return Object.hasOwn(object, property) ? object[Number(property)] : undefined;
|
||||
}
|
||||
|
||||
if (typeof object === "string") {
|
||||
if (property === "length") return object.length;
|
||||
if (!isArrayIndex(property)) return undefined;
|
||||
return object[Number(property)];
|
||||
}
|
||||
|
||||
if (typeof object !== "object") return undefined;
|
||||
return Object.hasOwn(object, property) ? (object as Record<string, unknown>)[property] : undefined;
|
||||
}
|
||||
|
||||
function isSafeUrl(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
if (value.startsWith("#")) return true;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStyle(value: unknown): Record<string, string | number> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const result: Record<string, string | number> = {};
|
||||
for (const [key, styleValue] of Object.entries(value)) {
|
||||
if (BLOCKED_STYLE_KEYS.has(key) || key.startsWith("--")) continue;
|
||||
if (typeof styleValue !== "string" && typeof styleValue !== "number") continue;
|
||||
if (typeof styleValue === "string" && /(?:url\s*\(|expression\s*\(|javascript:|position\s*:)/i.test(styleValue)) {
|
||||
continue;
|
||||
}
|
||||
result[key] = styleValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isInterpreterCallback(value: unknown): value is InterpreterCallback {
|
||||
return Boolean(value && typeof value === "object" && (value as { kind?: unknown }).kind === INTERPRETER_CALLBACK);
|
||||
}
|
||||
|
||||
export function sanitizeCustomJsxProps(
|
||||
props: Readonly<Record<string, unknown>>,
|
||||
componentName?: string,
|
||||
): Record<string, unknown> {
|
||||
const safe: Record<string, unknown> = {};
|
||||
const supportedProps = componentName ? customJsxSupportedPropsByName.get(componentName) : undefined;
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if ((/^on/i.test(key) && !supportedProps?.has(key)) || BLOCKED_PROPS.has(key)) continue;
|
||||
if (componentName && !supportedProps?.has(key)) continue;
|
||||
if (typeof value === "function" || isInterpreterCallback(value)) continue;
|
||||
if (URL_PROPS.has(key)) {
|
||||
if (isSafeUrl(value)) safe[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "style") {
|
||||
const style = sanitizeStyle(value);
|
||||
if (style) safe.style = style;
|
||||
continue;
|
||||
}
|
||||
safe[key] = value;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function jsxText(value: string): string {
|
||||
const lines = value.replace(/\r/g, "").split("\n");
|
||||
let result = "";
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let line = lines[index] ?? "";
|
||||
line = line.replace(/\t/g, " ");
|
||||
if (index !== 0) line = line.replace(/^\s+/, "");
|
||||
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
|
||||
if (!line) continue;
|
||||
if (result) result += " ";
|
||||
result += line;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function numericArgument(value: unknown, fallback: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.trunc(number);
|
||||
}
|
||||
|
||||
function normalizedSliceIndex(value: unknown, length: number, fallback: number): number {
|
||||
const index = numericArgument(value, fallback);
|
||||
if (index < 0) return Math.max(length + index, 0);
|
||||
return Math.min(index, length);
|
||||
}
|
||||
|
||||
class Interpreter {
|
||||
private readonly budget: Budget;
|
||||
private readonly warnings = new Set<string>();
|
||||
|
||||
public constructor(
|
||||
private readonly components: Readonly<Record<string, ComponentType<never>>>,
|
||||
private readonly environment: Environment,
|
||||
private readonly limits: EvaluationBudgets,
|
||||
) {
|
||||
this.budget = new Budget(limits);
|
||||
}
|
||||
|
||||
public render(root: AstNode): SafeJsxRenderResult {
|
||||
return { node: this.evaluate(root, this.environment, 0) as ReactNode, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
private evaluate(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
this.budget.operation(depth);
|
||||
|
||||
switch (node.type) {
|
||||
case "Literal":
|
||||
return node.value;
|
||||
case "Identifier":
|
||||
return environment.get(String(node.name));
|
||||
case "JSXElement":
|
||||
return this.evaluateJsxElement(node, environment, depth + 1);
|
||||
case "JSXFragment":
|
||||
return this.evaluateJsxFragment(node, environment, depth + 1);
|
||||
case "JSXText": {
|
||||
const text = this.budget.string(jsxText(String(node.value ?? "")));
|
||||
if (text) this.budget.rendered();
|
||||
return text || null;
|
||||
}
|
||||
case "JSXExpressionContainer": {
|
||||
const expression = asNode(node.expression, "JSX expression");
|
||||
if (expression.type === "JSXEmptyExpression") return null;
|
||||
const value = this.evaluate(expression, environment, depth + 1);
|
||||
return typeof value === "string" ? this.budget.string(value) : value;
|
||||
}
|
||||
case "ArrayExpression":
|
||||
return this.evaluateArray(node, environment, depth + 1);
|
||||
case "ObjectExpression":
|
||||
return this.evaluateObject(node, environment, depth + 1);
|
||||
case "UnaryExpression":
|
||||
return this.evaluateUnary(node, environment, depth + 1);
|
||||
case "BinaryExpression":
|
||||
return this.evaluateBinary(node, environment, depth + 1);
|
||||
case "LogicalExpression":
|
||||
return this.evaluateLogical(node, environment, depth + 1);
|
||||
case "ConditionalExpression":
|
||||
return this.evaluate(node.test as AstNode, environment, depth + 1)
|
||||
? this.evaluate(asNode(node.consequent, "conditional consequent"), environment, depth + 1)
|
||||
: this.evaluate(asNode(node.alternate, "conditional alternate"), environment, depth + 1);
|
||||
case "MemberExpression":
|
||||
return this.evaluateMember(node, environment, depth + 1);
|
||||
case "CallExpression":
|
||||
return this.evaluateCall(node, environment, depth + 1);
|
||||
case "ArrowFunctionExpression":
|
||||
return this.createCallback(node, environment);
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node, environment, depth + 1);
|
||||
case "ChainExpression":
|
||||
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateJsxFragment(node: AstNode, environment: Environment, depth: number): ReactNode {
|
||||
const children = asNodeArray(node.children, "fragment children").map((child) =>
|
||||
this.evaluate(child, environment, depth + 1),
|
||||
);
|
||||
this.budget.rendered();
|
||||
return createElement(Fragment, null, ...(children as ReactNode[]));
|
||||
}
|
||||
|
||||
private evaluateJsxElement(node: AstNode, environment: Environment, depth: number): ReactNode {
|
||||
const opening = asNode(node.openingElement, "JSX opening element");
|
||||
const tag = this.jsxTagName(asNode(opening.name, "JSX tag"));
|
||||
if (BLOCKED_TAGS.has(tag.toLowerCase())) {
|
||||
this.warnings.add(`Blocked element: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
const component = this.components[tag];
|
||||
if (!component) {
|
||||
this.warnings.add(`Unknown or unavailable component: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawProps: Record<string, unknown> = {};
|
||||
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const spread = this.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
|
||||
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
|
||||
Object.assign(rawProps, spread);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
|
||||
const nameNode = asNode(attribute.name, "attribute name");
|
||||
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
|
||||
const name = normalizedProperty(nameNode.name);
|
||||
if (attribute.value == null) {
|
||||
rawProps[name] = true;
|
||||
} else {
|
||||
const valueNode = asNode(attribute.value, "attribute value");
|
||||
rawProps[name] =
|
||||
valueNode.type === "Literal" ? valueNode.value : this.evaluate(valueNode, environment, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
let children = asNodeArray(node.children, "JSX children").map((child) =>
|
||||
this.evaluate(child, environment, depth + 1),
|
||||
);
|
||||
const props = sanitizeCustomJsxProps(rawProps, tag);
|
||||
if (tag === "SubFetch") {
|
||||
const callbackChildren = children.filter(isInterpreterCallback);
|
||||
const callbackProp = rawProps.render;
|
||||
if (callbackProp !== undefined && !isInterpreterCallback(callbackProp)) {
|
||||
throw new SafeJsxError("SubFetch render must be an inline arrow callback");
|
||||
}
|
||||
if (callbackChildren.length > 1 || (callbackChildren.length === 1 && callbackProp !== undefined)) {
|
||||
throw new SafeJsxError("SubFetch accepts only one render callback");
|
||||
}
|
||||
const renderCallback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
|
||||
if (renderCallback) {
|
||||
props.render = (value: unknown, metadata: unknown) =>
|
||||
new Interpreter(this.components, renderCallback.environment, this.limits).runCallback(
|
||||
renderCallback,
|
||||
[value, metadata],
|
||||
0,
|
||||
) as ReactNode;
|
||||
children = children.filter((child) => child !== renderCallback);
|
||||
}
|
||||
}
|
||||
this.budget.rendered();
|
||||
return createElement(component, props as never, ...(children as ReactNode[]));
|
||||
}
|
||||
|
||||
private jsxTagName(node: AstNode): string {
|
||||
if (node.type === "JSXIdentifier") return String(node.name);
|
||||
if (node.type === "JSXMemberExpression") {
|
||||
return `${this.jsxTagName(asNode(node.object, "JSX member object"))}.${this.jsxTagName(
|
||||
asNode(node.property, "JSX member property"),
|
||||
)}`;
|
||||
}
|
||||
throw new SafeJsxError(`Unsupported JSX tag: ${node.type}`);
|
||||
}
|
||||
|
||||
private evaluateArray(node: AstNode, environment: Environment, depth: number): unknown[] {
|
||||
const result: unknown[] = [];
|
||||
const elements = node.elements;
|
||||
if (!Array.isArray(elements)) throw new SafeJsxError("Invalid array expression");
|
||||
for (const elementValue of elements) {
|
||||
if (elementValue == null) {
|
||||
result.push(undefined);
|
||||
continue;
|
||||
}
|
||||
const element = asNode(elementValue, "array element");
|
||||
if (element.type === "SpreadElement") {
|
||||
const spread = this.evaluate(asNode(element.argument, "array spread"), environment, depth + 1);
|
||||
if (!Array.isArray(spread)) throw new SafeJsxError("Only arrays can be spread into arrays");
|
||||
this.budget.collection(spread.length);
|
||||
result.push(...spread);
|
||||
} else {
|
||||
this.budget.collection();
|
||||
result.push(this.evaluate(element, environment, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateObject(node: AstNode, environment: Environment, depth: number): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = Object.create(null);
|
||||
for (const property of asNodeArray(node.properties, "object properties")) {
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = this.evaluate(asNode(property.argument, "object spread"), environment, depth + 1);
|
||||
if (!spread || typeof spread !== "object" || Array.isArray(spread)) {
|
||||
throw new SafeJsxError("Only objects can be spread into objects");
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) result[normalizedProperty(key)] = value;
|
||||
continue;
|
||||
}
|
||||
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
|
||||
throw new SafeJsxError("Only explicit object properties are supported");
|
||||
}
|
||||
const keyNode = asNode(property.key, "object key");
|
||||
const key = normalizedProperty(
|
||||
property.computed
|
||||
? this.evaluate(keyNode, environment, depth + 1)
|
||||
: keyNode.type === "Identifier"
|
||||
? keyNode.name
|
||||
: keyNode.value,
|
||||
);
|
||||
result[key] = this.evaluate(asNode(property.value, "object value"), environment, depth + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateUnary(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const value = this.evaluate(asNode(node.argument, "unary argument"), environment, depth + 1);
|
||||
switch (node.operator) {
|
||||
case "!":
|
||||
return !value;
|
||||
case "+":
|
||||
return Number(value);
|
||||
case "-":
|
||||
return -Number(value);
|
||||
case "typeof":
|
||||
return typeof value;
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported unary operator: ${String(node.operator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateBinary(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const left = this.evaluate(asNode(node.left, "binary left operand"), environment, depth + 1);
|
||||
const right = this.evaluate(asNode(node.right, "binary right operand"), environment, depth + 1);
|
||||
switch (node.operator) {
|
||||
case "+":
|
||||
return typeof left === "string" || typeof right === "string"
|
||||
? this.budget.string(String(left) + String(right))
|
||||
: Number(left) + Number(right);
|
||||
case "-":
|
||||
return Number(left) - Number(right);
|
||||
case "*":
|
||||
return Number(left) * Number(right);
|
||||
case "/":
|
||||
return Number(left) / Number(right);
|
||||
case "%":
|
||||
return Number(left) % Number(right);
|
||||
case "**":
|
||||
return Number(left) ** Number(right);
|
||||
case "<":
|
||||
return (left as number) < (right as number);
|
||||
case "<=":
|
||||
return (left as number) <= (right as number);
|
||||
case ">":
|
||||
return (left as number) > (right as number);
|
||||
case ">=":
|
||||
return (left as number) >= (right as number);
|
||||
case "==":
|
||||
return String(left) === String(right);
|
||||
case "!=":
|
||||
return String(left) !== String(right);
|
||||
case "===":
|
||||
return left === right;
|
||||
case "!==":
|
||||
return left !== right;
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported binary operator: ${String(node.operator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateLogical(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const left = this.evaluate(asNode(node.left, "logical left operand"), environment, depth + 1);
|
||||
if (node.operator === "&&") {
|
||||
return left ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
|
||||
}
|
||||
if (node.operator === "||") {
|
||||
return left ? left : this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1);
|
||||
}
|
||||
if (node.operator === "??") {
|
||||
return left == null ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
|
||||
}
|
||||
throw new SafeJsxError(`Unsupported logical operator: ${String(node.operator)}`);
|
||||
}
|
||||
|
||||
private memberProperty(node: AstNode, environment: Environment, depth: number): string {
|
||||
const propertyNode = asNode(node.property, "member property");
|
||||
return normalizedProperty(
|
||||
node.computed
|
||||
? this.evaluate(propertyNode, environment, depth + 1)
|
||||
: propertyNode.type === "Identifier"
|
||||
? propertyNode.name
|
||||
: propertyNode.value,
|
||||
);
|
||||
}
|
||||
|
||||
private evaluateMember(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const object = this.evaluate(asNode(node.object, "member object"), environment, depth + 1);
|
||||
const property = this.memberProperty(node, environment, depth + 1);
|
||||
return ownProperty(object, property);
|
||||
}
|
||||
|
||||
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
|
||||
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
|
||||
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
|
||||
return this.evaluate(argument, environment, depth + 1);
|
||||
});
|
||||
const callee = asNode(node.callee, "call target");
|
||||
|
||||
if (callee.type === "MemberExpression") {
|
||||
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
|
||||
const method = this.memberProperty(callee, environment, depth + 1);
|
||||
const collectionResult = this.callCollectionMethod(receiver, method, args, depth + 1);
|
||||
if (collectionResult.handled) return collectionResult.value;
|
||||
const callable = ownProperty(receiver, method);
|
||||
if (!isSafeCallable(callable)) throw new SafeJsxError(`Calling method '${method}' is not allowed`);
|
||||
return this.accountHostResult(callable(...(args as never[])));
|
||||
}
|
||||
|
||||
const callable = this.evaluate(callee, environment, depth + 1);
|
||||
if (!isSafeCallable(callable)) throw new SafeJsxError("Calling this value is not allowed");
|
||||
return this.accountHostResult(callable(...(args as never[])));
|
||||
}
|
||||
|
||||
private callCollectionMethod(
|
||||
receiver: unknown,
|
||||
method: string,
|
||||
args: unknown[],
|
||||
depth: number,
|
||||
): { handled: boolean; value?: unknown } {
|
||||
if (Array.isArray(receiver)) {
|
||||
if (method === "map" || method === "filter") {
|
||||
const callback = args[0];
|
||||
if (isSafeCallable(callback)) {
|
||||
const result: unknown[] = [];
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
const callbackResult = callback(receiver[index] as never);
|
||||
if (method === "map") result.push(callbackResult);
|
||||
else if (callbackResult) result.push(receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError(`${method} requires an inline arrow callback`);
|
||||
const result: unknown[] = [];
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
const callbackResult = this.runCallback(callback, [receiver[index], index, receiver], depth + 1);
|
||||
if (method === "map" || callbackResult) result.push(method === "map" ? callbackResult : receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "pop") {
|
||||
this.budget.collection();
|
||||
return { handled: true, value: receiver.length > 0 ? receiver[receiver.length - 1] : undefined };
|
||||
}
|
||||
if (method === "slice") {
|
||||
const start = normalizedSliceIndex(args[0], receiver.length, 0);
|
||||
const end = normalizedSliceIndex(args[1], receiver.length, receiver.length);
|
||||
const result: unknown[] = [];
|
||||
for (let index = start; index < Math.max(start, end); index += 1) {
|
||||
this.budget.collection();
|
||||
result.push(receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "join") {
|
||||
this.budget.collection(receiver.length);
|
||||
const separator = String(args[0] ?? ",");
|
||||
let result = "";
|
||||
receiver.forEach((item, index) => {
|
||||
result += `${index === 0 ? "" : separator}${String(item ?? "")}`;
|
||||
this.budget.string(result);
|
||||
});
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "includes") {
|
||||
this.budget.collection(receiver.length);
|
||||
return { handled: true, value: receiver.some((item) => Object.is(item, args[0])) };
|
||||
}
|
||||
if (method === "indexOf") {
|
||||
this.budget.collection(receiver.length);
|
||||
return { handled: true, value: receiver.findIndex((item) => Object.is(item, args[0])) };
|
||||
}
|
||||
if (method === "find" || method === "findIndex" || method === "some" || method === "every") {
|
||||
const callback = args[0];
|
||||
const useSafe = isSafeCallable(callback);
|
||||
if (!useSafe && !isInterpreterCallback(callback))
|
||||
throw new SafeJsxError(`${method} requires an inline arrow callback`);
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
const result = useSafe
|
||||
? (callback as (...a: never[]) => unknown)(receiver[index] as never)
|
||||
: this.runCallback(callback as InterpreterCallback, [receiver[index], index, receiver], depth + 1);
|
||||
if (method === "find" && result) return { handled: true, value: receiver[index] };
|
||||
if (method === "findIndex" && result) return { handled: true, value: index };
|
||||
if (method === "some" && result) return { handled: true, value: true };
|
||||
if (method === "every" && !result) return { handled: true, value: false };
|
||||
}
|
||||
const defaults: Record<string, unknown> = { find: undefined, findIndex: -1, some: false, every: true };
|
||||
return { handled: true, value: defaults[method] };
|
||||
}
|
||||
if (method === "sort") {
|
||||
const callback = args[0];
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError("sort requires an inline arrow callback");
|
||||
const copy = [...receiver];
|
||||
copy.sort((left, right) => {
|
||||
this.budget.collection();
|
||||
const delta = Number(this.runCallback(callback, [left, right], depth + 1));
|
||||
if (!Number.isFinite(delta)) return 0;
|
||||
return delta;
|
||||
});
|
||||
return { handled: true, value: copy };
|
||||
}
|
||||
if (method === "reduce") {
|
||||
const callback = args[0];
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError("reduce requires an inline arrow callback");
|
||||
if (receiver.length === 0 && args.length < 2) {
|
||||
throw new SafeJsxError("Reduce of empty array with no initial value");
|
||||
}
|
||||
let startIndex = 0;
|
||||
let accumulator: unknown;
|
||||
if (args.length < 2) {
|
||||
accumulator = receiver[0];
|
||||
startIndex = 1;
|
||||
} else {
|
||||
accumulator = args[1];
|
||||
}
|
||||
for (let index = startIndex; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
accumulator = this.runCallback(callback, [accumulator, receiver[index], index, receiver], depth + 1);
|
||||
}
|
||||
return { handled: true, value: accumulator };
|
||||
}
|
||||
if (method === "at") {
|
||||
const index = numericArgument(args[0], 0);
|
||||
let normalizedIndex = index;
|
||||
if (index < 0) normalizedIndex = receiver.length + index;
|
||||
this.budget.collection();
|
||||
const outOfBounds = normalizedIndex < 0 || normalizedIndex >= receiver.length;
|
||||
return { handled: true, value: outOfBounds ? undefined : receiver[normalizedIndex] };
|
||||
}
|
||||
if (method === "flat") {
|
||||
// ponytail: only depth=1 supported; deeper nesting requires recursive budget tracking
|
||||
const depth = numericArgument(args[0], 1);
|
||||
if (depth !== 1) throw new SafeJsxError("flat() only supports depth 1");
|
||||
const result: unknown[] = [];
|
||||
for (const item of receiver) {
|
||||
this.budget.collection();
|
||||
if (Array.isArray(item)) {
|
||||
for (const nested of item) {
|
||||
this.budget.collection();
|
||||
result.push(nested);
|
||||
}
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "reverse") {
|
||||
const copy = [...receiver];
|
||||
const result: unknown[] = [];
|
||||
for (let index = copy.length - 1; index >= 0; index -= 1) {
|
||||
this.budget.collection();
|
||||
result.push(copy[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof receiver === "string") {
|
||||
switch (method) {
|
||||
case "toUpperCase":
|
||||
return { handled: true, value: this.budget.string(receiver.toUpperCase()) };
|
||||
case "toLowerCase":
|
||||
return { handled: true, value: this.budget.string(receiver.toLowerCase()) };
|
||||
case "trim":
|
||||
return { handled: true, value: this.budget.string(receiver.trim()) };
|
||||
case "trimStart":
|
||||
return { handled: true, value: this.budget.string(receiver.trimStart()) };
|
||||
case "trimEnd":
|
||||
return { handled: true, value: this.budget.string(receiver.trimEnd()) };
|
||||
case "replace": {
|
||||
const search = String(args[0] ?? "");
|
||||
const replacement = String(args[1] ?? "");
|
||||
const matchIndex = receiver.indexOf(search);
|
||||
if (matchIndex === -1) return { handled: true, value: this.budget.string(receiver) };
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(
|
||||
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
|
||||
),
|
||||
};
|
||||
}
|
||||
case "replaceAll": {
|
||||
const search = String(args[0] ?? "");
|
||||
const replacement = String(args[1] ?? "");
|
||||
if (search.length === 0) return { handled: true, value: this.budget.string(receiver) };
|
||||
let result = "";
|
||||
let cursor = 0;
|
||||
while (cursor < receiver.length) {
|
||||
const matchIndex = receiver.indexOf(search, cursor);
|
||||
if (matchIndex === -1) {
|
||||
result += receiver.slice(cursor);
|
||||
break;
|
||||
}
|
||||
result += receiver.slice(cursor, matchIndex) + replacement;
|
||||
cursor = matchIndex + search.length;
|
||||
}
|
||||
return { handled: true, value: this.budget.string(result) };
|
||||
}
|
||||
case "indexOf":
|
||||
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "lastIndexOf": {
|
||||
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
|
||||
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
|
||||
}
|
||||
case "repeat": {
|
||||
let maxCount = 0;
|
||||
if (receiver.length > 0) maxCount = Math.floor(200_001 / receiver.length);
|
||||
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
|
||||
return { handled: true, value: this.budget.string(receiver.repeat(count).slice(0, 200_001)) };
|
||||
}
|
||||
case "slice":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
|
||||
};
|
||||
case "substring":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
|
||||
};
|
||||
case "includes":
|
||||
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "startsWith":
|
||||
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "endsWith":
|
||||
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
|
||||
case "padStart":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(
|
||||
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
|
||||
),
|
||||
};
|
||||
case "padEnd":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(
|
||||
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
|
||||
),
|
||||
};
|
||||
case "charAt":
|
||||
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
|
||||
case "split": {
|
||||
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 100)));
|
||||
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
|
||||
this.budget.collection(split.length);
|
||||
return { handled: true, value: split };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof receiver === "number") {
|
||||
if (method === "toFixed") {
|
||||
const digits = Math.max(0, Math.min(20, numericArgument(args[0], 0)));
|
||||
return { handled: true, value: receiver.toFixed(digits) };
|
||||
}
|
||||
if (method === "toPrecision") {
|
||||
const precision = Math.max(1, Math.min(21, numericArgument(args[0], 1)));
|
||||
return { handled: true, value: receiver.toPrecision(precision) };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof receiver === "number" || typeof receiver === "boolean" || typeof receiver === "string") &&
|
||||
method === "toString"
|
||||
) {
|
||||
return { handled: true, value: this.budget.string(String(receiver)) };
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
private accountHostResult(value: unknown): unknown {
|
||||
if (typeof value === "string") return this.budget.string(value);
|
||||
if (Array.isArray(value)) this.budget.collection(value.length);
|
||||
return value;
|
||||
}
|
||||
|
||||
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
|
||||
}
|
||||
const params = asNodeArray(node.params, "callback parameters").map((param) => {
|
||||
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
|
||||
return normalizedProperty(param.name);
|
||||
});
|
||||
return {
|
||||
kind: INTERPRETER_CALLBACK,
|
||||
params,
|
||||
body: asNode(node.body, "callback body"),
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
|
||||
const values: Record<string, unknown> = Object.create(null);
|
||||
callback.params.forEach((name, index) => {
|
||||
values[name] = args[index];
|
||||
});
|
||||
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
|
||||
const quasis = asNodeArray(node.quasis, "template literal parts");
|
||||
const expressions = asNodeArray(node.expressions, "template literal expressions");
|
||||
let result = "";
|
||||
quasis.forEach((quasi, index) => {
|
||||
const value = quasi.value;
|
||||
if (!value || typeof value !== "object") throw new SafeJsxError("Invalid template literal part");
|
||||
result += String((value as { cooked?: unknown }).cooked ?? "");
|
||||
const expression = expressions[index];
|
||||
if (expression) result += String(this.evaluate(expression, environment, depth + 1));
|
||||
});
|
||||
return this.budget.string(result);
|
||||
}
|
||||
}
|
||||
|
||||
function parseTemplate(template: string): AstNode {
|
||||
if (template.length > MAX_TEMPLATE_LENGTH) {
|
||||
throw new SafeJsxError(`Template exceeds the ${MAX_TEMPLATE_LENGTH} character limit`);
|
||||
}
|
||||
let program: AstNode;
|
||||
try {
|
||||
program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowAwaitOutsideFunction: false,
|
||||
allowReturnOutsideFunction: false,
|
||||
}) as unknown as AstNode;
|
||||
} catch (error) {
|
||||
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
|
||||
}
|
||||
const body = asNodeArray(program.body, "program body");
|
||||
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
|
||||
throw new SafeJsxError("Template must contain JSX only");
|
||||
}
|
||||
return asNode(body[0].expression, "template expression");
|
||||
}
|
||||
|
||||
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
|
||||
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
|
||||
}
|
||||
const root = parseTemplate(template);
|
||||
return new Interpreter(components, new Environment(bindings), limits).render(root);
|
||||
}
|
||||
@@ -3,12 +3,49 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const WidgetDefinitionContext = createContext<string>("");
|
||||
export interface CustomJsxRuntimeContextValue {
|
||||
/** The placed board item used for all authorization decisions. */
|
||||
itemId?: string;
|
||||
/** Kept temporarily for display-only v1 templates. Never used for network authorization. */
|
||||
definitionId?: string;
|
||||
/** Short-lived, user-bound session used only by the admin authoring preview. */
|
||||
previewSessionId?: string;
|
||||
previewLiveActions?: boolean;
|
||||
isEditMode: boolean;
|
||||
requestCapabilities?: readonly CustomJsxRequestCapability[];
|
||||
}
|
||||
|
||||
export function WidgetDefinitionProvider({ definitionId, children }: { definitionId: string; children: ReactNode }) {
|
||||
return <WidgetDefinitionContext.Provider value={definitionId}>{children}</WidgetDefinitionContext.Provider>;
|
||||
export interface CustomJsxRequestCapability {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
}
|
||||
|
||||
const CustomJsxRuntimeContext = createContext<CustomJsxRuntimeContextValue>({ isEditMode: false });
|
||||
|
||||
export function WidgetDefinitionProvider({
|
||||
itemId,
|
||||
definitionId,
|
||||
previewSessionId,
|
||||
previewLiveActions = false,
|
||||
isEditMode = false,
|
||||
requestCapabilities = [],
|
||||
children,
|
||||
}: CustomJsxRuntimeContextValue & { children: ReactNode }) {
|
||||
return (
|
||||
<CustomJsxRuntimeContext.Provider
|
||||
value={{ itemId, definitionId, previewSessionId, previewLiveActions, isEditMode, requestCapabilities }}
|
||||
>
|
||||
{children}
|
||||
</CustomJsxRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWidgetDefinitionId(): string {
|
||||
return useContext(WidgetDefinitionContext);
|
||||
return useContext(CustomJsxRuntimeContext).definitionId ?? "";
|
||||
}
|
||||
|
||||
export function useCustomJsxRuntime(): CustomJsxRuntimeContextValue {
|
||||
return useContext(CustomJsxRuntimeContext);
|
||||
}
|
||||
Generated
+144
-48
@@ -12,6 +12,24 @@ catalogs:
|
||||
'@auth/drizzle-adapter':
|
||||
specifier: ^1.11.2
|
||||
version: 1.11.2
|
||||
'@codemirror/autocomplete':
|
||||
specifier: ^6.20.3
|
||||
version: 6.20.3
|
||||
'@codemirror/commands':
|
||||
specifier: ^6.10.3
|
||||
version: 6.10.3
|
||||
'@codemirror/lang-javascript':
|
||||
specifier: ^6.2.5
|
||||
version: 6.2.5
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lint':
|
||||
specifier: ^6.9.6
|
||||
version: 6.9.6
|
||||
'@codemirror/view':
|
||||
specifier: ^6.43.0
|
||||
version: 6.43.0
|
||||
'@ctrl/deluge':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
@@ -267,6 +285,9 @@ catalogs:
|
||||
'@types/xml2js':
|
||||
specifier: ^0.4.14
|
||||
version: 0.4.14
|
||||
'@uiw/react-codemirror':
|
||||
specifier: ^4.25.11
|
||||
version: 4.25.11
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
@@ -285,6 +306,12 @@ catalogs:
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
acorn:
|
||||
specifier: ^8.16.0
|
||||
version: 8.16.0
|
||||
acorn-jsx:
|
||||
specifier: ^5.3.2
|
||||
version: 5.3.2
|
||||
adm-zip:
|
||||
specifier: 0.5.18
|
||||
version: 0.5.18
|
||||
@@ -462,9 +489,6 @@ catalogs:
|
||||
react-error-boundary:
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2
|
||||
react-jsx-parser:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
@@ -782,6 +806,24 @@ importers:
|
||||
|
||||
apps/nextjs:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete':
|
||||
specifier: 'catalog:'
|
||||
version: 6.20.3
|
||||
'@codemirror/commands':
|
||||
specifier: 'catalog:'
|
||||
version: 6.10.3
|
||||
'@codemirror/lang-javascript':
|
||||
specifier: 'catalog:'
|
||||
version: 6.2.5
|
||||
'@codemirror/lang-json':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.2
|
||||
'@codemirror/lint':
|
||||
specifier: 'catalog:'
|
||||
version: 6.9.6
|
||||
'@codemirror/view':
|
||||
specifier: 'catalog:'
|
||||
version: 6.43.0
|
||||
'@dnd-kit/core':
|
||||
specifier: 'catalog:'
|
||||
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -944,6 +986,9 @@ importers:
|
||||
'@trpc/server':
|
||||
specifier: 'catalog:'
|
||||
version: 11.18.0(typescript@6.0.3)
|
||||
'@uiw/react-codemirror':
|
||||
specifier: 'catalog:'
|
||||
version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@xterm/addon-canvas':
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.0(@xterm/xterm@6.0.0)
|
||||
@@ -1350,6 +1395,9 @@ importers:
|
||||
trpc-to-openapi:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.0(patch_hash=2ca3c16af0fcca0c736697ad4fe553a14f794524fa9ce0d5c3e8ee4aea76090c)(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.3.0(zod@4.4.3))(zod@4.4.3)
|
||||
undici:
|
||||
specifier: 'catalog:'
|
||||
version: 7.28.0
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 4.4.3
|
||||
@@ -2496,6 +2544,12 @@ importers:
|
||||
'@homarr/translation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../translation
|
||||
acorn:
|
||||
specifier: 'catalog:'
|
||||
version: 8.16.0
|
||||
acorn-jsx:
|
||||
specifier: 'catalog:'
|
||||
version: 5.3.2(acorn@8.16.0)
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 4.4.3
|
||||
@@ -2647,6 +2701,12 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: 'catalog:'
|
||||
version: 3.27.1
|
||||
acorn:
|
||||
specifier: 'catalog:'
|
||||
version: 8.16.0
|
||||
acorn-jsx:
|
||||
specifier: 'catalog:'
|
||||
version: 5.3.2(acorn@8.16.0)
|
||||
clsx:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.1
|
||||
@@ -2674,9 +2734,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.7(react@19.2.7)
|
||||
react-jsx-parser:
|
||||
specifier: 'catalog:'
|
||||
version: 2.4.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react-markdown:
|
||||
specifier: 'catalog:'
|
||||
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -3597,9 +3654,15 @@ packages:
|
||||
'@codemirror/lint@6.9.6':
|
||||
resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==}
|
||||
|
||||
'@codemirror/search@6.7.1':
|
||||
resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==}
|
||||
|
||||
'@codemirror/state@6.6.0':
|
||||
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.43.0':
|
||||
resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==}
|
||||
|
||||
@@ -7901,20 +7964,12 @@ packages:
|
||||
'@types/prismjs@1.26.6':
|
||||
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/qs@6.9.16':
|
||||
resolution: {integrity: sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==}
|
||||
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
'@types/react-dom@18.3.7':
|
||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.0.0
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -7929,9 +7984,6 @@ packages:
|
||||
'@types/react-router@5.1.20':
|
||||
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
|
||||
|
||||
'@types/react@18.3.31':
|
||||
resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
|
||||
|
||||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
@@ -8010,6 +8062,28 @@ packages:
|
||||
'@types/yargs@17.0.35':
|
||||
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.11':
|
||||
resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==}
|
||||
peerDependencies:
|
||||
'@codemirror/autocomplete': '>=6.0.0'
|
||||
'@codemirror/commands': '>=6.0.0'
|
||||
'@codemirror/language': '>=6.0.0'
|
||||
'@codemirror/lint': '>=6.0.0'
|
||||
'@codemirror/search': '>=6.0.0'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
|
||||
'@uiw/react-codemirror@4.25.11':
|
||||
resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '>=7.29.7'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/theme-one-dark': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
codemirror: '>=6.0.0'
|
||||
react: '>=17.0.0'
|
||||
react-dom: '>=17.0.0'
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
@@ -8953,6 +9027,9 @@ packages:
|
||||
resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
codemirror@6.0.2:
|
||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||
|
||||
collapse-white-space@2.1.0:
|
||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||
|
||||
@@ -13375,13 +13452,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-jsx-parser@2.4.1:
|
||||
resolution: {integrity: sha512-gE25DiHseuKmSbb5O1K1IkyQ/bwth3zl4z3EdW8XMhwrb2agUpuh8GvqHFvGwh/wdZjDDuadGrSR2lEgjcOvqQ==}
|
||||
engines: {bun: ^1.1.27}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-loadable-ssr-addon-v5-slorber@1.0.3:
|
||||
resolution: {integrity: sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -16510,10 +16580,23 @@ snapshots:
|
||||
'@codemirror/view': 6.43.0
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/search@6.7.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/state@6.6.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.43.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
@@ -21580,18 +21663,10 @@ snapshots:
|
||||
|
||||
'@types/prismjs@1.26.6': {}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
optional: true
|
||||
|
||||
'@types/qs@6.9.16': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.31)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.31
|
||||
optional: true
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.17
|
||||
@@ -21613,12 +21688,6 @@ snapshots:
|
||||
'@types/history': 4.7.11
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@types/react@18.3.31':
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.15
|
||||
csstype: 3.2.3
|
||||
optional: true
|
||||
|
||||
'@types/react@19.2.17':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@@ -21706,6 +21775,33 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.6
|
||||
'@codemirror/search': 6.7.1
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
|
||||
'@uiw/react-codemirror@4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/theme-one-dark': 6.1.3
|
||||
'@codemirror/view': 6.43.0
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
|
||||
codemirror: 6.0.2
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/autocomplete'
|
||||
- '@codemirror/language'
|
||||
- '@codemirror/lint'
|
||||
- '@codemirror/search'
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@unhead/vue@2.1.15(vue@3.5.35(typescript@6.0.3))':
|
||||
@@ -22757,6 +22853,16 @@ snapshots:
|
||||
raw-body: 2.5.3
|
||||
type-is: 1.6.18
|
||||
|
||||
codemirror@6.0.2:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.6
|
||||
'@codemirror/search': 6.7.1
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
|
||||
color-convert@1.9.3:
|
||||
@@ -27766,16 +27872,6 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
react-jsx-parser@2.4.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn-jsx: 5.3.2(acorn@8.16.0)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.31
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.31)
|
||||
|
||||
react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.7))(webpack@5.107.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.16)):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
|
||||
+9
-1
@@ -3,6 +3,12 @@ packages:
|
||||
- packages/*
|
||||
- tooling/*
|
||||
catalog:
|
||||
"@codemirror/autocomplete": ^6.20.3
|
||||
"@codemirror/commands": ^6.10.3
|
||||
"@codemirror/lang-javascript": ^6.2.5
|
||||
"@codemirror/lang-json": ^6.0.2
|
||||
"@codemirror/lint": ^6.9.6
|
||||
"@codemirror/view": ^6.43.0
|
||||
"@auth/core": ^0.41.2
|
||||
"@auth/drizzle-adapter": ^1.11.2
|
||||
"@ctrl/deluge": ^8.0.0
|
||||
@@ -92,6 +98,7 @@ catalog:
|
||||
"@scalar/api-reference-react": ^0.9.51
|
||||
"@types/video.js": ^7.3.58
|
||||
"@types/ws": ^8.18.1
|
||||
"@uiw/react-codemirror": ^4.25.11
|
||||
"@types/xml2js": ^0.4.14
|
||||
posthog-js: ^1.396.4
|
||||
posthog-node: ^5.39.2
|
||||
@@ -102,6 +109,8 @@ catalog:
|
||||
"@xterm/addon-fit": 0.11.0
|
||||
"@xterm/xterm": ^6.0.0
|
||||
adm-zip: 0.5.18
|
||||
acorn: ^8.16.0
|
||||
acorn-jsx: ^5.3.2
|
||||
jszip: ^3.10.1
|
||||
sql.js: ^1.14.1
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
@@ -160,7 +169,6 @@ catalog:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7
|
||||
react-error-boundary: ^6.1.2
|
||||
react-jsx-parser: ^2.4.1
|
||||
react-markdown: ^10.1.0
|
||||
react-quill-new: ^3.8.3
|
||||
react-simple-code-editor: ^0.14.1
|
||||
|
||||
Reference in new issue
Block a user